From a454cfc9fe3f955eda04002c71dec27d1ed47307 Mon Sep 17 00:00:00 2001 From: Menghuan1918 Date: Sun, 27 Sep 2026 01:04:35 +0800 Subject: [PATCH] =?UTF-8?q?refactor(tasks):=20=E7=A7=BB=E6=A4=8D=E4=B8=BB?= =?UTF-8?q?=20Agent=20=E5=90=8C=E6=AC=BE=E5=90=88=E5=B9=B6=E6=B4=BB?= =?UTF-8?q?=E5=8A=A8=E6=8A=98=E5=8F=A0=EF=BC=88=E6=95=B0=E6=8D=AE=E5=B1=82?= =?UTF-8?q?=EF=BC=89?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 把上一轮验证过的数据层搬到工作图基座上,图/树两种模式共用: - 新增 src/process-activity.ts:宿主 ui-chat 的 process group 算法移植 (activity() 分类表逐条照抄、liveToolDetail 键优先级与 160 字截断、 counts 计数排序与并列规则、preparing 语义、按 user/reply 边界切范围)。 按本轮设计裁剪掉逐阶段列表(阶段改由徽标 + 颜色表达)。新增一条 偏差:最新范围为空(子代理刚回复完)时回退到上一个「做过事」的范围, 否则已结算卡片的小条只会显示「已完成分析」。 - chatT:从宿主 chat 命名空间读 message.stepProcess.* 措辞(方法调用 + try/catch 兜底,key 未命中即回退),4 个回归用例钉住「不许解绑 bind」。 - subagents.live 载荷换新:SidebarChildLiveView { running?, text?, summary?, lastEventTime? },报告树内每个子节点(含已结算)与拓扑根;删除 expand/stages 机制;childActivity → childLive(「不在 map 里 = 未运行」 的读法随旧协议一起作废)。 - tasks-model 消费新形状;LiveLine 与节点详情浮窗改显示合并活动措辞。 - 删除 src/subagent-activity.ts 与 tests/subagent-activity.spec.ts。 验证:typecheck / lint 通过;pnpm test 1252 passed / 9 skipped。 (cherry picked from commit 5e5284f214846b0c366048acbee459f3a7a79456) --- src/client/SubagentView.tsx | 6 +- src/client/TasksPopovers.tsx | 7 +- src/client/api.ts | 9 +- src/client/locales.ts | 54 +++- src/client/process-labels.ts | 115 ++++++++ src/client/subagent-catalog.ts | 21 +- src/client/tasks-graph-layout.ts | 9 +- src/client/tasks-model.ts | 19 +- src/client/tasks-shared.tsx | 22 +- src/context-types.ts | 26 ++ src/process-activity.ts | 394 +++++++++++++++++++++++++++ src/subagent-activity.ts | 93 ------- src/subagent-live-route.ts | 87 +++--- tests/locales.spec.ts | 52 +++- tests/process-activity.spec.ts | 251 +++++++++++++++++ tests/process-labels.spec.ts | 122 +++++++++ tests/subagent-activity.spec.ts | 105 ------- tests/subagent-live-polling.spec.tsx | 8 +- tests/subagent-live-route.spec.ts | 112 ++++---- tests/tasks-graph-layout.spec.ts | 17 +- tests/tasks-model.spec.ts | 7 +- 21 files changed, 1198 insertions(+), 338 deletions(-) create mode 100644 src/client/process-labels.ts create mode 100644 src/process-activity.ts delete mode 100644 src/subagent-activity.ts create mode 100644 tests/process-activity.spec.ts create mode 100644 tests/process-labels.spec.ts delete mode 100644 tests/subagent-activity.spec.ts diff --git a/src/client/SubagentView.tsx b/src/client/SubagentView.tsx index 6b6f52b..4f125f9 100644 --- a/src/client/SubagentView.tsx +++ b/src/client/SubagentView.tsx @@ -34,6 +34,7 @@ import { } from '@deepseek-ai/dsh-client-ui-primitives' import type { Context, + SidebarChildLiveView, SidebarJobView, SidebarSessionList, SidebarSubagentAddress, @@ -45,7 +46,6 @@ import { } from './subagent-detect.ts' import { subagentCatalogs } from './subagent-catalog.ts' import { treeSessionIds } from './subagent-lineage.ts' -import { type LastActivity } from '../subagent-activity.ts' import { collectTreeJobs, orderJobs } from './subagent-jobs.ts' import { api, type TeamsViewResult } from './api.ts' import { usePolling } from './use-polling.ts' @@ -93,8 +93,8 @@ interface CatalogRefreshFace { function useSubagentLive( rootId: string | undefined, active: boolean, -): Readonly> { - const [live, setLive] = useState>({}) +): Readonly> { + const [live, setLive] = useState>({}) // A new tree must never inherit another root's live previews. useEffect(() => { setLive({}) }, [rootId]) diff --git a/src/client/TasksPopovers.tsx b/src/client/TasksPopovers.tsx index b198051..c0acceb 100644 --- a/src/client/TasksPopovers.tsx +++ b/src/client/TasksPopovers.tsx @@ -32,6 +32,7 @@ import type { TasksAgentNode, TasksWorkflowNode } from './tasks-model.ts' import { AgentGlyph, WorkflowGlyph, flatten, modeLabel, nodeDotState, taskDotState, taskStatusKey, workflowStatusKey, } from './tasks-shared.tsx' +import { liveActivityLabel } from './process-labels.ts' import { t, type CopyKey } from './locales.ts' import css from './tasks-graph.module.css' @@ -81,9 +82,9 @@ export function AgentNodePopover(props: { /** The row's mode word; `unknown` claims no mode and this row is omitted. */ const nodeMode = modeLabel(node.mode) const liveText = node.live?.text !== undefined ? flatten(node.live.text) : undefined - const liveTool = node.live?.tool !== undefined - ? `${node.live.tool.name}${node.live.tool.args === '' ? '' : ` ${node.live.tool.args}`}` - : undefined + // The merged activity the main conversation would show for this node's + // newest range (category wording + the running call's detail). + const liveTool = liveActivityLabel(node.live?.summary) return (
diff --git a/src/client/api.ts b/src/client/api.ts index 1e918e7..626db7c 100644 --- a/src/client/api.ts +++ b/src/client/api.ts @@ -7,10 +7,10 @@ * request). Failures surface as {@link SidebarApiError} with the wire code. */ import { encodeHtmlUrl } from '../html-route.ts' -import type { LastActivity } from '../subagent-activity.ts' import type { SidechatLiveEvent, SidechatLogEvent, SidechatThreadInfo } from '../sidechat-core.ts' import type { SidebarCreateTeamTaskRequest, + SidebarChildLiveView, SidebarJobView, SidebarSessionEvent, SidebarTeamMemberView, @@ -119,8 +119,8 @@ export interface JobOutputResult { read: boolean } -/** The `subagents.live` response: running child id → latest activity. */ -export type SubagentLiveResult = { live: Record } +/** The `subagents.live` response: one row per tree child (and the root). */ +export type SubagentLiveResult = { live: Record } /** The `workflows.list` response: the tree's folded workflow runs. */ export type WorkflowsListResult = { runs: WorkflowRunView[] } @@ -375,7 +375,8 @@ export const api = { /** * One batch live-preview fetch for the whole Subagent tree. The payload is * the already-resolved topology ROOT (not a session scope); the host - * enumerates descendants once and folds running children's activity. + * enumerates descendants once and folds every child's newest process range + * into the main agent's merged-activity summary. */ subagentsLive: (rootSessionId: string, signal?: AbortSignal) => call('subagents.live', { rootSessionId }, signal), diff --git a/src/client/locales.ts b/src/client/locales.ts index 54e9126..5c13904 100644 --- a/src/client/locales.ts +++ b/src/client/locales.ts @@ -976,7 +976,17 @@ export const LOCALE_NS = 'betterSidebar' // path most sessions never enter. /** The DSH locale service attached by the client apply (absent → browser detection). */ -let localeService: { getSnapshot(): { active: string } } | undefined +let localeService: { + getSnapshot(): { active: string } + /** + * Bind one registered namespace to a translate function (DSH's + * `LocaleRuntime.bind`). Optional because a consumer may attach only the + * snapshot face. The untyped overload is what makes cross-package key reads + * possible: an UNREGISTERED namespace yields a function that returns the key + * itself, which is exactly how {@link chatT} detects "not available". + */ + bind?(ns: string): (key: string, params?: Record) => string +} | undefined /** * The better-locale override store attached by the client apply @@ -1003,10 +1013,50 @@ let betterLocaleStore: { * `t()` function, and the Sidebar root's locale subscription re-renders the * whole tree on switches. */ -export function attachLocale(service: { getSnapshot(): { active: string } } | undefined): void { +export function attachLocale(service: { + getSnapshot(): { active: string } + /** Optional namespace binding ({@link chatT} needs it; a bare snapshot face is still valid). */ + bind?(ns: string): (key: string, params?: Record) => string +} | undefined): void { localeService = service } +/** + * Translate a key in the HOST's own `chat` namespace — the wording the main + * conversation's merged "process" header uses (`message.stepProcess.*`, + * registered by `@deepseek-ai/dsh-client-ui-chat`). The Tasks page reads it so + * a running node reads exactly like the main agent's own activity line instead + * of inventing a second vocabulary (and without adding ~40 keys to 20 plugin + * dictionaries). + * + * Deliberately untyped: these keys are NOT a documented host contract. A host + * that renames one, or a deployment where the chat target never mounted, + * returns the key unchanged — the caller then falls back to its own wording + * ({@link chatT} answers undefined) rather than printing `message.…`. + * + * @param key - full `chat`-namespace key. + * @param params - `{name}` placeholders to interpolate (the host does this). + * @returns the translated text, or undefined when unavailable. + */ +export function chatT(key: string, params?: Record): string | undefined { + const service = localeService + if (service === undefined) return undefined + try { + // MUST stay a METHOD call: DSH's `bind` reads `this.bound`, so a detached + // reference (`const bind = service.bind; bind('chat')`) throws + // "Cannot read properties of undefined (reading 'bound')" — caught by the + // real-host mount lane, invisible to closure fakes in unit tests. + const translate = service.bind?.('chat') + if (typeof translate !== 'function') return undefined + const text = translate(key, params) + return typeof text === 'string' && text !== '' && text !== key ? text : undefined + } catch { + // The namespace seam is undocumented: a host that reshapes it must degrade + // to the caller's own wording, never break a render. + return undefined + } +} + /** * Attach (or detach, with undefined) the better-locale override store. * When attached with an active override, `t()` consults the store's diff --git a/src/client/process-labels.ts b/src/client/process-labels.ts new file mode 100644 index 0000000..6ad2097 --- /dev/null +++ b/src/client/process-labels.ts @@ -0,0 +1,115 @@ +/** + * The two labels the Tasks page borrows from the main conversation's merged + * activity display: the LIVE line ("正在运行命令 · npm run build") and the + * DONE title for a settled range ("已读取文件、执行了命令"). + * + * Both are ports of the host's own composition + * (`dsh-client-ui-chat`'s `ProcessGroupHeader` + `step-process.js` + * `processTitle`), reading the host's `chat` dictionary through + * {@link chatT} so the wording — and every language the host ships — matches + * what the main chat shows for the same work. + * + * The translator is injectable for tests; the default is the host bridge. When + * a key does not resolve (a host that renamed it, or a deployment where the + * chat target never mounted) the call answers undefined and the card falls + * back to the tool detail it already has, never to a raw `message.…` key. + */ +import type { ProcessActivitySummary } from '../process-activity.ts' +import { chatT } from './locales.ts' + +/** A host-namespace translator; undefined means "this key is not available". */ +export type ChatTranslate = (key: string, params?: Record) => string | undefined + +/** The host's `' · '` line separator (`message.turnProcess.separator`). */ +function separator(t: ChatTranslate): string { + return t('message.turnProcess.separator') ?? ' · ' +} + +/** + * The live line of one stage: the running category's `…ing` wording plus the + * running call's detail, or the "thinking" default while nothing is dispatched + * (exactly the host's `ProcessGroupHeader` label rule). + * + * @param summary - the newest stage's summary (absent while the child has none). + * @param t - host translator (defaults to the `chat` namespace bridge). + * @returns the line, or undefined when there is nothing to say. + */ +export function liveActivityLabel( + summary: ProcessActivitySummary | undefined, + t: ChatTranslate = chatT, +): string | undefined { + const activity = summary?.running ?? 'thinking' + const preparing = summary?.preparing === true + // The host maps its `thinking` fallback onto `prepare.tools` (there is no + // `prepare.thinking` key). + const label = preparing + ? t(`message.stepProcess.prepare.${activity === 'thinking' ? 'tools' : activity}`) + : t(`message.stepProcess.${activity}`) + if (label === undefined) { + // No host wording for this category: the detail is the honest fallback. + return summary?.runningDetail === undefined || summary.runningDetail === '' + ? undefined + : summary.runningDetail + } + const detail = preparing ? '' : summary?.runningDetail ?? '' + return detail === '' ? label : `${label}${separator(t)}${detail}` +} + +/** + * The last-resort activity text: the ranked categories spelled out as + * `kind ×count`. It keeps a card informative when the host's `chat` wording is + * unavailable (renamed keys, or a deployment where the chat target never + * mounted) without inventing a second vocabulary for the normal case — the + * category names are the host's own, and they are language-neutral. + * + * @param summary - a stage's summary. + * @returns the counts text, or undefined when the stage called nothing. + */ +export function activityCountsText( + summary: ProcessActivitySummary | undefined, +): string | undefined { + const counts = summary?.counts ?? [] + if (counts.length === 0) return undefined + return counts.map(entry => `${entry.kind} ×${entry.count}`).join(' · ') +} + +/** + * The settled range's title: the top three categories' `done.*` wording joined + * the way the host joins it (two labels share the "已" prefix; more than three + * append the "等" suffix). Counts are never printed. + * + * @param summary - a stage's summary. + * @param t - host translator (defaults to the `chat` namespace bridge). + * @returns the title, or undefined when the host wording is unavailable. + */ +export function doneActivityTitle( + summary: ProcessActivitySummary | undefined, + t: ChatTranslate = chatT, +): string | undefined { + const counts = summary?.counts ?? [] + if (counts.length === 0) return t('message.stepProcess.done.thinking') + const labels: string[] = [] + for (const { kind } of counts.slice(0, 3)) { + const label = t(`message.stepProcess.done.${kind}`) + if (label === undefined) return undefined + labels.push(label) + } + const first = labels[0] + if (first === undefined) return undefined + const second = labels[1] + if (second === undefined) return first + // The host lowercases every label after the first for latin languages; a + // Chinese label is unaffected by that. + const continuation = (label: string): string => label.charAt(0).toLowerCase() + label.slice(1) + if (labels.length === 2) { + const prefix = t('message.stepProcess.sharedPrefix') ?? '' + const stripped = prefix !== '' && first.startsWith(prefix) && second.startsWith(prefix) + ? second.slice(prefix.length) + : second + return t('message.stepProcess.joinTwo', { first, second: continuation(stripped) }) + ?? `${first}${separator(t)}${continuation(stripped)}` + } + const joined = [first, ...labels.slice(1).map(continuation)].join(t('message.stepProcess.comma') ?? ', ') + if (counts.length > 3) return t('message.stepProcess.more', { title: joined }) ?? joined + return joined +} diff --git a/src/client/subagent-catalog.ts b/src/client/subagent-catalog.ts index aadf370..51065ee 100644 --- a/src/client/subagent-catalog.ts +++ b/src/client/subagent-catalog.ts @@ -32,7 +32,7 @@ import type { SidebarSessionList, SidebarSubagentCatalogEntry, } from '../context-types.ts' -import type { LastActivity } from '../subagent-activity.ts' +import type { SidebarChildLiveView } from '../context-types.ts' /** One parent's direct-child catalog, as the topology consumes it. */ export interface SubagentCatalogView { @@ -89,16 +89,19 @@ export function isKnownLeaf( } /** - * The child's live state. The `subagents.live` route folds only RUNNING - * children (a Side Chat thread and an idle child are absent), so absence is - * the inactive answer rather than missing data. + * One child's live view, or undefined when the batch has not reported it yet + * (the first poll of a freshly rooted tree). * - * @param live - the batch live map (`child id → latest activity`). + * The `subagents.live` route reports EVERY tree child with an explicit + * `running` flag — the previous "absent means not running" reading cannot + * survive a route that also reports settled children's summaries. + * + * @param live - the batch live map (`session id → live view`). * @param childSessionId - the child row being drawn. */ -export function childActivity( - live: Readonly>, +export function childLive( + live: Readonly>, childSessionId: string, -): 'running' | 'inactive' { - return live[childSessionId] === undefined ? 'inactive' : 'running' +): SidebarChildLiveView | undefined { + return live[childSessionId] } diff --git a/src/client/tasks-graph-layout.ts b/src/client/tasks-graph-layout.ts index 6d3d304..b39be96 100644 --- a/src/client/tasks-graph-layout.ts +++ b/src/client/tasks-graph-layout.ts @@ -10,6 +10,7 @@ * the core bundle; <100-node trees need no virtualisation). */ import type { TasksNode } from './tasks-model.ts' +import type { SidebarChildLiveView } from '../context-types.ts' import { tasksEdges } from './tasks-model.ts' /** Geometry constants of the canvas (px, pre-scale). The design target is the @@ -115,12 +116,18 @@ export interface LayoutOptions { * The tallest shape therefore is base + live + live-tail + task, which stays * inside {@link GRAPH_ROW_STRIDE} (rows can never overlap). */ +/** Whether a node's live view carries a merged-activity row to render. */ +function hasMergedActivity(live: SidebarChildLiveView | undefined): boolean { + if (live === undefined) return false + return (live.summary?.counts.length ?? 0) > 0 || live.summary?.running !== undefined +} + function nodeHeight(node: TasksNode): number { if (node.kind !== 'agent') return GRAPH_NODE_H let height = GRAPH_NODE_H if (node.state === 'running') { height += GRAPH_LIVE_H - if (node.live?.tool !== undefined && node.live.text !== undefined) height += GRAPH_LIVE_TAIL_H + if (hasMergedActivity(node.live) && node.live?.text !== undefined) height += GRAPH_LIVE_TAIL_H } if ((node.tasks?.length ?? 0) > 0) height += GRAPH_TASK_H return height diff --git a/src/client/tasks-model.ts b/src/client/tasks-model.ts index 320a51b..004d27d 100644 --- a/src/client/tasks-model.ts +++ b/src/client/tasks-model.ts @@ -26,10 +26,10 @@ import type { SidebarTeamMemberView, SidebarTeamTaskView, } from '../context-types.ts' -import type { LastActivity } from '../subagent-activity.ts' +import type { SidebarChildLiveView } from '../context-types.ts' import type { WorkflowRunView } from '../workflow-runs.ts' import { isSideThreadSummary } from './subagent-detect.ts' -import { childActivity, isKnownLeaf, type SubagentCatalogView } from './subagent-catalog.ts' +import { childLive, isKnownLeaf, type SubagentCatalogView } from './subagent-catalog.ts' /** Display state of one agent node (drives the dot + fold candidacy). */ export type TasksNodeState = 'running' | 'idle' | 'done' | 'error' @@ -59,8 +59,8 @@ export interface TasksAgentNode { activity: 'running' | 'inactive' /** The on-screen session (the "you are here" marker). */ current: boolean - /** Live tail of a running child (the icon + tool + args line). */ - live?: LastActivity + /** Live tail of a running child (the merged activity + newest text). */ + live?: SidebarChildLiveView /** Team enrichment (membership view matched by session id). */ team?: { role: 'lead' | 'teammate' @@ -108,7 +108,7 @@ export interface TasksModelInput { catalogs: Readonly> rootId: string currentSessionId: string - live: Readonly> + live: Readonly> runs: readonly WorkflowRunView[] teamMembers: readonly SidebarTeamMemberView[] /** The team's shared tasks; each lands on its OWNER's node. */ @@ -215,9 +215,10 @@ export function buildTasksModel(input: TasksModelInput): TasksNode[] { if (entry.label?.startsWith('Side: ') ?? false) continue const team = teamOf(entry.id) // DSH 0.1.7's catalog row carries the identity only: the live channel - // folds RUNNING children (absence = not running) and a row keeps its - // disclosure unless its own catalog is known-empty. - const activity = childActivity(live, entry.id) + // reports EVERY child with an explicit `running` flag, and a row keeps + // its disclosure unless its own catalog is known-empty. + const view = childLive(live, entry.id) + const activity: 'running' | 'inactive' = view?.running === true ? 'running' : 'inactive' const state: TasksNodeState = activity === 'running' ? 'running' : team !== undefined ? teamState(team.status) : 'done' @@ -232,7 +233,7 @@ export function buildTasksModel(input: TasksModelInput): TasksNode[] { state, activity, current: entry.id === currentSessionId, - ...(live[entry.id] !== undefined ? { live: live[entry.id] } : {}), + ...(view !== undefined ? { live: view } : {}), ...(team !== undefined ? { team } : {}), ...(tasksByNode.get(entry.id) !== undefined ? { tasks: tasksByNode.get(entry.id) } : {}), hasChildren: !isKnownLeaf(catalogs, entry.id), diff --git a/src/client/tasks-shared.tsx b/src/client/tasks-shared.tsx index 9e21341..003354f 100644 --- a/src/client/tasks-shared.tsx +++ b/src/client/tasks-shared.tsx @@ -16,7 +16,8 @@ import { IconAgentPresetOutlineRegular, IconBranchOutlineRegular, IconChecklistOutlineRegular, IconUserOutlineRegular, type StateDotState, } from '@deepseek-ai/dsh-client-ui-primitives' -import type { LastActivity } from '../subagent-activity.ts' +import type { SidebarChildLiveView } from '../context-types.ts' +import { liveActivityLabel } from './process-labels.ts' import type { TasksAgentNode, TasksNodeState, TasksNodeTask, TasksWorkflowNode } from './tasks-model.ts' import { toolGlyph } from './tool-icons.tsx' import { t, type CopyKey } from './locales.ts' @@ -189,25 +190,26 @@ export function TaskLine(props: { } /** - * The live activity row of a RUNNING agent: the tool's own icon + tool name + - * args (the approved format), plus the flattened last text line underneath. + * The live activity row of a RUNNING agent: the merged process summary the + * main conversation would show for the child's newest range (category wording + * + the running call's detail), plus the flattened last text line underneath. * A running node with neither reads as thinking. */ -export function LiveLine(props: { live: LastActivity | undefined }): ReactNode { +export function LiveLine(props: { live: SidebarChildLiveView | undefined }): ReactNode { const { live } = props - if (live?.text === undefined && live?.tool === undefined) { + const activity = liveActivityLabel(live?.summary) + if (activity === undefined && live?.text === undefined) { return {t('subagentThinking')} } return ( <> - {live.tool !== undefined && ( + {activity !== undefined && ( - - {live.tool.name} - {live.tool.args !== '' && {preview(live.tool.args)}} + + {activity} )} - {live.text !== undefined && ( + {live?.text !== undefined && ( {preview(flatten(live.text), TEXT_PREVIEW)} )} diff --git a/src/context-types.ts b/src/context-types.ts index 9e110ce..c2164cd 100644 --- a/src/context-types.ts +++ b/src/context-types.ts @@ -30,6 +30,7 @@ */ import type { Context as CordisContext } from '@deepseek-ai/cordis' import type { BetterSidebarService } from './client/service.ts' +import type { ProcessActivitySummary } from './process-activity.ts' /** The request face route handlers see (structural subset of node's * IncomingMessage: the URL/method/header reads and the async body @@ -173,6 +174,31 @@ export interface SidebarHistoryEntry { /** Lifecycle status set of one background job (closed wire union). */ export type SidebarJobStatus = 'running' | 'stopping' | 'completed' | 'killed' | 'failed' +/** + * One tree child's live view as the `subagents.live` route reports it: the + * catalog's activity flag plus the fold of the child's newest process range + * (see ./process-activity.ts). + * + * `running` is the catalog's flag and is therefore always present on a CHILD + * row; the fold's fields are optional. The route used to report running + * children only, so "absent from the map" meant "not running" — a reading that + * cannot survive a route which also reports settled children's summaries. + */ +export interface SidebarChildLiveView { + /** + * The catalog's activity flag. Present on every CHILD row; ABSENT on the + * topology root, which the host cannot classify (the caller already knows + * its session's running state from the session list). + */ + running?: boolean + /** The child's newest assistant text (the card's detail line). */ + text?: string + /** The newest range's merged activity; absent when that range called nothing. */ + summary?: ProcessActivitySummary + /** Epoch ms of the newest event observed. */ + lastEventTime?: number +} + /** * One background job as the client mirror sees it (wire `JobView` shape: * id/kind/label/status/detail?/startedAt/finishedAt?). diff --git a/src/process-activity.ts b/src/process-activity.ts new file mode 100644 index 0000000..8d5e8b5 --- /dev/null +++ b/src/process-activity.ts @@ -0,0 +1,394 @@ +/** + * The plugin's port of the MAIN AGENT's merged "process" display: the compact + * activity summary DSH's chat shows for a range of tool calls. + * + * The host computes that summary over materialized Chat nodes inside + * `@deepseek-ai/dsh-client-ui-chat` (`conversation-nodes/process-activity.js` + * → `activity()` / `liveToolDetail()` / `processActivity()`, rendered by + * `ProcessGroupHeader`). None of it is exported — the client bundle's only + * runtime exports are `apply`/`inject`/`isRunningTool`/`isSettledTool` — so a + * surface that has only a SESSION EVENT LOG (a subagent's, which the chat view + * never materializes) must reproduce the algorithm. This module is that + * reproduction, kept framework-free so the host route and the node test + * environment share one implementation. + * + * Faithful points, all deliberate: + * + * - the tool NAME → category map is copied verbatim (including the + * `terminal_` prefix rule and the `*_inspect` suffix rule); + * - `counts` ranks by distinct-call count descending, ties broken by first + * appearance (the host relies on `Array.sort` stability + Map insertion + * order); + * - the running tool is the LAST unsettled call, and a "preparing" call (one + * the model announced but never dispatched) counts and can be the running + * one, exactly like the host's `PreparingToolCall` branch; + * - `runningDetail` walks the host's key list in order and truncates the same + * way; + * - the range is cut where the host's process GROUPS are cut: at a + * `user/message` and at every assistant message that carries text + * (`TurnGroups.rebuild`'s `INDEPENDENT` set plus its `reply(node)` flush). + * + * Deliberate deviations, all forced by the input: + * + * - the host's `runningDetail` falls back to the last REASONING paragraph of a + * running assistant step; a durable log carries no partial step, so the + * fallback is omitted (the label then reads as the "thinking" default); + * - the host reads `arguments` only for a dispatched call; a durable + * `assistant/message` already carries the announced block's `arguments`, but + * the preparing detail still follows the host (no detail at all) so both + * sides read the same; + * - an unsettled call at the tail of a SETTLED session is reported as not + * running ({@link foldProcess}'s `live: false`), mirroring the host's + * `closed` groups (`summary = { counts, running: undefined, runningDetail: '' }`); + * - when the NEWEST range holds no call at all (the child just replied), the + * fold reports the previous range instead — a card whose bar read "已完成分析" + * because the last thing that happened was a sentence would tell the reader + * nothing. The fallback range is never reported as running. + * + * This module deliberately does NOT build a per-STAGE list: the Tasks page + * marks workflow phases with a badge + colour instead (see tasks-model.ts), so + * only the current range's summary is derived here. + */ +import type { SidebarSessionEvent } from './context-types.ts' + +/** The host's `ProcessActivity` union, verbatim. */ +export type ProcessActivity = + | 'read' | 'readImage' | 'search' | 'write' | 'edit' | 'commands' | 'code' + | 'webSearch' | 'webFetch' | 'subagents' | 'plan' | 'questions' | 'tools' + +/** + * The host's `ProcessActivitySummary` shape: the ranked work of one range plus + * the live task detail. `preparing` exists only while the selected live + * activity has not reached its `tool/call` event. + */ +export interface ProcessActivitySummary { + counts: { kind: ProcessActivity; count: number }[] + running?: ProcessActivity + runningDetail: string + preparing?: true +} + +/** One fold result: the newest range's summary plus the newest text line. */ +export interface ProcessFold { + /** What a node card's status bar shows. */ + current: ProcessActivitySummary + /** The newest assembled assistant text in the window (the card's detail line). */ + text?: string + /** Epoch ms of the newest event in the window. */ + lastEventTime?: number +} + +/** How many events one fold may scan backwards (bounds the cost per child). */ +export const PROCESS_WINDOW_EVENTS = 400 +/** Detail cap, mirroring the host's `LIVE_TOOL_DETAIL_MAX_CHARS`. */ +export const PROCESS_DETAIL_MAX_CHARS = 160 +/** Cap of the detail text line sent to the client. */ +export const PROCESS_TEXT_MAX_CHARS = 400 + +/** + * The host's category map, verbatim (the arms are mutually exclusive, so the + * order only mirrors the host's for review). + * @param name - durable tool-call name. + * @returns the category the main agent's process group counts it under. + */ +export function activityOf(name: string): ProcessActivity { + if (name === 'read') return 'read' + if (name === 'read_image') return 'readImage' + if (name === 'grep' || name === 'glob' || name.endsWith('_inspect')) return 'search' + if (name === 'write') return 'write' + if (name === 'edit' || name === 'apply_patch') return 'edit' + if (['bash', 'pwsh', 'exec_command', 'write_stdin'].includes(name) || name.startsWith('terminal_')) { + return 'commands' + } + if (name === 'run_code') return 'code' + if (name === 'web_search') return 'webSearch' + if (name === 'web_fetch') return 'webFetch' + if (name === 'subagent' || name.startsWith('subagent_')) return 'subagents' + if (['todo_write', 'create_goal', 'update_goal', 'get_goal'].includes(name)) return 'plan' + if (name === 'ask_user_question' || name === 'request_user_input') return 'questions' + return 'tools' +} + +/** The host's live-detail key priority, verbatim. */ +const LIVE_TOOL_DETAIL_KEYS = [ + 'title', 'description', 'objective', 'task', 'task_name', 'name', 'question', + 'questions', 'prompt', 'message', 'command', 'cmd', 'queries', 'query', + 'pattern', 'url', 'uri', 'file_path', 'path', 'target', 'action', 'status', +] as const + +/** Grapheme segmenter when the runtime has one (the host relies on `Intl.Segmenter`). */ +const SEGMENTER = typeof Intl !== 'undefined' && typeof Intl.Segmenter === 'function' + ? new Intl.Segmenter(undefined, { granularity: 'grapheme' }) + : undefined + +/** Split into user-perceived characters, falling back to code points. */ +function graphemes(text: string): string[] { + if (SEGMENTER === undefined) return Array.from(text) + return Array.from(SEGMENTER.segment(text), part => part.segment) +} + +/** Collapse whitespace and truncate on a grapheme boundary (the host's normalize). */ +function normalizeDetail(value: unknown): string { + const flat = (typeof value === 'string' + ? value + : Array.isArray(value) && value.every(item => typeof item === 'string') + ? value.join(', ') + : '').replace(/\s+/g, ' ').trim() + const chars = graphemes(flat) + if (chars.length <= PROCESS_DETAIL_MAX_CHARS) return flat + return `${chars.slice(0, PROCESS_DETAIL_MAX_CHARS - 1).join('').trimEnd()}…` +} + +/** The first `question` string of an `ask_user_question` argument list. */ +function questionDetail(value: unknown): string { + if (!Array.isArray(value)) return '' + for (const item of value) { + if (item === null || typeof item !== 'object') continue + const detail = normalizeDetail((item as { question?: unknown }).question) + if (detail !== '') return detail + } + return '' +} + +/** + * The host's `liveToolDetail`: parse the raw arguments and take the first + * present key of {@link LIVE_TOOL_DETAIL_KEYS}, falling back to the tool name. + * @param name - tool-call name. + * @param argsRaw - the call's raw `arguments` JSON string. + */ +export function liveToolDetail(name: string, argsRaw: string): string { + let args: unknown + try { + args = JSON.parse(argsRaw) + } catch { + return normalizeDetail(name) + } + if (args === null || typeof args !== 'object') return normalizeDetail(name) + const record = args as Record + for (const key of LIVE_TOOL_DETAIL_KEYS) { + if (!(key in record)) continue + const value = record[key] + const detail = key === 'questions' ? questionDetail(value) : normalizeDetail(value) + if (detail !== '') return detail + } + return normalizeDetail(name) +} + +/** + * Extract the concatenated plain text of a content-block list (the durable + * `ContentBlock[]` shape, structurally: blocks with `type: 'text'` carry + * `text`; anything else — a tool call, an image, … — contributes nothing). + * @param content - the raw `content` field of a message event. + * @returns the joined text, or undefined when the message carries no text. + */ +export function contentText(content: unknown): string | undefined { + if (!Array.isArray(content)) return undefined + const parts: string[] = [] + for (const block of content) { + if (block === null || typeof block !== 'object') continue + const candidate = block as { type?: unknown; text?: unknown } + if (candidate.type === 'text' && typeof candidate.text === 'string') { + parts.push(candidate.text) + } + } + return parts.length > 0 ? parts.join('\n') : undefined +} + +/** One announced or dispatched call inside the current range. */ +interface CallRecord { + callId: string + name: string + args: string + seq: number + /** A paired `tool/result` was observed in the scanned window. */ + settled: boolean + /** Announced by the model but never dispatched (the host's `PreparingToolCall`). */ + preparing: boolean +} + +/** A tool-call content block under BOTH durable vocabularies (0.1.7 `tool-call`; older logs `tool_use`). */ +function announcedCalls(content: unknown): { callId: string; name: string; args: string }[] { + if (!Array.isArray(content)) return [] + const found: { callId: string; name: string; args: string }[] = [] + for (const block of content) { + if (block === null || typeof block !== 'object') continue + const candidate = block as { type?: unknown; id?: unknown; callId?: unknown; name?: unknown; arguments?: unknown } + if (candidate.type !== 'tool-call' && candidate.type !== 'tool_use') continue + const rawId = candidate.id ?? candidate.callId + if (typeof rawId !== 'string' || typeof candidate.name !== 'string') continue + found.push({ + callId: rawId, + name: candidate.name, + args: typeof candidate.arguments === 'string' ? candidate.arguments : '', + }) + } + return found +} + +/** The callId of one `tool/result` event under both logged shapes. */ +function resultCallId(data: Record): string | undefined { + const message = data.message as { source?: { callId?: unknown }; toolCallId?: unknown } | undefined + for (const candidate of [data.callId, message?.source?.callId, message?.toolCallId, data.toolCallId]) { + if (typeof candidate === 'string' && candidate !== '') return candidate + } + return undefined +} + +/** Rank one range's calls into the host's summary shape. */ +function summarize(calls: readonly CallRecord[], live: boolean): ProcessActivitySummary { + const counts = new Map() + let running: ProcessActivity | undefined + let preparing: true | undefined + let runningDetail = '' + let runningSeq = -Infinity + // The host counts in FORWARD member order, so a count tie ranks by first + // appearance; this fold collects backwards and must restore that order. + const ordered = calls.slice().sort((left, right) => left.seq - right.seq) + for (const call of ordered) { + const kind = activityOf(call.name) + counts.set(kind, (counts.get(kind) ?? 0) + 1) + if (call.settled || !live) continue + // The host selects on `>=` in event order: the newest unsettled call wins. + if (call.seq < runningSeq) continue + runningSeq = call.seq + running = kind + preparing = call.preparing ? true : undefined + runningDetail = call.preparing ? (kind === 'tools' ? call.name : '') : liveToolDetail(call.name, call.args) + } + const ranked = [...counts].map(([kind, count]) => ({ kind, count })) + ranked.sort((left, right) => right.count - left.count) + return { + counts: ranked, + ...(running === undefined ? {} : { running }), + runningDetail, + ...(preparing === true ? { preparing: true as const } : {}), + } +} + +/** Cap one detail line without cutting a surrogate pair in half. */ +function truncateText(text: string): string { + const chars = graphemes(text) + if (chars.length <= PROCESS_TEXT_MAX_CHARS) return text + return `${chars.slice(0, PROCESS_TEXT_MAX_CHARS - 1).join('').trimEnd()}…` +} + +/** The calls of the range currently being collected. */ +interface Range { + calls: CallRecord[] + ids: Set +} + +/** Start an empty range. */ +function openRange(): Range { + return { calls: [], ids: new Set() } +} + +/** Append one call to a range (deduped by callId). */ +function addCall(range: Range, call: CallRecord): void { + if (range.ids.has(call.callId)) return + range.calls.push(call) + range.ids.add(call.callId) +} + +/** + * Fold a session event log into the host's merged activity view: the newest + * range's summary and the newest text line. Scans BACKWARD from the end and + * stops at the range it reports, so a long history costs only its recent tail. + * + * @param events - the session's append-only event log (oldest → newest). + * @param options - `live` (whether the session is executing; a settled one + * reports no running tool, like the host's closed groups) and `maxEvents`. + * @returns the current summary plus the optional detail fields. + */ +export function foldProcess( + events: readonly SidebarSessionEvent[], + options: { live: boolean; maxEvents?: number }, +): ProcessFold { + const live = options.live + const maxEvents = Math.max(1, options.maxEvents ?? PROCESS_WINDOW_EVENTS) + + let range = openRange() + /** The newest NON-EMPTY range (the fallback when the newest one is empty). */ + let answer: CallRecord[] | undefined + /** Whether {@link answer} is the newest range (only then can it be running). */ + let answerLive = false + const settled = new Set() + let latestText: string | undefined + let lastEventTime: number | undefined + let crossed = 0 + + for (let index = events.length - 1, scanned = 0; index >= 0; index -= 1) { + if (scanned >= maxEvents) break + const event = events[index] + if (event === undefined) continue + scanned += 1 + lastEventTime ??= event.time + const { type, data } = event + + if (type === 'tool/result') { + const callId = resultCallId(data) + if (callId !== undefined) settled.add(callId) + continue + } + + if (type === 'tool/call') { + const callId = typeof data.callId === 'string' && data.callId !== '' ? data.callId : `anon-${event.seq}` + addCall(range, { + callId, + name: typeof data.name === 'string' ? data.name : 'tool', + args: typeof data.arguments === 'string' ? data.arguments : '', + seq: event.seq, + settled: settled.has(callId), + preparing: false, + }) + continue + } + + if (type !== 'assistant/message' && type !== 'user/message') continue + const message = type === 'assistant/message' + ? (data.message as { content?: unknown } | undefined) + : undefined + if (message !== undefined) { + // Announced calls belong to the range that FOLLOWS their assistant + // message — the range being collected right now. + for (const announced of announcedCalls(message.content)) { + const isSettled = settled.has(announced.callId) + addCall(range, { + callId: announced.callId, + name: announced.name, + args: announced.args, + seq: event.seq, + settled: isSettled, + preparing: !isSettled, + }) + } + } + const text = message === undefined ? undefined : contentText(message.content) + if (text !== undefined) latestText ??= text + // A text-bearing assistant message and a user message both END the range + // to their left (the host's flush points). + if (type !== 'user/message' && text === undefined) continue + if (range.calls.length > 0) { + // The range just closed is the newest one that did anything. + answer = range.calls + answerLive = crossed === 0 + break + } + // Nothing happened in this range: step past it into the previous one, once. + crossed += 1 + if (crossed > 1) break + range = openRange() + } + + // `answer` is set when a boundary closed a range that did something; the + // range still in hand covers two ends of the log: the whole log (no boundary + // at all — that range IS the newest) and the one the scan ran out of events + // in (a fallback range, so it is never live). + const calls = answer ?? range.calls + const newest = answer === undefined ? crossed === 0 : answerLive + return { + current: summarize(calls, live && newest), + ...(latestText === undefined ? {} : { text: truncateText(latestText) }), + ...(lastEventTime === undefined ? {} : { lastEventTime }), + } +} diff --git a/src/subagent-activity.ts b/src/subagent-activity.ts deleted file mode 100644 index d44d07d..0000000 --- a/src/subagent-activity.ts +++ /dev/null @@ -1,93 +0,0 @@ -/** - * Pure derivation of the compact LIVE line shown on a running subagent card: - * the last text output and the last tool call of the child's session events. - * The host batch route feeds raw session events into this parser; the client - * only receives the already-folded `LastActivity` map. Renders nothing - * itself — the SubagentView component turns this into the card's status - * lines. Kept framework-free so the parser is unit-testable in the node - * environment. - */ -import type { SidebarSessionEvent } from './context-types.ts' - -/** - * Extract the concatenated plain text of a content-block list (the durable - * `ContentBlock[]` shape, structurally: blocks with `type: 'text'` carry - * `text`; anything else — tool_use, image, … — contributes nothing). - * @param content - the raw `content` field of a message event. - * @returns the joined text, or undefined when the message carries no text. - */ -export function contentText(content: unknown): string | undefined { - if (!Array.isArray(content)) return undefined - const parts: string[] = [] - for (const block of content) { - if (block === null || typeof block !== 'object') continue - const candidate = block as { type?: unknown; text?: unknown } - if (candidate.type === 'text' && typeof candidate.text === 'string') { - parts.push(candidate.text) - } - } - return parts.length > 0 ? parts.join('\n') : undefined -} - -/** The live status of one subagent card (both fields optional). */ -export interface LastActivity { - /** The latest assembled assistant text output in the tail. */ - text?: string - /** The latest tool call in the tail. */ - tool?: { name: string; args: string } -} - -/** - * Fold a session event log into the last text output + last tool call (each - * is the LAST occurrence in event order). Lifecycle events and raw stream - * rows are ignored — the card shows what the subagent is doing right now, - * not its plumbing. (DSH 0.1.5 publishes the raw deltas as process-local - * frames instead of log events, so only the assembled `assistant/message` - * reaches this scan.) The scan runs BACKWARD from the newest - * event and stops once both fields are found, so a long history costs only - * the recent tail in the common case. - * @param events - the session's append-only event log (oldest → newest). - * @param maxMessages - optional message-boundary window: only the tail's - * last `maxMessages` surface messages (`user/message`, `assistant/message`) - * and the events between them are considered, mirroring the old - * `subagents.history({ maxMessages })` window. Stale activity older than - * the window is never surfaced, and a long log is never scanned in full. - * @returns the last text and/or tool call; an empty object when the log has neither. - */ -export function lastActivity( - events: readonly SidebarSessionEvent[], - maxMessages = Infinity, -): LastActivity { - let text: string | undefined - let tool: { name: string; args: string } | undefined - let messagesSeen = 0 - for (let index = events.length - 1; index >= 0; index -= 1) { - if (text !== undefined && tool !== undefined) break - const event = events[index] - if (event === undefined) continue - const { type, data } = event - if (type === 'user/message' || type === 'assistant/message') { - messagesSeen += 1 - if (messagesSeen > maxMessages) break - } else if (messagesSeen >= maxMessages) { - // The window already holds its `maxMessages` messages: anything older - // than the oldest in-window message sits outside the recent window. - continue - } - if (text === undefined && type === 'assistant/message') { - const message = data.message as { content?: unknown } | undefined - const extracted = contentText(message?.content) - if (extracted !== undefined) text = extracted - } else if (tool === undefined && type === 'tool/call') { - tool = { - name: typeof data.name === 'string' ? data.name : 'tool', - args: typeof data.arguments === 'string' ? data.arguments : '', - } - } - } - if (text === undefined && tool === undefined) return {} - return { - ...(text === undefined ? {} : { text }), - ...(tool === undefined ? {} : { tool }), - } -} diff --git a/src/subagent-live-route.ts b/src/subagent-live-route.ts index f4e3d24..030e0f1 100644 --- a/src/subagent-live-route.ts +++ b/src/subagent-live-route.ts @@ -4,42 +4,44 @@ * * The route takes the already-resolved topology root (`rootSessionId`), * enumerates the whole descendant tree ONCE through the host subagent - * runtime (`ctx.get('subagents')` / `listDescendants`), keeps only rows the - * catalog reports running (`activity: 'running'` — the same gate the client - * renders cards on), and folds the newest text/tool activity from each - * child's attached session event log. It never touches DSH source and never - * reads the model's `job_output` cursor. + * runtime (`ctx.get('subagents')` / `listDescendants`), and folds EVERY child + * session's newest process range with {@link foldProcess} — the plugin's port + * of the main agent's merged process summary (category counts + the one + * running call + its detail). It never touches DSH source and never reads the + * model's `job_output` cursor. + * + * Folding a settled child is cheap by construction: the default fold stops at + * the first range boundary it can report, so a child that is idle between + * turns costs a handful of event reads (`snapshotEvents()` itself returns the + * session's cached frozen snapshot, not a per-call copy). * * Degradation contract: * - `ctx.get('subagents')` missing or `listDescendants` failure → 503 (the * Subagent page has no topology to show in such deployments anyway). - * - One child's events missing/corrupt → that child is skipped, the rest of - * the batch still returns. + * - One child's events missing/corrupt → that child is still REPORTED (its + * `running` flag is the catalog's), just without a summary; the rest of the + * batch is unaffected. */ -import type { Context, SidebarSubagentsService } from './context-types.ts' +import type { + Context, + SidebarChildLiveView, + SidebarSubagentsService, +} from './context-types.ts' import { SIDE_LABEL_PREFIX } from './sidechat-core.ts' -import { lastActivity, type LastActivity } from './subagent-activity.ts' +import { foldProcess } from './process-activity.ts' import { requireString, SidebarError } from './wire.ts' /** The live-preview routes of the /sidebar JSON API. */ export interface SidebarSubagentLiveRoutes { /** - * Fold one tree's running subagent histories into a compact live map. + * Fold one tree's subagent activity into a compact live map. * @param payload - `{ rootSessionId }`. - * @returns `{ live: Record }`; children with - * no text/tool yet are omitted. + * @returns `{ live: Record }` over the + * whole descendant catalog plus the topology root. */ - live(payload: unknown): Promise<{ live: Record }> + live(payload: unknown): Promise<{ live: Record }> } -/** - * The recent-message window of the live preview: only the last 12 surface - * messages of a child's log are folded, matching the old per-card - * `subagents.history({ maxMessages: 12 })` window. Keeps stale tool calls - * out of the preview and bounds the backward scan per child. - */ -export const LIVE_WINDOW_MESSAGES = 12 - /** * Build the live-preview routes bound to the plugin context. * @param ctx - host plugin context. @@ -67,25 +69,38 @@ export function buildSubagentLiveApi(ctx: Context): SidebarSubagentLiveRoutes { ) } - const live: Record = {} + const live: Record = {} + /** + * Fold one session into the map. `running` is the caller-visible activity + * flag: the catalog's for a child, undefined for the topology ROOT (the + * host cannot classify it, and the page already knows the answer from the + * session list). The fold's own `live` gate is that flag when it exists, + * and the LOG's evidence for the root. + */ + const foldInto = (sessionId: string, running: boolean | undefined): void => { + const view: SidebarChildLiveView = running === undefined ? {} : { running } + try { + const events = ctx.sessions.get(sessionId)?.snapshotEvents() ?? [] + const fold = foldProcess(events, { live: running !== false }) + if (fold.current.counts.length > 0 || fold.current.running !== undefined) { + view.summary = fold.current + } + if (fold.text !== undefined) view.text = fold.text + if (fold.lastEventTime !== undefined) view.lastEventTime = fold.lastEventTime + } catch { + // One session's event log is not readable: keep the row (a child's + // running flag is the catalog's) and skip only its details. + } + live[sessionId] = view + } + + foldInto(rootSessionId, undefined) for (const entry of descendants) { - // Same gate the client renders cards on: only catalog-running - // children get live lines (spec: "仅对 running 且非 Side Chat"). - if (entry.kind !== 'child' || entry.activity !== 'running') continue + if (entry.kind !== 'child') continue // Side Chat threads ride the subagent origin but are sidebar tabs, // never topology — keep them out of the live map too. if (entry.label?.startsWith(SIDE_LABEL_PREFIX) ?? false) continue - try { - const activity = lastActivity( - ctx.sessions.get(entry.id)?.snapshotEvents() ?? [], - LIVE_WINDOW_MESSAGES, - ) - if (activity.text !== undefined || activity.tool !== undefined) { - live[entry.id] = activity - } - } catch { - // One child's event log is not readable: skip only that child. - } + foldInto(entry.id, entry.activity === 'running') } return { live } }, diff --git a/tests/locales.spec.ts b/tests/locales.spec.ts index e2e6cab..258ef3e 100644 --- a/tests/locales.spec.ts +++ b/tests/locales.spec.ts @@ -6,7 +6,7 @@ * interpolation. */ import { afterEach, describe, expect, it } from 'vitest' -import { LOCALE_NS, attachBetterLocale, attachLocale, en, isZh, relativeTime, t, zh } from '../src/client/locales.ts' +import { LOCALE_NS, attachBetterLocale, attachLocale, chatT, en, isZh, relativeTime, t, zh } from '../src/client/locales.ts' import { localeDicts } from '../src/client/chunks/locale.tsx' /** Minimal structural fake of the DSH LocaleService face the sidebar uses. */ @@ -121,6 +121,56 @@ describe('locales (DSH i18n following)', () => { }) }) +/** + * A locale service whose `bind` reads instance state, exactly like DSH's own + * (`LocaleRuntime.bind` touches `this.bound`). A detached call — the natural + * refactor `const bind = service.bind; bind('chat')` — throws here, which is + * what a real host did before the mount lane caught it. + */ +class FakeLocaleWithBind { + active = 'zh' + private readonly dicts: Record> = { + chat: { 'message.stepProcess.done.read': '已读取文件' }, + } + getSnapshot(): { active: string } { + return { active: this.active } + } + bind(ns: string): (key: string, params?: Record) => string { + // The `this` read that makes a detached call blow up. + const dict: Record = this.dicts[ns] ?? {} + return (key) => dict[key] ?? key + } +} + +describe('locales (host chat namespace bridge)', () => { + it('reads the host namespace through a METHOD call on the attached service', () => { + attachLocale(new FakeLocaleWithBind()) + expect(chatT('message.stepProcess.done.read')).toBe('已读取文件') + }) + + it('answers undefined for a namespace or key the host does not serve', () => { + attachLocale(new FakeLocaleWithBind()) + // An unregistered host namespace resolves to the key itself: the caller + // must see "unavailable", never the raw `message.…` key. + expect(chatT('message.stepProcess.read')).toBeUndefined() + }) + + it('answers undefined when the attached face has no bind at all', () => { + attachLocale({ getSnapshot: () => ({ active: 'en' }) }) + expect(chatT('message.stepProcess.done.read')).toBeUndefined() + attachLocale(undefined) + expect(chatT('message.stepProcess.done.read')).toBeUndefined() + }) + + it('never lets a throwing host seam break a render', () => { + attachLocale({ + getSnapshot: () => ({ active: 'en' }), + bind: () => { throw new TypeError("Cannot read properties of undefined (reading 'bound')") }, + }) + expect(chatT('message.stepProcess.done.read')).toBeUndefined() + }) +}) + /** Minimal fake of the better-locale override store (the subset t()/isZh() read). */ class FakeBetterLocale { active: string | undefined = undefined diff --git a/tests/process-activity.spec.ts b/tests/process-activity.spec.ts new file mode 100644 index 0000000..e4938b5 --- /dev/null +++ b/tests/process-activity.spec.ts @@ -0,0 +1,251 @@ +/** + * The ported main-agent process fold: category mapping, live-detail + * extraction, and the range cut (`foldProcess`). These pin the parity claims + * the live route and the Tasks page cards rest on, so a later host change + * shows up as a red test rather than a silently different label. + */ +import { describe, expect, it } from 'vitest' +import { + PROCESS_DETAIL_MAX_CHARS, + PROCESS_TEXT_MAX_CHARS, + activityOf, + contentText, + foldProcess, + liveToolDetail, + type ProcessActivity, +} from '../src/process-activity.ts' +import type { SidebarSessionEvent } from '../src/context-types.ts' + +/** One raw session event. */ +const entry = (type: string, data: Record, seq = 0, time = 0): SidebarSessionEvent => ({ + type, seq, time, data, +}) + +/** A `tool/call` event. */ +const call = (callId: string, name: string, args = '{}', seq = 0, time = 0): SidebarSessionEvent => + entry('tool/call', { callId, name, arguments: args }, seq, time) + +/** A `tool/result` event paired by callId (0.1.7's tool-role message shape). */ +const result = (callId: string, seq = 0, time = 0): SidebarSessionEvent => + entry('tool/result', { message: { source: { kind: 'tool', callId }, content: [{ type: 'text', text: 'ok' }] } }, seq, time) + +/** An assistant message carrying text and/or announced tool-call blocks. */ +const assistant = (content: unknown[], seq = 0, time = 0): SidebarSessionEvent => + entry('assistant/message', { turn: 1, step: 1, message: { content } }, seq, time) + +/** A user message (a range boundary). */ +const user = (text = 'continue', seq = 0, time = 0): SidebarSessionEvent => + entry('user/message', { content: [{ type: 'text', text }] }, seq, time) + +describe('process activity: the host category map, verbatim', () => { + const cases: [string, ProcessActivity][] = [ + ['read', 'read'], + ['read_image', 'readImage'], + ['grep', 'search'], + ['glob', 'search'], + ['bash_inspect', 'search'], + ['write', 'write'], + ['edit', 'edit'], + ['apply_patch', 'edit'], + ['bash', 'commands'], + ['pwsh', 'commands'], + ['exec_command', 'commands'], + ['write_stdin', 'commands'], + ['terminal_send', 'commands'], + ['run_code', 'code'], + ['web_search', 'webSearch'], + ['web_fetch', 'webFetch'], + ['subagent', 'subagents'], + ['subagent_control', 'subagents'], + ['todo_write', 'plan'], + ['create_goal', 'plan'], + ['update_goal', 'plan'], + ['get_goal', 'plan'], + ['ask_user_question', 'questions'], + ['request_user_input', 'questions'], + ['something_else', 'tools'], + ['', 'tools'], + ] + for (const [name, kind] of cases) { + it(`${name || '(empty)'} → ${kind}`, () => { + expect(activityOf(name)).toBe(kind) + }) + } +}) + +describe('process activity: live tool detail', () => { + it('takes the first present key of the host priority list', () => { + expect(liveToolDetail('bash', JSON.stringify({ command: 'npm test', path: 'x' }))).toBe('npm test') + // `title` beats `command` because it is earlier in the host's list. + expect(liveToolDetail('bash', JSON.stringify({ command: 'a', title: 'Build' }))).toBe('Build') + }) + + it('joins string arrays and reads the first question', () => { + expect(liveToolDetail('grep', JSON.stringify({ queries: ['one', 'two'] }))).toBe('one, two') + expect(liveToolDetail('ask_user_question', JSON.stringify({ + questions: [{ question: 'first?' }, { question: 'second?' }], + }))).toBe('first?') + }) + + it('collapses whitespace and truncates on a grapheme boundary', () => { + const long = 'a'.repeat(PROCESS_DETAIL_MAX_CHARS + 40) + const detail = liveToolDetail('bash', JSON.stringify({ command: long })) + expect(detail.endsWith('…')).toBe(true) + expect(Array.from(detail).length).toBe(PROCESS_DETAIL_MAX_CHARS) + expect(liveToolDetail('bash', JSON.stringify({ command: 'a\n\n b\tc' }))).toBe('a b c') + }) + + it('falls back to the tool name for unusable arguments', () => { + expect(liveToolDetail('bash', 'not json')).toBe('bash') + expect(liveToolDetail('bash', '"scalar"')).toBe('bash') + expect(liveToolDetail('bash', JSON.stringify({ unknownKey: 'x' }))).toBe('bash') + expect(liveToolDetail('bash', '')).toBe('bash') + }) +}) + +describe('process activity: content text', () => { + it('joins text blocks and ignores everything else', () => { + expect(contentText([{ type: 'text', text: 'hello' }, { type: 'tool-call', id: 'c' }])).toBe('hello') + expect(contentText([{ type: 'text', text: 'a' }, { type: 'text', text: 'b' }])).toBe('a\nb') + expect(contentText(undefined)).toBeUndefined() + expect(contentText('nope')).toBeUndefined() + }) +}) + +describe('process activity: the range fold', () => { + it('returns an empty summary for an empty log', () => { + expect(foldProcess([], { live: true })).toEqual({ current: { counts: [], runningDetail: '' } }) + }) + + it('counts distinct calls per category and ranks by count', () => { + const fold = foldProcess([ + user('go', 1, 10), + call('c1', 'read', '{"path":"a"}', 2, 11), + call('c2', 'read', '{"path":"b"}', 3, 12), + call('c3', 'bash', '{"command":"ls"}', 4, 13), + ], { live: true }) + expect(fold.current.counts).toEqual([{ kind: 'read', count: 2 }, { kind: 'commands', count: 1 }]) + }) + + it('breaks count ties by first appearance', () => { + const fold = foldProcess([ + call('c1', 'bash', '{}', 1, 1), + call('c2', 'read', '{}', 2, 2), + ], { live: true }) + expect(fold.current.counts).toEqual([{ kind: 'commands', count: 1 }, { kind: 'read', count: 1 }]) + }) + + it('reports the newest unsettled call as running, with its detail', () => { + const fold = foldProcess([ + call('c1', 'bash', '{"command":"npm test"}', 1, 1), + result('c1', 2, 2), + call('c2', 'grep', '{"pattern":"host"}', 3, 3), + ], { live: true }) + expect(fold.current.running).toBe('search') + expect(fold.current.runningDetail).toBe('host') + expect(fold.current.counts).toEqual([{ kind: 'commands', count: 1 }, { kind: 'search', count: 1 }]) + }) + + it('reports no running tool once the session settled (the host closed group)', () => { + const fold = foldProcess([call('c1', 'bash', '{"command":"ls"}', 1, 1)], { live: false }) + expect(fold.current.running).toBeUndefined() + expect(fold.current.runningDetail).toBe('') + expect(fold.current.counts).toEqual([{ kind: 'commands', count: 1 }]) + }) + + it('counts and flags a call the model announced but never dispatched', () => { + const fold = foldProcess([ + user('go', 1, 5), + assistant([{ type: 'tool-call', id: 'c1', name: 'bash', arguments: '{"command":"ls"}' }], 2, 6), + ], { live: true }) + expect(fold.current.counts).toEqual([{ kind: 'commands', count: 1 }]) + expect(fold.current.running).toBe('commands') + expect(fold.current.preparing).toBe(true) + // The host shows no detail while preparing (the name only for `tools`). + expect(fold.current.runningDetail).toBe('') + expect(foldProcess([ + assistant([{ type: 'tool-call', id: 'c1', name: 'mystery_tool' }], 2, 6), + ], { live: true }).current.runningDetail).toBe('mystery_tool') + }) + + it('does not double count an announced call that was dispatched and settled', () => { + const fold = foldProcess([ + user('go', 1, 5), + assistant([{ type: 'tool-call', id: 'c1', name: 'read', arguments: '{"path":"a"}' }], 2, 6), + call('c1', 'read', '{"path":"a"}', 3, 7), + result('c1', 4, 8), + ], { live: true }) + expect(fold.current.counts).toEqual([{ kind: 'read', count: 1 }]) + expect(fold.current.running).toBeUndefined() + }) + + it('stops at the newest range boundary and keeps the newest text', () => { + // The stale read lives in the PREVIOUS range: a reply ended it. + const fold = foldProcess([ + user('go', 1, 10), + call('c1', 'read', '{"path":"old"}', 2, 11), + assistant([{ type: 'text', text: 'first answer' }], 3, 12), + call('c2', 'bash', '{"command":"npm test"}', 4, 13), + assistant([{ type: 'text', text: 'second answer' }], 5, 14), + ], { live: false }) + expect(fold.current.counts).toEqual([{ kind: 'commands', count: 1 }]) + expect(fold.text).toBe('second answer') + expect(fold.lastEventTime).toBe(14) + }) + + it('falls back to the previous range when the newest one is empty', () => { + // The child replied and then did nothing: reporting the empty newest range + // would show "nothing happened" for a node that just did real work. + const fold = foldProcess([ + user('go', 1, 10), + call('c1', 'read', '{"path":"a"}', 2, 11), + call('c2', 'bash', '{"command":"npm test"}', 3, 12), + assistant([{ type: 'text', text: 'all green' }], 4, 13), + ], { live: false }) + // Forwards order is read → bash, so the tie ranks read first. + expect(fold.current.counts).toEqual([{ kind: 'read', count: 1 }, { kind: 'commands', count: 1 }]) + expect(fold.text).toBe('all green') + // The fallback range is CLOSED: it can never report a running tool. + expect(fold.current.running).toBeUndefined() + }) + + it('never reports the fallback range as running even while the session lives', () => { + const fold = foldProcess([ + call('c1', 'bash', '{"command":"npm test"}', 1, 1), + assistant([{ type: 'text', text: 'done with that' }], 2, 2), + ], { live: true }) + expect(fold.current.counts).toEqual([{ kind: 'commands', count: 1 }]) + expect(fold.current.running).toBeUndefined() + }) + + it('gives up after one empty range instead of scanning the whole log', () => { + const events = [ + call('ancient', 'read', '{"path":"old"}', 1, 1), + assistant([{ type: 'text', text: 'first' }], 2, 2), + assistant([{ type: 'text', text: 'second' }], 3, 3), + assistant([{ type: 'text', text: 'third' }], 4, 4), + ] + // Two replies in a row leave TWO empty ranges before the ancient call; the + // fold stops after the first empty one and reports an empty summary. + const fold = foldProcess(events, { live: false }) + expect(fold.current.counts).toEqual([]) + expect(fold.text).toBe('third') + }) + + it('stops the backward scan at the event cap', () => { + const events: SidebarSessionEvent[] = [] + for (let index = 0; index < 900; index += 1) { + events.push(call(`c${index}`, 'read', `{"path":"${index}"}`, index, index)) + } + const fold = foldProcess(events, { live: true }) + expect(fold.current.counts[0]?.count).toBeLessThan(900) + expect(fold.current.counts[0]?.kind).toBe('read') + }) + + it('caps the text line without splitting the ellipsis', () => { + const long = 'x'.repeat(PROCESS_TEXT_MAX_CHARS + 30) + const fold = foldProcess([assistant([{ type: 'text', text: long }], 1, 1)], { live: false }) + expect(Array.from(fold.text ?? '').length).toBe(PROCESS_TEXT_MAX_CHARS) + expect(fold.text?.endsWith('…')).toBe(true) + }) +}) diff --git a/tests/process-labels.spec.ts b/tests/process-labels.spec.ts new file mode 100644 index 0000000..293fd65 --- /dev/null +++ b/tests/process-labels.spec.ts @@ -0,0 +1,122 @@ +/** + * The Tasks page's borrowed main-conversation wording: the live activity line + * and the settled range title, composed exactly the way the host composes + * them, plus the fallback that keeps a host rename from printing raw keys. + */ +import { describe, expect, it } from 'vitest' +import { doneActivityTitle, liveActivityLabel, type ChatTranslate } from '../src/client/process-labels.ts' +import type { ProcessActivitySummary } from '../src/process-activity.ts' + +/** The host's `chat` dictionary for the keys this module reads (zh wording). */ +const HOST: Record = { + 'message.stepProcess.thinking': '正在分析请求', + 'message.stepProcess.commands': '正在运行命令', + 'message.stepProcess.read': '正在读取文件', + 'message.stepProcess.tools': '正在调用工具', + 'message.stepProcess.prepare.commands': '准备运行命令', + 'message.stepProcess.prepare.tools': '准备调用工具', + 'message.stepProcess.done.thinking': '已完成分析', + 'message.stepProcess.done.read': '已读取文件', + 'message.stepProcess.done.commands': '执行了命令', + 'message.stepProcess.done.search': '已搜索代码', + 'message.stepProcess.done.tools': '已调用工具', + 'message.stepProcess.joinTwo': '{first}并{second}', + 'message.stepProcess.comma': ',', + 'message.stepProcess.sharedPrefix': '已', + 'message.stepProcess.more': '{title}等', + 'message.turnProcess.separator': ' · ', +} + +/** The host translator, with `{name}` interpolation like DSH's own. */ +const t: ChatTranslate = (key, params) => { + const template = HOST[key] + if (template === undefined) return undefined + if (params === undefined) return template + let text = template + for (const [name, value] of Object.entries(params)) text = text.replaceAll(`{${name}}`, String(value)) + return text +} + +/** A summary built from counts. */ +function summary(over: Partial = {}): ProcessActivitySummary { + return { counts: [], runningDetail: '', ...over } +} + +describe('live activity label', () => { + it('renders the category wording plus the running detail', () => { + expect(liveActivityLabel(summary({ + counts: [{ kind: 'commands', count: 2 }], + running: 'commands', + runningDetail: 'npm run build', + }), t)).toBe('正在运行命令 · npm run build') + }) + + it('falls back to the host thinking wording while nothing is dispatched', () => { + expect(liveActivityLabel(summary({ counts: [{ kind: 'read', count: 1 }] }), t)).toBe('正在分析请求') + expect(liveActivityLabel(undefined, t)).toBe('正在分析请求') + }) + + it('uses the prepare wording without a detail', () => { + expect(liveActivityLabel(summary({ + running: 'commands', + runningDetail: 'ignored while preparing', + preparing: true, + }), t)).toBe('准备运行命令') + }) + + it('answers undefined when the host namespace is unavailable', () => { + const missing: ChatTranslate = () => undefined + expect(liveActivityLabel(summary({ running: 'commands' }), missing)).toBeUndefined() + // …and falls back to the detail it does have when the host has one. + expect(liveActivityLabel(summary({ running: 'commands', runningDetail: 'npm test' }), missing)) + .toBe('npm test') + }) +}) + +describe('settled range title', () => { + it('prints one category without counts', () => { + expect(doneActivityTitle(summary({ counts: [{ kind: 'read', count: 3 }] }), t)).toBe('已读取文件') + }) + + it('joins two categories and drops the shared prefix from the second', () => { + expect(doneActivityTitle(summary({ + counts: [{ kind: 'read', count: 2 }, { kind: 'search', count: 1 }], + }), t)).toBe('已读取文件并搜索代码') + }) + + it('joins three categories with the host comma', () => { + expect(doneActivityTitle(summary({ + counts: [ + { kind: 'read', count: 3 }, + { kind: 'search', count: 2 }, + { kind: 'commands', count: 1 }, + ], + }), t)).toBe('已读取文件,已搜索代码,执行了命令') + }) + + it('appends the host suffix beyond the top three', () => { + const title = doneActivityTitle(summary({ + counts: [ + { kind: 'read', count: 4 }, + { kind: 'search', count: 3 }, + { kind: 'commands', count: 2 }, + { kind: 'tools', count: 1 }, + ], + }), t) + // The host lowercases the continuation labels but only the TWO-label + // branch strips the shared "已" prefix, so these keep it. + expect(title).toBe('已读取文件,已搜索代码,执行了命令等') + }) + + it('answers the host thinking wording when nothing was called', () => { + expect(doneActivityTitle(summary(), t)).toBe('已完成分析') + expect(doneActivityTitle(undefined, t)).toBe('已完成分析') + }) + + it('answers undefined when a category has no host wording', () => { + const partial: ChatTranslate = key => (key === 'message.stepProcess.done.read' ? '已读取文件' : undefined) + expect(doneActivityTitle(summary({ counts: [{ kind: 'read', count: 1 }, { kind: 'commands', count: 1 }] }), partial)) + .toBeUndefined() + expect(doneActivityTitle(summary({ counts: [{ kind: 'read', count: 1 }] }), partial)).toBe('已读取文件') + }) +}) diff --git a/tests/subagent-activity.spec.ts b/tests/subagent-activity.spec.ts deleted file mode 100644 index 4593b68..0000000 --- a/tests/subagent-activity.spec.ts +++ /dev/null @@ -1,105 +0,0 @@ -import { describe, expect, it } from 'vitest' -import { contentText, lastActivity } from '../src/subagent-activity.ts' -import type { SidebarSessionEvent } from '../src/context-types.ts' - -describe('subagent activity summary parser', () => { - /** One raw session event. */ - const entry = (type: string, data: Record): SidebarSessionEvent => ({ - type, seq: 0, time: 0, data, - }) - - it('extracts text blocks and skips non-text content', () => { - // Text blocks join as paragraphs (newline-separated). - expect(contentText([{ type: 'text', text: 'hello' }, { type: 'text', text: ' world' }])).toBe('hello\n world') - expect(contentText([{ type: 'tool_use', name: 'bash' }])).toBeUndefined() - expect(contentText(undefined)).toBeUndefined() - expect(contentText('nope')).toBeUndefined() - }) - - it('lastActivity returns the LAST text output and the LAST tool call', () => { - const live = lastActivity([ - entry('turn/start', { turn: 1 }), - entry('user/message', { content: [{ type: 'text', text: '请检查代码' }] }), - entry('tool/call', { callId: 'c1', name: 'bash', arguments: '{"command":"ls -la"}' }), - entry('assistant/message', { - turn: 1, step: 1, - message: { content: [{ type: 'text', text: '检查完毕' }] }, - }), - entry('tool/call', { callId: 'c2', name: 'read', arguments: '{"path":"a.ts"}' }), - entry('assistant/message', { - turn: 1, step: 2, - message: { content: [{ type: 'text', text: '再看一眼' }] }, - }), - ]) - expect(live).toEqual({ - text: '再看一眼', - tool: { name: 'read', args: '{"path":"a.ts"}' }, - }) - }) - - it('lastActivity keeps only the fields the log actually has', () => { - expect(lastActivity([ - entry('tool/call', { callId: 'c1', name: 'bash', arguments: '{"command":"ls"}' }), - ])).toEqual({ tool: { name: 'bash', args: '{"command":"ls"}' } }) - expect(lastActivity([ - entry('assistant/message', { turn: 1, step: 1, message: { content: [{ type: 'text', text: 'ok' }] } }), - ])).toEqual({ text: 'ok' }) - }) - - it('lastActivity ignores lifecycle events, stream rows, and text-less messages', () => { - const live = lastActivity([ - entry('turn/end', { turn: 1, reason: 'success' }), - entry('step/start', { turn: 1, step: 1 }), - entry('assistant/live-chunk', { turn: 1, step: 1, chunk: { type: 'text-delta', index: 0, text: 'x' } }), - entry('assistant/message', { turn: 1, step: 1, message: { content: [{ type: 'tool_use', name: 'bash' }] } }), - ]) - expect(live).toEqual({}) - expect(lastActivity([])).toEqual({}) - }) - - it('lastActivity defaults a missing tool name and tolerates non-string arguments', () => { - const live = lastActivity([ - entry('tool/call', { callId: 'c1' }), - entry('tool/call', { callId: 'c2', name: 'web', arguments: { url: 'x' } }), - ]) - expect(live.tool).toEqual({ name: 'web', args: '' }) - }) - - it('lastActivity window excludes activity older than the last maxMessages messages', () => { - // One stale tool call before the window: with maxMessages=1 only the - // latest message (and the events around it) are considered. - expect(lastActivity([ - entry('tool/call', { callId: 'stale', name: 'bash', arguments: '{"command":"old"}' }), - entry('assistant/message', { turn: 1, step: 1, message: { content: [{ type: 'text', text: 'new text' }] } }), - ], 1)).toEqual({ text: 'new text' }) - - // The same log with a wider window surfaces the tool call again. - expect(lastActivity([ - entry('tool/call', { callId: 'stale', name: 'bash', arguments: '{"command":"old"}' }), - entry('assistant/message', { turn: 1, step: 1, message: { content: [{ type: 'text', text: 'new text' }] } }), - ], 2)).toEqual({ - text: 'new text', - tool: { name: 'bash', args: '{"command":"old"}' }, - }) - - // Non-message events (tool calls) do not consume quota, but once the - // window holds its messages, older events are dropped even without an - // older message to stop at: the second message pushes the tool call out. - expect(lastActivity([ - entry('tool/call', { callId: 'stale', name: 'bash', arguments: '{"command":"old"}' }), - entry('assistant/message', { turn: 1, step: 1, message: { content: [{ type: 'text', text: 'first' }] } }), - entry('user/message', { content: [{ type: 'text', text: '继续' }] }), - ], 1)).toEqual({}) - - // A tool call between the two in-window messages stays visible. - expect(lastActivity([ - entry('tool/call', { callId: 'stale', name: 'bash', arguments: '{"command":"old"}' }), - entry('assistant/message', { turn: 1, step: 1, message: { content: [{ type: 'text', text: 'first' }] } }), - entry('tool/call', { callId: 'fresh', name: 'read', arguments: '{"path":"a.ts"}' }), - entry('user/message', { content: [{ type: 'text', text: '继续' }] }), - ], 2)).toEqual({ - text: 'first', - tool: { name: 'read', args: '{"path":"a.ts"}' }, - }) - }) -}) diff --git a/tests/subagent-live-polling.spec.tsx b/tests/subagent-live-polling.spec.tsx index bdef915..6113d27 100644 --- a/tests/subagent-live-polling.spec.tsx +++ b/tests/subagent-live-polling.spec.tsx @@ -125,7 +125,7 @@ describe('SubagentView live polling', () => { if (method === 'subagents.live') { const body = JSON.parse(String(init?.body)) as { rootSessionId?: string } liveCalls.push(body.rootSessionId ?? '') - return jsonResponse({ ok: true, value: { live: { a: {}, b: {} } } }) + return jsonResponse({ ok: true, value: { live: { a: { running: true }, b: { running: true } } } }) } if (method === 'jobs.list') return jsonResponse({ ok: true, value: { jobs: [] } }) throw new Error(`unexpected fetch ${String(url)}`) @@ -177,7 +177,7 @@ describe('SubagentView live polling', () => { // Settle the first request; only then does the next 3s tick fire. await act(async () => { - resolveFirst?.(jsonResponse({ ok: true, value: { live: { a: {}, b: {} } } })) + resolveFirst?.(jsonResponse({ ok: true, value: { live: { a: { running: true }, b: { running: true } } } })) await Promise.resolve() }) await act(async () => { await vi.advanceTimersByTimeAsync(3_000) }) @@ -217,7 +217,7 @@ describe('SubagentView live polling', () => { // Settling the stale response must neither render nor schedule a poll. await act(async () => { - resolveFirst?.(jsonResponse({ ok: true, value: { live: { a: { text: 'stale' } } } })) + resolveFirst?.(jsonResponse({ ok: true, value: { live: { a: { running: true, text: 'stale' } } } })) await Promise.resolve() }) await act(async () => { await vi.advanceTimersByTimeAsync(10_000) }) @@ -235,7 +235,7 @@ describe('SubagentView live polling', () => { if (method === 'subagents.live') { const body = JSON.parse(String(init?.body)) as { rootSessionId?: string } liveCalls.push(body.rootSessionId ?? '') - const live = body.rootSessionId === 'root' ? { a: { text: 'hello' } } : {} + const live = body.rootSessionId === 'root' ? { a: { running: true, text: 'hello' } } : {} return jsonResponse({ ok: true, value: { live } }) } if (method === 'jobs.list') return jsonResponse({ ok: true, value: { jobs: [] } }) diff --git a/tests/subagent-live-route.spec.ts b/tests/subagent-live-route.spec.ts index 9f8027d..989f67c 100644 --- a/tests/subagent-live-route.spec.ts +++ b/tests/subagent-live-route.spec.ts @@ -49,8 +49,20 @@ function ctxWith(subagents: unknown, sessions: unknown): Context { } as unknown as Context } +/** One tool call event. */ +const call = (callId: string, name: string, args = '{}', seq = 0, time = 0): SidebarSessionEvent => + ({ type: 'tool/call', seq, time, data: { callId, name, arguments: args } }) + +/** A settled tool result for one call id. */ +const result = (callId: string, seq = 0, time = 0): SidebarSessionEvent => + ({ type: 'tool/result', seq, time, data: { message: { source: { kind: 'tool', callId } } } }) + +/** An assistant message carrying text. */ +const reply = (text: string, seq = 0, time = 0): SidebarSessionEvent => + ({ type: 'assistant/message', seq, time, data: { message: { content: [{ type: 'text', text }] } } }) + describe('subagents.live route', () => { - it('returns non-empty activity for running children only', async () => { + it('reports every child with its catalog flag and folded range', async () => { const subagents: SidebarSubagentsService = { listDescendants: vi.fn(async () => [ child('running-a', { label: 'A' }), @@ -62,64 +74,70 @@ describe('subagents.live route', () => { } const sessions = { get: (id: string) => { - if (id === 'running-a') { - return session([ - { type: 'assistant/message', seq: 0, time: 0, data: { message: { content: [{ type: 'text', text: 'hello' }] } } }, - ]) - } - if (id === 'running-b') { - return session([ - { type: 'tool/call', seq: 0, time: 0, data: { name: 'bash', arguments: '{"command":"ls"}' } }, - ]) - } + if (id === 'running-a') return session([reply('hello', 1, 1)]) + if (id === 'running-b') return session([call('c1', 'bash', '{"command":"ls"}', 1, 1)]) return session([]) }, } const api = buildSubagentLiveApi(ctxWith(subagents, sessions)) - await expect(api.live({ rootSessionId: 'root' })).resolves.toEqual({ - live: { - 'running-a': { text: 'hello' }, - 'running-b': { tool: { name: 'bash', args: '{"command":"ls"}' } }, - }, + const live = (await api.live({ rootSessionId: 'root' })).live + // The topology root is reported too (its activity flag is the caller's). + expect(live.root).toEqual({}) + expect(live['running-a']?.running).toBe(true) + expect(live['running-a']?.text).toBe('hello') + expect(live['running-b']?.summary).toEqual({ + counts: [{ kind: 'commands', count: 1 }], + running: 'commands', + runningDetail: 'ls', }) + // A settled child is still a row: its summary drives its card. + expect(live.inactive?.running).toBe(false) + // Side Chat threads and diagnostics are not topology. + expect(live['side-chat']).toBeUndefined() + expect(live['corrupt-row']).toBeUndefined() expect(subagents.listDescendants).toHaveBeenCalledWith('root') }) - it('omits children with no text/tool yet', async () => { + it('folds a settled child from its last range that did something', async () => { const subagents: SidebarSubagentsService = { - listDescendants: vi.fn(async () => [child('empty', { label: 'Empty' })]), + listDescendants: vi.fn(async () => [child('done', { activity: 'inactive', label: 'D' })]), } - const sessions = { get: () => session([]) } - const api = buildSubagentLiveApi(ctxWith(subagents, sessions)) - await expect(api.live({ rootSessionId: 'root' })).resolves.toEqual({ live: {} }) + const events = [ + call('c1', 'bash', '{"command":"npm test"}', 1, 1), + result('c1', 2, 2), + reply('all green', 3, 3), + ] + const api = buildSubagentLiveApi(ctxWith(subagents, { get: () => session(events) })) + const live = (await api.live({ rootSessionId: 'root' })).live + expect(live.done?.summary).toEqual({ + counts: [{ kind: 'commands', count: 1 }], + runningDetail: '', + }) + expect(live.done?.text).toBe('all green') + expect(live.done?.lastEventTime).toBe(3) }) - it('folds only activity inside the recent 12-message window', async () => { - // One stale tool call sits before 13 user messages; the recent window - // (the tail's last 12 messages) must not surface it. - const staleTool: SidebarSessionEvent = { - type: 'tool/call', seq: 0, time: 0, - data: { callId: 'stale', name: 'bash', arguments: '{"command":"old"}' }, - } - const oldMessages: SidebarSessionEvent[] = Array.from({ length: 13 }, (_, i) => ({ - type: 'user/message', seq: i + 1, time: 0, - data: { content: [{ type: 'text', text: `m${i}` }] }, - })) - const recentText: SidebarSessionEvent = { - type: 'assistant/message', seq: 99, time: 0, - data: { message: { content: [{ type: 'text', text: 'recent' }] } }, - } + it('stops the fold at the newest range boundary', async () => { + // One stale tool call sits in an earlier range: the newest range is the + // tail after the reply, so the stale call must not be counted. const subagents: SidebarSubagentsService = { listDescendants: vi.fn(async () => [child('windowed', { label: 'W' })]), } - const sessions = { get: () => session([staleTool, ...oldMessages, recentText]) } - const api = buildSubagentLiveApi(ctxWith(subagents, sessions)) - await expect(api.live({ rootSessionId: 'root' })).resolves.toEqual({ - live: { windowed: { text: 'recent' } }, + const events = [ + call('stale', 'bash', '{"command":"old"}', 1, 1), + reply('done with the old work', 2, 2), + call('fresh', 'read', '{"path":"a"}', 3, 3), + ] + const api = buildSubagentLiveApi(ctxWith(subagents, { get: () => session(events) })) + const live = (await api.live({ rootSessionId: 'root' })).live + expect(live.windowed?.summary).toEqual({ + counts: [{ kind: 'read', count: 1 }], + running: 'read', + runningDetail: 'a', }) }) - it('skips a child whose session log is unavailable without failing the batch', async () => { + it('keeps a child whose session log is unreadable (its flag is the catalog\'s)', async () => { const subagents: SidebarSubagentsService = { listDescendants: vi.fn(async () => [ child('good', { label: 'Good' }), @@ -128,18 +146,14 @@ describe('subagents.live route', () => { } const sessions = { get: (id: string) => { - if (id === 'good') { - return session([ - { type: 'assistant/message', seq: 0, time: 0, data: { message: { content: [{ type: 'text', text: 'ok' }] } } }, - ]) - } + if (id === 'good') return session([reply('ok', 0, 0)]) throw new Error('missing') }, } const api = buildSubagentLiveApi(ctxWith(subagents, sessions)) - await expect(api.live({ rootSessionId: 'root' })).resolves.toEqual({ - live: { good: { text: 'ok' } }, - }) + const live = (await api.live({ rootSessionId: 'root' })).live + expect(live.good?.text).toBe('ok') + expect(live.bad).toEqual({ running: true }) }) it('degrades to a 503 when the subagent runtime is absent', async () => { diff --git a/tests/tasks-graph-layout.spec.ts b/tests/tasks-graph-layout.spec.ts index 7d5ae66..f7d6e07 100644 --- a/tests/tasks-graph-layout.spec.ts +++ b/tests/tasks-graph-layout.spec.ts @@ -7,6 +7,7 @@ * the width-aware solver. */ import { describe, expect, it } from 'vitest' +import type { SidebarChildLiveView } from '../src/context-types.ts' import { GRAPH_GAP_Y, GRAPH_LIVE_H, @@ -50,6 +51,12 @@ function layoutRow(layout: ReturnType, id: string): num return (box.y - GRAPH_PAD) / GRAPH_ROW_STRIDE } +/** One running node's live view: a merged activity row, no text tail. */ +const ACTIVITY: SidebarChildLiveView = { + running: true, + summary: { counts: [{ kind: 'read', count: 1 }], running: 'read', runningDetail: 'x' }, +} + describe('layoutTasksGraph', () => { it('places the root on row 0 and children one row below', () => { const nodes: TasksNode[] = [agent('root'), agent('a', 'root'), agent('b', 'root')] @@ -98,11 +105,11 @@ describe('layoutTasksGraph', () => { expect(plain.boxes.get('mate')?.h).toBe(GRAPH_NODE_H) }) - it('reserves the live tail row only when the tail carries a tool AND text', () => { + it('reserves the live tail row only when the tail carries activity AND text', () => { const nodes: TasksNode[] = [ agent('root'), - agent('tool-only', 'root', false, { live: { tool: { name: 'read', args: 'x' } } }), - agent('both', 'root', false, { live: { tool: { name: 'read', args: 'x' }, text: 'y' } }), + agent('tool-only', 'root', false, { live: ACTIVITY }), + agent('both', 'root', false, { live: { ...ACTIVITY, text: 'y' } }), ] const layout = layoutTasksGraph(nodes) // One live row either way (the tool row, or the meta-styled thinking @@ -118,9 +125,7 @@ describe('layoutTasksGraph', () => { expect(GRAPH_ROW_STRIDE).toBeGreaterThanOrEqual(tallest) const nodes: TasksNode[] = [ agent('root'), - agent('busiest', 'root', false, { - live: { tool: { name: 'read', args: 'x' }, text: 'y' }, tasks: [task('t1')], - }), + agent('busiest', 'root', false, { live: { ...ACTIVITY, text: 'y' }, tasks: [task('t1')] }), ] const layout = layoutTasksGraph(nodes) expect(layout.boxes.get('busiest')?.h).toBe(tallest) diff --git a/tests/tasks-model.spec.ts b/tests/tasks-model.spec.ts index 3bf6fd4..5ef673c 100644 --- a/tests/tasks-model.spec.ts +++ b/tests/tasks-model.spec.ts @@ -17,7 +17,7 @@ import type { SidebarTeamMemberView, } from '../src/context-types.ts' import type { SubagentCatalogView } from '../src/client/subagent-catalog.ts' -import type { LastActivity } from '../src/subagent-activity.ts' +import type { SidebarChildLiveView } from '../src/context-types.ts' import type { WorkflowRunView } from '../src/workflow-runs.ts' /** A summary row. */ @@ -36,8 +36,9 @@ function catalog(entries: SidebarSubagentCatalogEntry[]): SubagentCatalogView { } /** The live channel's fold: only the RUNNING children appear (DSH 0.1.7). */ -function running(...ids: string[]): Record { - return Object.fromEntries(ids.map(id => [id, { text: `${id} is working` }])) +/** The live map of a set of RUNNING sessions (the new live-view shape). */ +function running(...ids: string[]): Record { + return Object.fromEntries(ids.map(id => [id, { running: true, text: `${id} is working` }])) } /** A workflow run view. */