diff --git a/changelog.d/366-model-labels.added.md b/changelog.d/366-model-labels.added.md new file mode 100644 index 00000000..0e405020 --- /dev/null +++ b/changelog.d/366-model-labels.added.md @@ -0,0 +1 @@ +You can now see which model each turn used and which model each Bot runs on: a Timeline turn's tooltip ends with the model that turn ran on (`· deepseek-v4-pro`), and hovering a Bot in the sidebar tree or Bot list shows its model (`Coder · DeepSeek V4 Pro`). Nothing new stores keys; models are still chosen per Bot in its settings ([#366](https://github.com/ThinkFlowLab/FlyBot/issues/366)). diff --git a/core/client.js b/core/client.js index 703a39f6..a91285bb 100644 --- a/core/client.js +++ b/core/client.js @@ -2677,6 +2677,17 @@ function faceRowTitle(title) { if (named) return `${named[2]} · ${named[1]}`; return text.match(/^Face-to-face · (\d{6})$/)?.[1] ?? text; } +function modelOf(bot, roster) { + const ref = bot.model ?? bot.appliedModel; + if (!ref) return void 0; + const served = (roster.models ?? []).find((model) => model.ref === ref); + const [provider, ...rest] = ref.split("/"); + return { ref, name: served?.name ?? rest.join("/"), provider: served?.providerName ?? provider, served: served !== void 0 }; +} +function botRowTitle(name, model, wide) { + if (model) return `${name} · ${model}`; + return wide ? void 0 : name; +} function awayGroups(groups, roomsById) { return (groups ?? []).filter((entry) => roomsById?.[entry.id]?.origin !== "face-to-face"); } @@ -6950,8 +6961,9 @@ function BotRow({ bot, roster, actions, useSessions, useSessionStatus, useSessio className: "bt-row", "aria-label": label, "aria-current": active ? "page" : void 0, + // An own Bot's hover text names its model (#366); a friend's Bot runs on its owner's model. "data-bot-id": id, - title: remoteRecord2 ? shown : wide ? void 0 : bot.name, + title: remoteRecord2 ? shown : botRowTitle(shown, modelOf(bot, roster)?.name, wide), onClick: () => actions.openSession(id), onContextMenu }, @@ -8800,13 +8812,6 @@ function groupModels(models) { }); } var servedOf = (roster, ref) => (roster.models ?? []).find((model) => model.ref === ref); -function modelOf(bot, roster) { - const ref = bot.model ?? bot.appliedModel; - if (!ref) return void 0; - const served = servedOf(roster, ref); - const [provider, ...rest] = ref.split("/"); - return { ref, name: served?.name ?? rest.join("/"), provider: served?.providerName ?? provider, served: served !== void 0 }; -} var effortCache = /* @__PURE__ */ new Map(); function useEfforts(served, ref, actions) { const [fetched, setFetched] = (0, import_react16.useState)(void 0); diff --git a/core/src/client/model-menu.js b/core/src/client/model-menu.js index f3dd2eb2..4898d400 100644 --- a/core/src/client/model-menu.js +++ b/core/src/client/model-menu.js @@ -1,6 +1,6 @@ import { createElement as h, Fragment, useEffect, useRef, useState, useSyncExternalStore } from 'react' import { createPortal } from 'react-dom' -import { currentPart } from './text.js' +import { currentPart, modelOf } from './text.js' import { CheckIcon, SlidersIcon } from './icons.js' import { BotMark } from './mark.js' import { openHarnessModels, openHarnessOnboarding } from './sidebar.js' @@ -33,14 +33,8 @@ export function groupModels(models) { const servedOf = (roster, ref) => (roster.models ?? []).find(model => model.ref === ref) -// What a Bot runs on, for display: the chosen model, else the one it last ran on. -export function modelOf(bot, roster) { - const ref = bot.model ?? bot.appliedModel - if (!ref) return undefined - const served = servedOf(roster, ref) - const [provider, ...rest] = ref.split('/') - return { ref, name: served?.name ?? rest.join('/'), provider: served?.providerName ?? provider, served: served !== undefined } -} +// `modelOf` lives in text.js so the sidebar can name a Bot's model without this module (#366). +export { modelOf } // The thinking levels of one model, `{efforts, defaultEffort?}` or undefined when it // has none. The catalog carries them for models Bots use; any other ref asks the host. diff --git a/core/src/client/sidebar.js b/core/src/client/sidebar.js index a4b8dd8a..a2062324 100644 --- a/core/src/client/sidebar.js +++ b/core/src/client/sidebar.js @@ -3,7 +3,7 @@ import { Component, createElement as h, Fragment, useEffect, useLayoutEffect, us import { createPortal } from 'react-dom' import { FishLogo } from '@deepseek-ai/dsh-client-ui-primitives' import { isMainOf } from './sources.js' -import { awayGroups, currentPart, faceRowTitle, nameMentions, previewOf, relativeShort, unsentReplyTurns } from './text.js' +import { awayGroups, botRowTitle, currentPart, faceRowTitle, modelOf, nameMentions, previewOf, relativeShort, unsentReplyTurns } from './text.js' import { PlusIcon, SearchIcon, GaugeIcon, PlugIcon, ClockIcon, GearIcon, PinMarkIcon, ConnectorIcon, SwitchIcon, CloudDownloadIcon, FlyBotMark } from './icons.js' import { MAIN_LOOK } from './characters.js' import { botState, BotMark, BotAvatar, RoomAvatar } from './mark.js' @@ -90,7 +90,8 @@ function BotRow({ bot, roster, actions, useSessions, useSessionStatus, useSessio : wide && unread && !active ? h('span', { className: 'bt-unread', 'aria-hidden': true }) : null const row = h('button', { type: 'button', className: 'bt-row', 'aria-label': label, 'aria-current': active ? 'page' : undefined, - 'data-bot-id': id, title: remoteRecord ? shown : wide ? undefined : bot.name, onClick: () => actions.openSession(id), onContextMenu, + // An own Bot's hover text names its model (#366); a friend's Bot runs on its owner's model. + 'data-bot-id': id, title: remoteRecord ? shown : botRowTitle(shown, modelOf(bot, roster)?.name, wide), onClick: () => actions.openSession(id), onContextMenu, }, h(BotAvatar, { bot, size: 36, state, main }), pinned && !wide ? h(PinMark) : null, diff --git a/core/src/client/text.js b/core/src/client/text.js index 5d059e22..edfbf925 100644 --- a/core/src/client/text.js +++ b/core/src/client/text.js @@ -306,6 +306,22 @@ export function faceRowTitle(title) { return text.match(/^Face-to-face · (\d{6})$/)?.[1] ?? text } +// What a Bot runs on, for display: the chosen model, else the one it last ran on. +export function modelOf(bot, roster) { + const ref = bot.model ?? bot.appliedModel + if (!ref) return undefined + const served = (roster.models ?? []).find(model => model.ref === ref) + const [provider, ...rest] = ref.split('/') + return { ref, name: served?.name ?? rest.join('/'), provider: served?.providerName ?? provider, served: served !== undefined } +} + +// A Bot row's hover title names the model the Bot runs on (#366): "Coder · DeepSeek V4 Pro". +// Without a model, a wide row needs none (its name is on it) and a narrow one names the Bot. +export function botRowTitle(name, model, wide) { + if (model) return `${name} · ${model}` + return wide ? undefined : name +} + // The group chats whose work shows on a Bot's row as " · Thinking…". A face room has its // own row, so its turns never take over the Bot's preview (#458). export function awayGroups(groups, roomsById) { diff --git a/core/src/host/flybot-feed.js b/core/src/host/flybot-feed.js index c93b9a21..b5ddb71e 100644 --- a/core/src/host/flybot-feed.js +++ b/core/src/host/flybot-feed.js @@ -47,6 +47,23 @@ export function installFeed(rt, fire) { if (entry === undefined) sessions.set(id, entry = { calls: new Map() }) return entry } + // The model each Session's latest request header named (`provider/model`). DSH logs a header + // only when it changes, so the last one seen is the one every later step runs on. + /** @type {Map} */ + const models = new Map() + const refOf = config => (typeof config?.provider === 'string' && typeof config?.model === 'string' && config.provider && config.model + ? cleanLabel(`${config.provider}/${config.model}`, 120) : undefined) + // The model a turn ran on: only a turn that made a model call has one. A friend's Bot chat runs + // no model of yours. Before this process saw a header, the Session's own latest header answers. + const turnModel = (session, id, entry, where) => { + if (!entry.stepped || where.place === 'remote') return undefined + if (!models.has(id)) { + let ref + try { ref = refOf(session?.requestHeader?.()?.config) } catch { /* no header yet */ } + if (ref !== undefined) models.set(id, ref) + } + return models.get(id) + } // What the user calls a friend's Bot ("Chief (UiA1415)", "Helper (UiB1415)", #384), as the sidebar // does, from the published roster or the friend's chat. Untrusted (a peer chose it): cleaned, capped. @@ -170,14 +187,23 @@ export function installFeed(rt, fire) { // A turn that never ended (a crash) is closed by the next one. if (entry.turn !== undefined) { closeCalls(id, entry, where, turnId, timeOf(event), true) - fire('turn.end', omitUndefined({ ...base, turnId, turn: entry.turn, time: timeOf(event), outcome: 'aborted', interrupted: true })) + fire('turn.end', omitUndefined({ ...base, turnId, turn: entry.turn, time: timeOf(event), outcome: 'aborted', interrupted: true, model: turnModel(session, id, entry, where) })) } entry.turn = turn + entry.stepped = false const trigger = triggerOf(entry.pending) entry.pending = undefined fire('turn.start', omitUndefined({ ...base, turnId: `${id}#${turn}`, turn, time: timeOf(event), seq: seqOf(event), trigger })) return } + case 'step/start': + if (entry.turn !== undefined) entry.stepped = true + return + case 'request/header': { + const ref = refOf(event.data?.header?.config) + if (ref !== undefined) models.set(id, ref) + return + } case 'tool/call': { const callId = cleanId(event.data?.callId) if (callId === undefined || entry.turn === undefined) return @@ -203,8 +229,9 @@ export function installFeed(rt, fire) { const kind = event.data?.reason?.kind const outcome = OUTCOMES.has(kind) ? kind : 'completed' const errorCode = outcome === 'error' ? cleanCode(event.data?.reason?.error?.code) : undefined - fire('turn.end', omitUndefined({ ...base, turnId, turn: entry.turn, time, seq: seqOf(event), outcome, errorCode })) + fire('turn.end', omitUndefined({ ...base, turnId, turn: entry.turn, time, seq: seqOf(event), outcome, errorCode, model: turnModel(session, id, entry, where) })) entry.turn = undefined + entry.stepped = false if (entry.pending === undefined) sessions.delete(id) return } diff --git a/core/test/bot-row-model.test.js b/core/test/bot-row-model.test.js new file mode 100644 index 00000000..717c1343 --- /dev/null +++ b/core/test/bot-row-model.test.js @@ -0,0 +1,28 @@ +import test from 'node:test' +import assert from 'node:assert/strict' +import { readFileSync } from 'node:fs' +import { botRowTitle, modelOf } from '../src/client/text.js' + +test('#366: a Bot row\'s hover title names the model it runs on', () => { + assert.equal(botRowTitle('Coder', 'DeepSeek V4 Pro', true), 'Coder · DeepSeek V4 Pro') + assert.equal(botRowTitle('Coder', 'DeepSeek V4 Pro', false), 'Coder · DeepSeek V4 Pro') + // No model yet: a wide row shows its name already, a narrow (avatar-only) row still names the Bot. + assert.equal(botRowTitle('Coder', undefined, true), undefined) + assert.equal(botRowTitle('Coder', '', false), 'Coder') +}) + +test('#366: the model a row names is the served name of the chosen model, else the one it last ran on', () => { + const roster = { models: [{ ref: 'deepseek/deepseek-v4-pro', name: 'DeepSeek V4 Pro', providerName: 'DeepSeek' }] } + assert.equal(modelOf({ model: 'deepseek/deepseek-v4-pro' }, roster).name, 'DeepSeek V4 Pro') + assert.equal(modelOf({ appliedModel: 'deepseek/deepseek-v4-pro' }, roster).name, 'DeepSeek V4 Pro') + // A model no provider serves anymore is still named, by its id. + assert.deepEqual(modelOf({ model: 'kimi/kimi-k2' }, roster), { ref: 'kimi/kimi-k2', name: 'kimi-k2', provider: 'kimi', served: false }) + assert.equal(modelOf({}, roster), undefined) +}) + +test('#366: every own Bot row (the tree draws the same row) takes that title; a friend\'s Bot keeps its own', () => { + const sidebar = readFileSync(new URL('../src/client/sidebar.js', import.meta.url), 'utf8') + const row = sidebar.slice(sidebar.indexOf('function BotRow('), sidebar.indexOf('function RemoteMark(')) + assert.match(row, /className: 'bt-row', [^\n]*\n[^\n]*\n\s*'data-bot-id': id, title: remoteRecord \? shown : botRowTitle\(shown, modelOf\(bot, roster\)\?\.name, wide\), onClick/) + assert.match(sidebar, /function TreeNode\([\s\S]*?h\(BotRow, \{ \.\.\.rowProps, bot, asRow: true/) +}) diff --git a/core/test/flybot-feed.test.js b/core/test/flybot-feed.test.js index 884d6787..a7ee610a 100644 --- a/core/test/flybot-feed.test.js +++ b/core/test/flybot-feed.test.js @@ -264,3 +264,58 @@ test('a friend\'s request into a team Bot names the friend as the sidebar does, emit('a', 'user/message', { source: { kind: 'bot', form: 'relay', role: 'request', exchangeId: 'q2', hop: 0, remote: { handle: '@stranger.zed.home' } }, content: [] }, 7100) assert.deepEqual(only(feed, 'exchange.delivered')[1].from, { kind: 'remote', handle: '@stranger.zed.home' }) }) + +const header = (provider, model) => ({ header: { config: { provider, model } }, reason: 'change' }) + +test('turn.end names the model of the turn\'s latest request header (#366)', async (t) => { + const { feed, emit, stop } = await boot() + t.after(stop) + emit('a', 'turn/start', { turn: 1 }) + emit('a', 'step/start', { turn: 1, step: 1 }) + emit('a', 'request/header', header('deepseek', 'deepseek-v4-pro')) + emit('a', 'turn/end', { turn: 1, reason: { kind: 'completed' } }) + // DSH logs no new header while the model stays: the next turn still ran on it. + emit('a', 'turn/start', { turn: 2 }) + emit('a', 'step/start', { turn: 2, step: 1 }) + emit('a', 'turn/end', { turn: 2, reason: { kind: 'completed' } }) + // A change of model mid-session is the next turn's label, not the earlier ones'. + emit('a', 'turn/start', { turn: 3 }) + emit('a', 'step/start', { turn: 3, step: 1 }) + emit('a', 'request/header', header('kimi', 'kimi-k2')) + emit('a', 'turn/end', { turn: 3, reason: { kind: 'error', error: { code: 'RATE_LIMIT' } } }) + assert.deepEqual(only(feed, 'turn.end').map(end => end.model), ['deepseek/deepseek-v4-pro', 'deepseek/deepseek-v4-pro', 'kimi/kimi-k2']) + assert.equal(only(feed, 'turn.start').some(start => 'model' in start), false) +}) + +test('turn.end: no model for a turn without a model call, and the Session\'s own header before the feed saw one (#366)', async (t) => { + const { ctx, feed, stop } = await boot() + t.after(stop) + let seq = 0 + const session = { id: 'b', requestHeader: () => ({ config: { provider: 'deepseek', model: 'deepseek-v4-flash' } }) } + const emit = (type, data) => ctx.emitArgs('session/event', session, { type, seq: ++seq, time: 5000 + seq, data }) + emit('turn/start', { turn: 1 }) + emit('turn/end', { turn: 1, reason: { kind: 'aborted' } }) + emit('turn/start', { turn: 2 }) + emit('step/start', { turn: 2, step: 1 }) + emit('turn/end', { turn: 2, reason: { kind: 'completed' } }) + const ends = only(feed, 'turn.end') + assert.equal('model' in ends[0], false) + assert.equal(ends[1].model, 'deepseek/deepseek-v4-flash') +}) + +test('negative: a model name is cleaned, and a friend\'s Bot chat never names one (#366)', async (t) => { + const { feed, emit, stop } = await boot() + t.after(stop) + emit('a', 'turn/start', { turn: 1 }) + emit('a', 'step/start', { turn: 1, step: 1 }) + emit('a', 'request/header', header('deepseek', 'v4\n')) + emit('a', 'turn/end', { turn: 1, reason: { kind: 'completed' } }) + emit('rc', 'turn/start', { turn: 1 }) + emit('rc', 'step/start', { turn: 1, step: 1 }) + emit('rc', 'request/header', header('flybot-remote', '@helper.uib.home')) + emit('rc', 'turn/end', { turn: 1, reason: { kind: 'completed' } }) + const [mine, remote] = only(feed, 'turn.end') + assert.doesNotMatch(mine.model, /untrusted|\n/i) + assert.equal(remote.place, 'remote') + assert.equal('model' in remote, false) +}) diff --git a/core/types/flybot.d.ts b/core/types/flybot.d.ts index 7c31c0d6..0022a4c9 100644 --- a/core/types/flybot.d.ts +++ b/core/types/flybot.d.ts @@ -112,6 +112,11 @@ export interface TurnEnded extends FeedPlace { errorCode?: string /** True when the turn never reported its end and a newer turn closed it. */ interrupted?: boolean + /** + * `provider/model` of the turn's latest request header: the model it ran on. Absent for a + * turn that made no model call, for place 'remote', and when DSH logged no header. + */ + model?: string } export interface ToolStarted extends FeedPlace { diff --git a/docs/subsystems/core-client.md b/docs/subsystems/core-client.md index db0cc264..41b330bf 100644 --- a/docs/subsystems/core-client.md +++ b/docs/subsystems/core-client.md @@ -114,7 +114,7 @@ The overlay (`flybot-slots.js` `FlyBotApprovals`) is the fallback, not a second ### Bot tree chevron -- The sidebar Bot-list tree (`sidebar.js` `TreeNode`, from slot `flybot.sidebar.structure`; optional `include(botId)` filters the default list, #452) draws collapse/expand as `.bt-tree-toggle`: a 28×28 hit target with a 16px `.bt-tree-chev` glyph, hover background, and a `:focus-visible` accent ring. The glyph rotates when `aria-expanded` is false; the button stays upright so hover and focus stay square. Expand/Collapse `{name}` labels and `aria-expanded` are unchanged. Leaf rows use the same 28px width as a spacer (`.bt-tree-leaf`) so names stay aligned; indent is still `depth * 16px` and the Bot row keeps its height. +- The sidebar Bot-list tree (`sidebar.js` `TreeNode`, from slot `flybot.sidebar.structure`; optional `include(botId)` filters the default list, #452) draws collapse/expand as `.bt-tree-toggle`: a 28×28 hit target with a 16px `.bt-tree-chev` glyph, hover background, and a `:focus-visible` accent ring. The glyph rotates when `aria-expanded` is false; the button stays upright so hover and focus stay square. Expand/Collapse `{name}` labels and `aria-expanded` are unchanged. Leaf rows use the same 28px width as a spacer (`.bt-tree-leaf`) so names stay aligned; indent is still `depth * 16px` and the Bot row keeps its height. An own Bot's row, in the tree and in the flat list, names the model the Bot runs on in its hover text (`botRowTitle` in `text.js`: `Coder · DeepSeek V4 Pro`, the model's served name from `modelOf`, also in `text.js` so the sidebar does not import the model menu, #366); a friend's Bot row keeps its `remoteBotTitle`, since it runs on its owner's model. ### New-chat menu diff --git a/docs/subsystems/plugin-runtime.md b/docs/subsystems/plugin-runtime.md index a20a4a24..6f26a538 100644 --- a/docs/subsystems/plugin-runtime.md +++ b/docs/subsystems/plugin-runtime.md @@ -59,7 +59,7 @@ FlyBot's plugin API surface on top of the patched core: host hooks, client slots 9. `flybot.rooms` hosts a Bot Social face-to-face matching room as a native room (`upsertFace` / `applyFaceLine` / `ofFace` / `dropFace` / `onFaceLine` (`fn(room, line, { mentions })`: for the owner's typed line, the handles its `@` names, #293) / `postNotice` / `postHuman` (quiet; it reads the friend's line for `@` of a local Bot by label or handle and passes `callIds`, so only those Bots answer, #287; an optional `mentions` handle list from an attested line is resolved first, #293; `attested: true`, passed only for a line the server stamps `typed_by_owner`, lets a plain or `@all` line start this home's Bots in one capped pass, #295; a plain line is answered by the Bots that judge it theirs, `@all` is not routed, #299) / `postRemote`; `postHuman` draws another member's typed line as a chat bubble from that person, #180). Slot `flybot.room.overview` is a block at the top of a group's overview: while joining, a tile grid (avatar above name + owner) with **Leave** and **Enter chat** on one row; once open, compact **Leave** and Auto-send — not Open/Start/Leave together. Slot `flybot.new-chat.action` adds a row to the sidebar **+** short action menu (after New group chat; `label` / `hint` go through the same `t()` map). Still not team mode. 10. Held sends are owner-bound (#52): a hold stamps server-derived provenance on the record — `requestId`, a sha256 `version` over the request content, `origin` (`via`/`hop`/audience) and the sender Bot's `ownerId`; message claims (`meta`, text) never confer authority. `flybot/decide` passes authz for any signed-in actor and hooks binds the decision to the sender Bot's owner's session; `release` revalidates and marks records `stale` (changed request, deleted sender/target Bot, changed owner — `deleteBot` invalidates eagerly; a demoted Chief's sender-side holds are invalidated eagerly on `setMain`, not inside `staleReason`). `release()` re-checks immediately before dispatch so an in-flight approve cannot go out after a concurrent invalidation. Unknown `invalidateFor` field values throw. The pending list is filtered per actor under member enforcement. The default approval card shows `senderName`/`targetName` and a delegated marker (`Approval.origin`). 11. Task lifecycle (#54): `bot.send` stamps `meta.taskId` from the working task (`rt.taskIdForSend`); a held send then calls `rt.taskHeld` (blocks **that** task). The owner's `flybot/decide` calls `rt.taskDecided` (approve resumes, reject cancels); `deleteBot` of a local Bot calls `rt.taskBotDeleted` (open tasks fail). Incoming holds stay the owner's. The state machine lives in `tasks.js`; hooks only fire those three seams. Clarification and `workBotId` wait on #55. -12. Activity feed (#241): plugins that want to see where team time goes subscribe through `flybot.on(...)` and never to raw DSH `session/event` (the maintainer decision on #241). Events: `turn.start` / `turn.end` (a Bot's own chat `place: 'chat'`, its hidden group Session `'group'`, the group's own Session `'room'`, whose turn is one round, or a friend's Bot chat `'remote'` (#248: its Session runs no model and only carries lines to and from the friend, so those turns are transport, not a team Bot's work; `remote: {handle, name}` names the friend as the sidebar does (`Helper · Bob's Bot`, `Bob's Chief`; a `{kind: 'remote'}` party carries the same `name` when the roster knows it), and the friend's line into the chat is an `exchange.delivered` with `role: 'remote'`)), `tool.start` / `tool.end` (paired by `callId`, closed `interrupted` when their turn ends first) and `exchange.delivered` (a message arrived in a Session's log: `exchangeId` as in `state.exchanges`, `replyTo` of the answer, `from` a user / Bot / group / friend's Bot). The feed is metadata only: message text, tool arguments, tool output and model output never leave `flybot-feed.js`, so a consumer that stores it cannot persist a secret. Strings from outside the team (tool names, remote handles) go through `cleanLabel` (which also removes `untrusted` fence markers when spaced out or split), error outcomes carry an upper-case code (`RATE_LIMIT`, else `UNKNOWN`) and no message. Delivered means "in the recipient's log", not read; the feed cannot see read. Host nudges (retry, resume, kickoff, deferred and re-sent deliveries) are not deliveries. Not replayed: a listener hears events after it subscribes. Listeners get copies and a throwing one cannot reach the Session or the other listeners. Events fire in a read-only host too, since they only observe. A friend's Bot in a feed entry is named as the sidebar names it, `Helper (Bob)` (`remoteTitle` → `remoteBotTitle`, #384); the approval overlay's recipient uses the same name. +12. Activity feed (#241): plugins that want to see where team time goes subscribe through `flybot.on(...)` and never to raw DSH `session/event` (the maintainer decision on #241). Events: `turn.start` / `turn.end` (a Bot's own chat `place: 'chat'`, its hidden group Session `'group'`, the group's own Session `'room'`, whose turn is one round, or a friend's Bot chat `'remote'` (#248: its Session runs no model and only carries lines to and from the friend, so those turns are transport, not a team Bot's work; `remote: {handle, name}` names the friend as the sidebar does (`Helper · Bob's Bot`, `Bob's Chief`; a `{kind: 'remote'}` party carries the same `name` when the roster knows it), and the friend's line into the chat is an `exchange.delivered` with `role: 'remote'`)), `tool.start` / `tool.end` (paired by `callId`, closed `interrupted` when their turn ends first) and `exchange.delivered` (a message arrived in a Session's log: `exchangeId` as in `state.exchanges`, `replyTo` of the answer, `from` a user / Bot / group / friend's Bot). The feed is metadata only: message text, tool arguments, tool output and model output never leave `flybot-feed.js`, so a consumer that stores it cannot persist a secret. Strings from outside the team (tool names, remote handles) go through `cleanLabel` (which also removes `untrusted` fence markers when spaced out or split), error outcomes carry an upper-case code (`RATE_LIMIT`, else `UNKNOWN`) and no message. `turn.end` carries `model` (`provider/model`, cleaned) when the turn made a model call (#366): the model of the Session's latest `request/header` (DSH logs one only when it changes, so the last one seen applies; before the feed saw one, the Session's own `requestHeader()`). A turn with no step, and every `'remote'` turn, has none; `turn.start` never has one, since the model is known only once the first request goes out. Delivered means "in the recipient's log", not read; the feed cannot see read. Host nudges (retry, resume, kickoff, deferred and re-sent deliveries) are not deliveries. Not replayed: a listener hears events after it subscribes. Listeners get copies and a throwing one cannot reach the Session or the other listeners. Events fire in a read-only host too, since they only observe. A friend's Bot in a feed entry is named as the sidebar names it, `Helper (Bob)` (`remoteTitle` → `remoteBotTitle`, #384); the approval overlay's recipient uses the same name. ### Chat-row pending badge diff --git a/docs/subsystems/timeline.md b/docs/subsystems/timeline.md index f3b46460..4836961a 100644 --- a/docs/subsystems/timeline.md +++ b/docs/subsystems/timeline.md @@ -38,13 +38,13 @@ Everything in `plugins/timeline/`: the recorder, the trace file, the read model ## Invariants 1. Timeline code stays inside `plugins/timeline/`. Events come only from `ctx.flybot` (the activity feed); it never subscribes to DSH `session/event`, never edits `ds-bot/` or `core/src/`. Runtime sources import only this package, Node builtins or `ds-bot` (#153, enforced by `scripts/lint-plugins.mjs`). -2. Metadata only. A trace record holds ids, times, outcomes, tool names, message lengths and cleaned Bot/group names. Never message text, tool arguments or tool output; the feed does not carry them and the recorder would not copy them. Tests assert that a secret in any of those never reaches the trace file. +2. Metadata only. A trace record holds ids, times, outcomes, tool names, message lengths, cleaned Bot/group names and the model a turn ran on (`te.md`, `provider/model` from the feed's `turn.end.model`, #366). Never message text, tool arguments or tool output; the feed does not carry them and the recorder would not copy them. Tests assert that a secret in any of those never reaches the trace file. 3. Untrusted strings are rebuilt at the plugin's own boundary (`sanitize.js`) on the way in and again on the way out (`window.js`): ids and codes from strict patterns, labels stripped of control and bidi characters, markup brackets and `untrusted` fence markers (also when split or spaced out), and length-capped. An error code is an upper-case word or `UNKNOWN`. 4. Write discipline matches `dev/interop/journal.mjs`: one writer (`writer.lock`, refused again inside one process), a segment is only replaced whole (tmp, fsync, rename, directory fsync). The trace is capped at 3 segments of 2000 records (about 3000 spans) and 24 hours, whichever comes first. A second dsh on the same directory, or an unwritable directory, records in memory only and says so in the response (`trace.persisting`). 5. Honest numbers. "Delivered" is when a message reached the recipient's log; there are no read stamps. "Idle" means no running turn; a Bot waiting on a model call or a slow tool is busy. "Model" time is turn time outside tool calls (queueing and host overhead included), not a measured model latency. A turn an earlier run left open is closed at the last record before the restart and marked `lost`. "You asked" latency is the length of the turn it started. 6. A friend's Bot is a lane of its own, never a Bot of the local team (#248): it shows as a `friend` lane (its chat's name, else its handle) with messages only. The local side of a friend chat runs no model, so its transport turns are not recorded and it has no utilization row; your line to a friend waits until the friend's next line back (`role: 'remote'`), and a friend's request into one of your Bots is answered by that Bot's turn as a typed line is. Lanes never show a raw session id: a gone Bot or group with no remembered name is `Removed Bot` / `Removed group`. Group rooms are the room Session's own turn (the round), with the members' hidden group turns inside it. 7. The browser poller runs only while a panel holds it open, one request in flight, backing off on errors (4 s to 30 s) and keeping the last good window; a reply of another version is an error state, never drawn from. A hidden page loads once (a panel opened in a background tab is never left on Loading…, #259), then stops polling; becoming visible refreshes at once. -8. The panel draws text only: every string is a React text child or an SVG text node, never markup, and is cleaned again in the browser (`safe`). It polls only while open (and refreshes at once when the tab or window comes back, #259, #271), shows the last good data under an error, and says in a note when the trace is shorter than the window, truncated, memory-only or looking back. Its one-line footnote repeats the honesty rules of invariant 5 (delivered is not read, busy is not idle, model time is not a measured latency). The dialog shrinks to its content and hangs from the top (switching tabs moves nothing above the body), the **Live** control is always shown (pressed while following the latest activity, a call to action when looking back), lanes name what they are under the name (`main`, `group`, `friend`, `removed`) with no icon prefix, and the map puts each name beside or under its node, clipped with the full name in a tooltip (#249). A friend's Bot is named as the sidebar names it (`UiA1415's Chief`, `Helper · UiB1415's Bot`, from the feed's `remote.name` / party `name`), so two Bots called Helper stay apart; a wait label that would run past the plot is right-aligned, and one with no room is left to the arrow's tooltip (#255). The window's range is always in view under the header (`Oct 18 · 13:20:05 – 13:35:05 (15 min)`, dates at both ends across midnight, and whether it is live), and the footnote is two short items, not one line (#279). +8. The panel draws text only: every string is a React text child or an SVG text node, never markup, and is cleaned again in the browser (`safe`). It polls only while open (and refreshes at once when the tab or window comes back, #259, #271), shows the last good data under an error, and says in a note when the trace is shorter than the window, truncated, memory-only or looking back. Its one-line footnote repeats the honesty rules of invariant 5 (delivered is not read, busy is not idle, model time is not a measured latency). The dialog shrinks to its content and hangs from the top (switching tabs moves nothing above the body), the **Live** control is always shown (pressed while following the latest activity, a call to action when looking back), lanes name what they are under the name (`main`, `group`, `friend`, `removed`) with no icon prefix, and the map puts each name beside or under its node, clipped with the full name in a tooltip (#249). A turn bar's tooltip ends with the model the turn ran on (its model id, `· deepseek-v4-pro`); a running turn, or one recorded before #366, names none (#366). A friend's Bot is named as the sidebar names it (`UiA1415's Chief`, `Helper · UiB1415's Bot`, from the feed's `remote.name` / party `name`), so two Bots called Helper stay apart; a wait label that would run past the plot is right-aligned, and one with no room is left to the arrow's tooltip (#255). The window's range is always in view under the header (`Oct 18 · 13:20:05 – 13:35:05 (15 min)`, dates at both ends across midnight, and whether it is live), and the footnote is two short items, not one line (#279). 9. Opt-in (#281): `apply` without `config.enabled === true` subscribes to nothing, writes nothing and registers no endpoint; the browser entry draws no sidebar row unless `config.sidebarSection === true` and its poller only runs while a panel is open. A test asserts the host does nothing by default. Docs and the install doctor's default commands do not present it as part of a standard install. 10. The endpoint reads only. It does not start, stop, send or change anything. diff --git a/patches/ds-bot/0001-core-fixes.patch b/patches/ds-bot/0001-core-fixes.patch index 857328e3..55b520e4 100644 --- a/patches/ds-bot/0001-core-fixes.patch +++ b/patches/ds-bot/0001-core-fixes.patch @@ -912,8 +912,11 @@ diff --git a/src/client/memory-panel.js b/src/client/memory-panel.js diff --git a/src/client/model-menu.js b/src/client/model-menu.js --- a/src/client/model-menu.js +++ b/src/client/model-menu.js -@@ -3,7 +3,7 @@ import { createPortal } from 'react-dom' - import { currentPart } from './text.js' +@@ -1,9 +1,9 @@ + import { createElement as h, Fragment, useEffect, useRef, useState, useSyncExternalStore } from 'react' + import { createPortal } from 'react-dom' +-import { currentPart } from './text.js' ++import { currentPart, modelOf } from './text.js' import { CheckIcon, SlidersIcon } from './icons.js' import { BotMark } from './mark.js' -import { openHarnessOnboarding, openHarnessSettings } from './sidebar.js' @@ -921,6 +924,23 @@ diff --git a/src/client/model-menu.js b/src/client/model-menu.js import { SPRING_POP, SPRING_SHAPE, spring, useLinger } from './motion.js' import { t } from './i18n.js' +@@ -33,14 +33,8 @@ export function groupModels(models) { + + const servedOf = (roster, ref) => (roster.models ?? []).find(model => model.ref === ref) + +-// What a Bot runs on, for display: the chosen model, else the one it last ran on. +-export function modelOf(bot, roster) { +- const ref = bot.model ?? bot.appliedModel +- if (!ref) return undefined +- const served = servedOf(roster, ref) +- const [provider, ...rest] = ref.split('/') +- return { ref, name: served?.name ?? rest.join('/'), provider: served?.providerName ?? provider, served: served !== undefined } +-} ++// `modelOf` lives in text.js so the sidebar can name a Bot's model without this module (#366). ++export { modelOf } + + // The thinking levels of one model, `{efforts, defaultEffort?}` or undefined when it + // has none. The catalog carries them for models Bots use; any other ref asks the host. @@ -139,6 +139,8 @@ export function useAnchoredMenu() { // levels of the shown model when it has them. // `onPick`/`onPickEffort` replace saving to the Bot, for a Bot that does not exist yet. @@ -1444,7 +1464,7 @@ diff --git a/src/client/text.js b/src/client/text.js } return '' } -@@ -230,3 +230,19 @@ export function usageOf(list, ids) { +@@ -230,3 +230,35 @@ export function usageOf(list, ids) { const hit = input > 0 ? Math.round((cacheRead / input) * 100) : 0 return t('{tokens} tok · Cache hit {hit}%', { tokens: formatTokens(input + output), hit }) } @@ -1459,6 +1479,22 @@ diff --git a/src/client/text.js b/src/client/text.js + return text.match(/^Face-to-face · (\d{6})$/)?.[1] ?? text +} + ++// What a Bot runs on, for display: the chosen model, else the one it last ran on. ++export function modelOf(bot, roster) { ++ const ref = bot.model ?? bot.appliedModel ++ if (!ref) return undefined ++ const served = (roster.models ?? []).find(model => model.ref === ref) ++ const [provider, ...rest] = ref.split('/') ++ return { ref, name: served?.name ?? rest.join('/'), provider: served?.providerName ?? provider, served: served !== undefined } ++} ++ ++// A Bot row's hover title names the model the Bot runs on (#366): "Coder · DeepSeek V4 Pro". ++// Without a model, a wide row needs none (its name is on it) and a narrow one names the Bot. ++export function botRowTitle(name, model, wide) { ++ if (model) return `${name} · ${model}` ++ return wide ? undefined : name ++} ++ +// The group chats whose work shows on a Bot's row as " · Thinking…". A face room has its +// own row, so its turns never take over the Bot's preview (#458). +export function awayGroups(groups, roomsById) { diff --git a/patches/ds-bot/0003-flybot-api-client.patch b/patches/ds-bot/0003-flybot-api-client.patch index 619749c1..e02c40ed 100644 --- a/patches/ds-bot/0003-flybot-api-client.patch +++ b/patches/ds-bot/0003-flybot-api-client.patch @@ -1671,7 +1671,7 @@ diff --git a/src/client/sidebar.js b/src/client/sidebar.js import { isMainOf } from './sources.js' -import { currentPart, previewOf, relativeShort } from './text.js' -import { PlusIcon, SearchIcon, GaugeIcon, PlugIcon, ClockIcon, GearIcon, PinMarkIcon, ConnectorIcon, SwitchIcon, CloudDownloadIcon } from './icons.js' -+import { awayGroups, currentPart, faceRowTitle, nameMentions, previewOf, relativeShort, unsentReplyTurns } from './text.js' ++import { awayGroups, botRowTitle, currentPart, faceRowTitle, modelOf, nameMentions, previewOf, relativeShort, unsentReplyTurns } from './text.js' +import { PlusIcon, SearchIcon, GaugeIcon, PlugIcon, ClockIcon, GearIcon, PinMarkIcon, ConnectorIcon, SwitchIcon, CloudDownloadIcon, FlyBotMark } from './icons.js' import { MAIN_LOOK } from './characters.js' import { botState, BotMark, BotAvatar, RoomAvatar } from './mark.js' @@ -1685,7 +1685,7 @@ diff --git a/src/client/sidebar.js b/src/client/sidebar.js // ------------------------------------------------------------------------- // Sidebar -@@ -37,56 +37,118 @@ const workKeys = text => (text === '' ? [] : text.split(',').map((key) => { +@@ -37,56 +37,119 @@ const workKeys = text => (text === '' ? [] : text.split(',').map((key) => { })) const workLine = (lead, tool, more) => h(Shimmer, null, `${lead} · ${workPhrase(tool)}…${more > 0 ? ` +${more}` : ''}`) @@ -1749,7 +1749,8 @@ diff --git a/src/client/sidebar.js b/src/client/sidebar.js + const row = h('button', { type: 'button', className: 'bt-row', 'aria-label': label, 'aria-current': active ? 'page' : undefined, - 'data-bot-id': id, title: wide ? undefined : bot.name, onClick: () => actions.openSession(id), onContextMenu, -+ 'data-bot-id': id, title: remoteRecord ? shown : wide ? undefined : bot.name, onClick: () => actions.openSession(id), onContextMenu, ++ // An own Bot's hover text names its model (#366); a friend's Bot runs on its owner's model. ++ 'data-bot-id': id, title: remoteRecord ? shown : botRowTitle(shown, modelOf(bot, roster)?.name, wide), onClick: () => actions.openSession(id), onContextMenu, }, h(BotAvatar, { bot, size: 36, state, main }), pinned && !wide ? h(PinMark) : null, diff --git a/patches/ds-bot/0008-client-bundle.patch b/patches/ds-bot/0008-client-bundle.patch index 59ecb0d0..94978201 100644 --- a/patches/ds-bot/0008-client-bundle.patch +++ b/patches/ds-bot/0008-client-bundle.patch @@ -1797,7 +1797,7 @@ diff --git a/client.js b/client.js } return ""; } -@@ -1624,9 +1624,177 @@ function usageOf(list, ids) { +@@ -1624,9 +1624,188 @@ function usageOf(list, ids) { const hit = input > 0 ? Math.round(cacheRead / input * 100) : 0; return t("{tokens} tok · Cache hit {hit}%", { tokens: formatTokens(input + output), hit }); } @@ -1807,6 +1807,17 @@ diff --git a/client.js b/client.js + if (named) return `${named[2]} · ${named[1]}`; + return text.match(/^Face-to-face · (\d{6})$/)?.[1] ?? text; +} ++function modelOf(bot, roster) { ++ const ref = bot.model ?? bot.appliedModel; ++ if (!ref) return void 0; ++ const served = (roster.models ?? []).find((model) => model.ref === ref); ++ const [provider, ...rest] = ref.split("/"); ++ return { ref, name: served?.name ?? rest.join("/"), provider: served?.providerName ?? provider, served: served !== void 0 }; ++} ++function botRowTitle(name, model, wide) { ++ if (model) return `${name} · ${model}`; ++ return wide ? void 0 : name; ++} +function awayGroups(groups, roomsById) { + return (groups ?? []).filter((entry) => roomsById?.[entry.id]?.origin !== "face-to-face"); +} @@ -2304,18 +2315,10 @@ diff --git a/client.js b/client.js .bt-ububble{max-width:min(560px,78%);background:var(--bt-user);color:var(--bt-user-ink);border-radius:18px;padding:7px 12px;font-size:14px;line-height:20px;box-sizing:border-box;overflow-wrap:anywhere} .bt-ububble [class*="_markdown"],.bt-ububble a{color:inherit!important} .bt-ububble p{margin:0;line-height:22px} -@@ -2842,239 +2842,221 @@ function installCompiledStyles(text, tag) { - timer = setTimeout(render, 120); - }; - const onLoad = (event) => { -- if (event.target instanceof HTMLLinkElement) schedule(); -- }; -- const observer = new MutationObserver(schedule); -- observer.observe(document.head, { childList: true }); -- document.addEventListener("load", onLoad, true); -- return () => { -- observer.disconnect(); -- document.removeEventListener("load", onLoad, true); +@@ -2850,231 +2850,213 @@ function installCompiledStyles(text, tag) { + return () => { + observer.disconnect(); + document.removeEventListener("load", onLoad, true); - clearTimeout(timer); - style.remove(); - }; @@ -2383,14 +2386,6 @@ diff --git a/client.js b/client.js - onAnimationEnd: poked ? (event) => { - if (/^bt-poke/.test(event.animationName)) setPoked(false); - } : void 0 -+ if (event.target instanceof HTMLLinkElement) schedule(); -+ }; -+ const observer = new MutationObserver(schedule); -+ observer.observe(document.head, { childList: true }); -+ document.addEventListener("load", onLoad, true); -+ return () => { -+ observer.disconnect(); -+ document.removeEventListener("load", onLoad, true); + clearTimeout(timer); + style.remove(); }; @@ -3852,9 +3847,8 @@ diff --git a/client.js b/client.js + room2?.admin === bot.id ? (0, import_react8.createElement)("span", { className: "bt-group-role" }, t("Admin")) : null + ); + return (0, import_react8.createElement)( - "div", - { className: "bt-group-msg" }, -- (0, import_react6.createElement)( ++ "div", ++ { className: "bt-group-msg" }, + name, + (0, import_react8.createElement)(Bubbles, { texts: [text], roster, selfId: bot.id, actions, sessionId: room2?.id, msgKey, useUi, lead }) + ); @@ -3915,7 +3909,9 @@ diff --git a/client.js b/client.js + const mark = (0, import_react8.createElement)(BotMark, { bot, size: 22 }); + const lead = chatId ? (0, import_react8.createElement)("button", { type: "button", className: "bt-lead", title: handle, "aria-label": t("Open {name}'s chat", { name }), onClick: () => actions.openSession(chatId) }, mark) : (0, import_react8.createElement)("span", { className: "bt-lead", title: handle }, mark); + return (0, import_react8.createElement)( -+ "div", + "div", +- { className: "bt-group-msg" }, +- (0, import_react6.createElement)( + { className: "bt-group-msg bt-remote-msg", "data-remote": handle }, + // A face room's header is the one name alone; the handle is on hover only (#457). + face ? (0, import_react8.createElement)("span", { className: "bt-group-name", style: { color: inkText(colorOf(bot)) }, title: handle }, name) : (0, import_react8.createElement)( @@ -4362,8 +4358,7 @@ diff --git a/client.js b/client.js if (reply.kind === "stopped") { - return (0, import_react6.createElement)("div", { key: `${reply.botId}-${index}`, className: "bt-event" }, t("You stopped {name}'s work", { name: bot.name })); + return (0, import_react8.createElement)("div", { key: `${reply.botId}-${index}`, className: "bt-event" }, t("You stopped {name}'s work", { name: bot.name })); - } -- return (0, import_react6.createElement)(GroupMessage, { key: `${reply.botId}-${index}`, bot, text: reply.text, room: room2, roster, actions, msgKey: callId ? `${sessionId}:${callId}:${index}` : void 0, useUi }); ++ } + if (reply.kind === "notice" && room2?.origin === "face-to-face" && String(reply.text ?? "").trim() === WAITING_OTHERS_NOTICE) return null; + if (reply.kind === "notice" || reply.kind === "joined" || reply.kind === "left" || reply.kind === "started") { + return (0, import_react8.createElement)("div", { key: `notice-${index}`, className: "bt-event", "data-flybot": "face-notice" }, remoteMessageText(reply.text)); @@ -4380,7 +4375,8 @@ diff --git a/client.js b/client.js + msgKey: callId ? `${sessionId}:${callId}:${index}` : void 0, + useUi + }); -+ } + } +- return (0, import_react6.createElement)(GroupMessage, { key: `${reply.botId}-${index}`, bot, text: reply.text, room: room2, roster, actions, msgKey: callId ? `${sessionId}:${callId}:${index}` : void 0, useUi }); + return (0, import_react8.createElement)(GroupMessage, { key: `${reply.botId}-${index}`, bot, text: reply.text, room: room2, roster, actions, msgKey: callId ? `${sessionId}:${callId}:${index}` : void 0, useUi, seq: reply.seq }); }), pending.map((id) => { @@ -5231,7 +5227,7 @@ diff --git a/client.js b/client.js "button", { type: "button", -@@ -4785,12 +4785,15 @@ function BotRow({ bot, roster, actions, useSessions, useSessionStatus, useSessio +@@ -4785,38 +4785,105 @@ function BotRow({ bot, roster, actions, useSessions, useSessionStatus, useSessio onClick: () => actions.openSession(id), onContextMenu }, @@ -5251,12 +5247,13 @@ diff --git a/client.js b/client.js "button", { type: "button", -@@ -4798,25 +4798,88 @@ function BotRow({ bot, roster, actions, useSessions, useSessionStatus, useSessio + className: "bt-row", "aria-label": label, "aria-current": active ? "page" : void 0, ++ // An own Bot's hover text names its model (#366); a friend's Bot runs on its owner's model. "data-bot-id": id, - title: wide ? void 0 : bot.name, -+ title: remoteRecord2 ? shown : wide ? void 0 : bot.name, ++ title: remoteRecord2 ? shown : botRowTitle(shown, modelOf(bot, roster)?.name, wide), onClick: () => actions.openSession(id), onContextMenu }, @@ -6786,8 +6783,17 @@ diff --git a/client.js b/client.js var import_react_dom6 = require("react-dom"); var MENU_EXIT_MS = 120; function groupModels(models) { -@@ -6376,8 +6376,8 @@ function modelOf(bot, roster) { +@@ -6367,17 +6367,10 @@ function groupModels(models) { + }); } + var servedOf = (roster, ref) => (roster.models ?? []).find((model) => model.ref === ref); +-function modelOf(bot, roster) { +- const ref = bot.model ?? bot.appliedModel; +- if (!ref) return void 0; +- const served = servedOf(roster, ref); +- const [provider, ...rest] = ref.split("/"); +- return { ref, name: served?.name ?? rest.join("/"), provider: served?.providerName ?? provider, served: served !== void 0 }; +-} var effortCache = /* @__PURE__ */ new Map(); function useEfforts(served, ref, actions) { - const [fetched, setFetched] = (0, import_react13.useState)(void 0); diff --git a/patches/ds-bot/files/src/host/flybot-feed.js b/patches/ds-bot/files/src/host/flybot-feed.js index c93b9a21..b5ddb71e 100644 --- a/patches/ds-bot/files/src/host/flybot-feed.js +++ b/patches/ds-bot/files/src/host/flybot-feed.js @@ -47,6 +47,23 @@ export function installFeed(rt, fire) { if (entry === undefined) sessions.set(id, entry = { calls: new Map() }) return entry } + // The model each Session's latest request header named (`provider/model`). DSH logs a header + // only when it changes, so the last one seen is the one every later step runs on. + /** @type {Map} */ + const models = new Map() + const refOf = config => (typeof config?.provider === 'string' && typeof config?.model === 'string' && config.provider && config.model + ? cleanLabel(`${config.provider}/${config.model}`, 120) : undefined) + // The model a turn ran on: only a turn that made a model call has one. A friend's Bot chat runs + // no model of yours. Before this process saw a header, the Session's own latest header answers. + const turnModel = (session, id, entry, where) => { + if (!entry.stepped || where.place === 'remote') return undefined + if (!models.has(id)) { + let ref + try { ref = refOf(session?.requestHeader?.()?.config) } catch { /* no header yet */ } + if (ref !== undefined) models.set(id, ref) + } + return models.get(id) + } // What the user calls a friend's Bot ("Chief (UiA1415)", "Helper (UiB1415)", #384), as the sidebar // does, from the published roster or the friend's chat. Untrusted (a peer chose it): cleaned, capped. @@ -170,14 +187,23 @@ export function installFeed(rt, fire) { // A turn that never ended (a crash) is closed by the next one. if (entry.turn !== undefined) { closeCalls(id, entry, where, turnId, timeOf(event), true) - fire('turn.end', omitUndefined({ ...base, turnId, turn: entry.turn, time: timeOf(event), outcome: 'aborted', interrupted: true })) + fire('turn.end', omitUndefined({ ...base, turnId, turn: entry.turn, time: timeOf(event), outcome: 'aborted', interrupted: true, model: turnModel(session, id, entry, where) })) } entry.turn = turn + entry.stepped = false const trigger = triggerOf(entry.pending) entry.pending = undefined fire('turn.start', omitUndefined({ ...base, turnId: `${id}#${turn}`, turn, time: timeOf(event), seq: seqOf(event), trigger })) return } + case 'step/start': + if (entry.turn !== undefined) entry.stepped = true + return + case 'request/header': { + const ref = refOf(event.data?.header?.config) + if (ref !== undefined) models.set(id, ref) + return + } case 'tool/call': { const callId = cleanId(event.data?.callId) if (callId === undefined || entry.turn === undefined) return @@ -203,8 +229,9 @@ export function installFeed(rt, fire) { const kind = event.data?.reason?.kind const outcome = OUTCOMES.has(kind) ? kind : 'completed' const errorCode = outcome === 'error' ? cleanCode(event.data?.reason?.error?.code) : undefined - fire('turn.end', omitUndefined({ ...base, turnId, turn: entry.turn, time, seq: seqOf(event), outcome, errorCode })) + fire('turn.end', omitUndefined({ ...base, turnId, turn: entry.turn, time, seq: seqOf(event), outcome, errorCode, model: turnModel(session, id, entry, where) })) entry.turn = undefined + entry.stepped = false if (entry.pending === undefined) sessions.delete(id) return } diff --git a/patches/ds-bot/files/test/bot-row-model.test.js b/patches/ds-bot/files/test/bot-row-model.test.js new file mode 100644 index 00000000..717c1343 --- /dev/null +++ b/patches/ds-bot/files/test/bot-row-model.test.js @@ -0,0 +1,28 @@ +import test from 'node:test' +import assert from 'node:assert/strict' +import { readFileSync } from 'node:fs' +import { botRowTitle, modelOf } from '../src/client/text.js' + +test('#366: a Bot row\'s hover title names the model it runs on', () => { + assert.equal(botRowTitle('Coder', 'DeepSeek V4 Pro', true), 'Coder · DeepSeek V4 Pro') + assert.equal(botRowTitle('Coder', 'DeepSeek V4 Pro', false), 'Coder · DeepSeek V4 Pro') + // No model yet: a wide row shows its name already, a narrow (avatar-only) row still names the Bot. + assert.equal(botRowTitle('Coder', undefined, true), undefined) + assert.equal(botRowTitle('Coder', '', false), 'Coder') +}) + +test('#366: the model a row names is the served name of the chosen model, else the one it last ran on', () => { + const roster = { models: [{ ref: 'deepseek/deepseek-v4-pro', name: 'DeepSeek V4 Pro', providerName: 'DeepSeek' }] } + assert.equal(modelOf({ model: 'deepseek/deepseek-v4-pro' }, roster).name, 'DeepSeek V4 Pro') + assert.equal(modelOf({ appliedModel: 'deepseek/deepseek-v4-pro' }, roster).name, 'DeepSeek V4 Pro') + // A model no provider serves anymore is still named, by its id. + assert.deepEqual(modelOf({ model: 'kimi/kimi-k2' }, roster), { ref: 'kimi/kimi-k2', name: 'kimi-k2', provider: 'kimi', served: false }) + assert.equal(modelOf({}, roster), undefined) +}) + +test('#366: every own Bot row (the tree draws the same row) takes that title; a friend\'s Bot keeps its own', () => { + const sidebar = readFileSync(new URL('../src/client/sidebar.js', import.meta.url), 'utf8') + const row = sidebar.slice(sidebar.indexOf('function BotRow('), sidebar.indexOf('function RemoteMark(')) + assert.match(row, /className: 'bt-row', [^\n]*\n[^\n]*\n\s*'data-bot-id': id, title: remoteRecord \? shown : botRowTitle\(shown, modelOf\(bot, roster\)\?\.name, wide\), onClick/) + assert.match(sidebar, /function TreeNode\([\s\S]*?h\(BotRow, \{ \.\.\.rowProps, bot, asRow: true/) +}) diff --git a/patches/ds-bot/files/test/flybot-feed.test.js b/patches/ds-bot/files/test/flybot-feed.test.js index 884d6787..a7ee610a 100644 --- a/patches/ds-bot/files/test/flybot-feed.test.js +++ b/patches/ds-bot/files/test/flybot-feed.test.js @@ -264,3 +264,58 @@ test('a friend\'s request into a team Bot names the friend as the sidebar does, emit('a', 'user/message', { source: { kind: 'bot', form: 'relay', role: 'request', exchangeId: 'q2', hop: 0, remote: { handle: '@stranger.zed.home' } }, content: [] }, 7100) assert.deepEqual(only(feed, 'exchange.delivered')[1].from, { kind: 'remote', handle: '@stranger.zed.home' }) }) + +const header = (provider, model) => ({ header: { config: { provider, model } }, reason: 'change' }) + +test('turn.end names the model of the turn\'s latest request header (#366)', async (t) => { + const { feed, emit, stop } = await boot() + t.after(stop) + emit('a', 'turn/start', { turn: 1 }) + emit('a', 'step/start', { turn: 1, step: 1 }) + emit('a', 'request/header', header('deepseek', 'deepseek-v4-pro')) + emit('a', 'turn/end', { turn: 1, reason: { kind: 'completed' } }) + // DSH logs no new header while the model stays: the next turn still ran on it. + emit('a', 'turn/start', { turn: 2 }) + emit('a', 'step/start', { turn: 2, step: 1 }) + emit('a', 'turn/end', { turn: 2, reason: { kind: 'completed' } }) + // A change of model mid-session is the next turn's label, not the earlier ones'. + emit('a', 'turn/start', { turn: 3 }) + emit('a', 'step/start', { turn: 3, step: 1 }) + emit('a', 'request/header', header('kimi', 'kimi-k2')) + emit('a', 'turn/end', { turn: 3, reason: { kind: 'error', error: { code: 'RATE_LIMIT' } } }) + assert.deepEqual(only(feed, 'turn.end').map(end => end.model), ['deepseek/deepseek-v4-pro', 'deepseek/deepseek-v4-pro', 'kimi/kimi-k2']) + assert.equal(only(feed, 'turn.start').some(start => 'model' in start), false) +}) + +test('turn.end: no model for a turn without a model call, and the Session\'s own header before the feed saw one (#366)', async (t) => { + const { ctx, feed, stop } = await boot() + t.after(stop) + let seq = 0 + const session = { id: 'b', requestHeader: () => ({ config: { provider: 'deepseek', model: 'deepseek-v4-flash' } }) } + const emit = (type, data) => ctx.emitArgs('session/event', session, { type, seq: ++seq, time: 5000 + seq, data }) + emit('turn/start', { turn: 1 }) + emit('turn/end', { turn: 1, reason: { kind: 'aborted' } }) + emit('turn/start', { turn: 2 }) + emit('step/start', { turn: 2, step: 1 }) + emit('turn/end', { turn: 2, reason: { kind: 'completed' } }) + const ends = only(feed, 'turn.end') + assert.equal('model' in ends[0], false) + assert.equal(ends[1].model, 'deepseek/deepseek-v4-flash') +}) + +test('negative: a model name is cleaned, and a friend\'s Bot chat never names one (#366)', async (t) => { + const { feed, emit, stop } = await boot() + t.after(stop) + emit('a', 'turn/start', { turn: 1 }) + emit('a', 'step/start', { turn: 1, step: 1 }) + emit('a', 'request/header', header('deepseek', 'v4\n')) + emit('a', 'turn/end', { turn: 1, reason: { kind: 'completed' } }) + emit('rc', 'turn/start', { turn: 1 }) + emit('rc', 'step/start', { turn: 1, step: 1 }) + emit('rc', 'request/header', header('flybot-remote', '@helper.uib.home')) + emit('rc', 'turn/end', { turn: 1, reason: { kind: 'completed' } }) + const [mine, remote] = only(feed, 'turn.end') + assert.doesNotMatch(mine.model, /untrusted|\n/i) + assert.equal(remote.place, 'remote') + assert.equal('model' in remote, false) +}) diff --git a/patches/ds-bot/files/types/flybot.d.ts b/patches/ds-bot/files/types/flybot.d.ts index 7c31c0d6..0022a4c9 100644 --- a/patches/ds-bot/files/types/flybot.d.ts +++ b/patches/ds-bot/files/types/flybot.d.ts @@ -112,6 +112,11 @@ export interface TurnEnded extends FeedPlace { errorCode?: string /** True when the turn never reported its end and a newer turn closed it. */ interrupted?: boolean + /** + * `provider/model` of the turn's latest request header: the model it ran on. Absent for a + * turn that made no model call, for place 'remote', and when DSH logged no header. + */ + model?: string } export interface ToolStarted extends FeedPlace { diff --git a/plugins/timeline/README.md b/plugins/timeline/README.md index 85f6a0b3..ff7e5c43 100644 --- a/plugins/timeline/README.md +++ b/plugins/timeline/README.md @@ -23,7 +23,7 @@ Config for the browser entry: `sidebarSection: true` shows the sidebar row, the ## What it records -Bot turns, tool calls (name and duration only), and Bot-to-Bot / user-to-Bot deliveries (who, to whom, role, message length). Group rooms appear as the room's own round with the members' turns inside. Never message text, tool arguments or tool output. +Bot turns (with the model each one ran on, shown in the turn's tooltip), tool calls (name and duration only), and Bot-to-Bot / user-to-Bot deliveries (who, to whom, role, message length). Group rooms appear as the room's own round with the members' turns inside. Never message text, tool arguments or tool output. The trace lives in `$DSH_HOME/bot/flybot-timeline/` as capped segment files: about 3000 spans or 24 hours, whichever comes first, then the oldest segment rotates out. One writer at a time (`writer.lock`); each file is replaced whole with write-tmp, fsync, rename. A second dsh on the same directory records in memory only. diff --git a/plugins/timeline/client.js b/plugins/timeline/client.js index 5061612c..63ea9a70 100644 --- a/plugins/timeline/client.js +++ b/plugins/timeline/client.js @@ -129,6 +129,8 @@ window.__ModuleLoader__.load({ // ------------------------------------------------------------------ formatting // The host cleaned every name already; this is the second line, for a host that did not. const safe = (value, max = 60) => String(value ?? '').replace(/[\u0000-\u001f\u007f-\u009f\u200b-\u200f\u202a-\u202e\u2066-\u2069<>]/g, '').trim().slice(0, max) + // A turn's model as the tooltip names it: the model id of `provider/model`, as Bot settings fall back to. + const modelName = ref => safe(String(ref ?? '').split('/').slice(1).join('/') || ref, 80) const pad = n => String(n).padStart(2, '0') const fmtClock = (t, seconds = true) => { const d = new Date(t) @@ -269,7 +271,7 @@ window.__ModuleLoader__.load({ bars.push({ id: safe(turn.id, 80), lane: lane.id, x: x0, y, w, h: BAR_H, running: turn.running === true, lost: turn.lost === true, outcome: turn.outcome, group: turn.place === 'group', room: roomLane ? roomLane.name : undefined, - title: `${lane.name}${roomLane ? ` in ${roomLane.name}` : ''}: ${fmtDur(end - turn.t0)}${turn.running ? ' (running)' : ''}${turn.lost ? ' (cut off by a restart; end unknown)' : ''}${turn.outcome && turn.outcome !== 'completed' ? ` · ${turn.outcome}${turn.errorCode ? ` ${safe(turn.errorCode, 40)}` : ''}` : ''}`, + title: `${lane.name}${roomLane ? ` in ${roomLane.name}` : ''}: ${fmtDur(end - turn.t0)}${turn.running ? ' (running)' : ''}${turn.lost ? ' (cut off by a restart; end unknown)' : ''}${turn.outcome && turn.outcome !== 'completed' ? ` · ${turn.outcome}${turn.errorCode ? ` ${safe(turn.errorCode, 40)}` : ''}` : ''}${modelName(turn.model) ? ` · ${modelName(turn.model)}` : ''}`, tools: turn.tools.map((tool) => { const a = X(Math.max(from, tool.t0)) const b = X(Math.min(to, tool.t1 ?? to)) diff --git a/plugins/timeline/src/recorder.js b/plugins/timeline/src/recorder.js index eaccaf6c..d13dc9c0 100644 --- a/plugins/timeline/src/recorder.js +++ b/plugins/timeline/src/recorder.js @@ -6,7 +6,8 @@ // boot the recorder started: turns still open before it were cut off by a restart // ts turn start { id, bot?, room?, pl, x?, f?, ro? } pl = chat | group | room; // x = triggering exchange id, f = who sent it, ro = its role -// te turn end { id, o, ec? } o = completed | aborted | error +// te turn end { id, o, ec?, md? } o = completed | aborted | error; +// md = the model it ran on (`provider/model`) // ks tool start { id, tid, nm, bot?, room? } // ke tool end { id, tid, ok, ir? } ir = the turn ended first // m delivered { id, f, to, ro, hop, rt?, room?, n } f/to = party; rt = replyTo @@ -95,7 +96,8 @@ export function createRecorder({ adapter, trace, now = Date.now }) { const t = cleanTime(event?.time) if (!id || !t) return const o = OUTCOMES.has(event.outcome) ? event.outcome : 'completed' - write({ r: 'te', t, id, o, ...(o === 'error' ? { ec: cleanCode(event.errorCode) } : {}) }) + const md = typeof event.model === 'string' ? cleanLabel(event.model, 120) : '' + write({ r: 'te', t, id, o, ...(o === 'error' ? { ec: cleanCode(event.errorCode) } : {}), ...(md ? { md } : {}) }) }, 'tool.start'(event) { if (isRemoteChat(event)) return diff --git a/plugins/timeline/src/window.js b/plugins/timeline/src/window.js index eada8d93..75b00acd 100644 --- a/plugins/timeline/src/window.js +++ b/plugins/timeline/src/window.js @@ -70,7 +70,7 @@ function fold(records) { break case 'te': { const turn = turns.get(record.id) - if (turn && turn.t1 === undefined) Object.assign(turn, { t1: Math.max(turn.t0, record.t), outcome: record.o, errorCode: record.ec }) + if (turn && turn.t1 === undefined) Object.assign(turn, { t1: Math.max(turn.t0, record.t), outcome: record.o, errorCode: record.ec, model: typeof record.md === 'string' ? cleanLabel(record.md, 120) : undefined }) break } case 'ks': @@ -165,6 +165,7 @@ export function buildWindow(records, { now = Date.now(), to, windowMs, bots = [] running: turn.t1 === undefined && live, ...(turn.outcome ? { outcome: turn.outcome } : {}), ...(turn.errorCode ? { errorCode: cleanCode(turn.errorCode) } : {}), + ...(turn.model ? { model: turn.model } : {}), ...(turn.lost ? { lost: true } : {}), ...(turn.trigger ? { trigger: { exchangeId: turn.trigger.x, ...(turn.trigger.from ? { from: laneOfParty(turn.trigger.from) } : {}), ...(turn.trigger.role ? { role: turn.trigger.role } : {}) } } : {}), tools: (toolsOf.get(turn.id) ?? []) diff --git a/plugins/timeline/test/panel.test.js b/plugins/timeline/test/panel.test.js index 29e06e27..98ecd8cc 100644 --- a/plugins/timeline/test/panel.test.js +++ b/plugins/timeline/test/panel.test.js @@ -480,3 +480,11 @@ test('#279: the window\'s time range is shown in plain view, with the date, its assert.match(textOf(findAll(back.tree, node => node.props.className === 'tl-range')[0]), /earlier window, not live/) assert.equal(findAll(mount(client.TimelinePanel, { api: fakeApi({ status: 'loading' }), onClose() {} }).tree, node => node.props.className === 'tl-range').length, 0, 'nothing before the first answer') }) + +test('a turn bar\'s tooltip names the model the turn ran on (#366)', () => { + const recs = records().map(record => (record.r === 'te' && record.id === 'a#1' ? { ...record, md: 'deepseek/deepseek-v4-pro' } : record)) + const g = client.layoutSwimlanes(win({ windowMs: 60_000 }, recs), {}) + const bar = id => g.bars.find(item => item.id === id) + assert.match(bar('a#1').title, /^Chief: .* · deepseek-v4-pro$/) + assert.doesNotMatch(bar('a#2').title, /deepseek/) +}) diff --git a/plugins/timeline/test/recorder.test.js b/plugins/timeline/test/recorder.test.js index d0a333d1..c054f79a 100644 --- a/plugins/timeline/test/recorder.test.js +++ b/plugins/timeline/test/recorder.test.js @@ -153,3 +153,13 @@ test('a friend\'s name from the feed party names its lane, and a later name repl feed('turn.start', { ...base, turnId: 's1#1', turn: 1, time: 4_110, trigger: { exchangeId: 'q1', from: { kind: 'remote', handle: '@chief.uia.home', name: "UiA's Chief" }, role: 'request', hop: 0 } }) assert.equal(trace.names().friends['f:chief.uia.home'], "UiA's Chief") }) + +test('a turn end keeps the model it ran on, cleaned; a turn with none stores no field (#366)', () => { + const { feed, records } = setup() + feed('turn.end', { ...base, turnId: 's1#1', turn: 1, time: 4_900, outcome: 'completed', model: 'deepseek/deepseek-v4-pro' }) + feed('turn.end', { ...base, turnId: 's1#2', turn: 2, time: 5_000, outcome: 'aborted' }) + feed('turn.end', { ...base, turnId: 's1#3', turn: 3, time: 5_100, outcome: 'completed', model: 'evil/