From 6cea4bdd6f178ddc57f5d0a12d655a19a010d66a Mon Sep 17 00:00:00 2001 From: NuperAki <55879805+TheHeartFickle@users.noreply.github.com> Date: Fri, 21 Aug 2026 18:15:49 +0800 Subject: [PATCH] =?UTF-8?q?fix(subagent):=20=E5=AD=90=E4=BB=A3=E7=90=86?= =?UTF-8?q?=E9=A1=B5=E5=AE=9E=E6=97=B6=E9=A2=84=E8=A7=88=E6=94=B9=E4=B8=BA?= =?UTF-8?q?=E6=89=B9=E9=87=8F=E6=8E=A5=E5=8F=A3=EF=BC=8C=E9=81=BF=E5=85=8D?= =?UTF-8?q?=20O(N=C2=B2)=20history=20=E8=AF=B7=E6=B1=82=E9=A3=8E=E6=9A=B4?= =?UTF-8?q?=20(#298)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * fix(subagent): 子代理页实时预览改为批量接口,避免 O(N²) history 请求风暴 ## 背景 DSH Web UI 中,当主代理同时运行多个子代理时,`dsh-better-sidebar` 的「任务管理/子代理」页面会出现明显卡顿。经调查,卡顿根因不在页面渲染,而在实时预览的请求模式。 ## 根因 旧实现中,每个 running 子代理卡片都独立轮询 `subagents.history`: - N 个 running 子代理 → 每 3 秒产生 N 次 `subagents.history` 请求; - 每次 `subagents.history` 都会在 host 侧触发一次完整的 `listChildren` 全量子代理目录枚举; - 子代理数量越多,服务端重复扫描越严重,近似 O(N²) 放大; - 请求超过 3 秒时,客户端还会 abort 旧请求并重发,进一步形成请求风暴。 ## 改动内容 1. **新增 host 批量接口 `POST /sidebar/api/subagents.live`** - 请求 `{ rootSessionId }`; - 通过 `ctx.subagents.listDescendants(rootSessionId)` 一次枚举整棵子代理树; - 仅对 running 且非 Side Chat 的子代理,从 session events 尾部提取最新 `text/tool` 活动; - 返回 `{ live: Record }`; - 缺 `subagents`/`agents` 服务或枚举失败时返回 503;单个子代理日志异常只跳过该子代理。 2. **客户端改为单轮询 + 单飞行请求** - 删除每个 running 子代理独立的 `subagents.history` 定时器; - 在 `SubagentView` 中合并为一个 `useSubagentLive` 轮询 hook; - 使用递归 `setTimeout` 和单在途请求,慢请求不再被 abort/重发; - 页面隐藏或 root 变化时停止轮询并中止在途请求。 3. **解析器优化** - `lastActivity` 改为接收原始 `SidebarSessionEvent[]`; - 从事件尾部向前扫描,找到最新 text 和 tool 后提前停止,避免复制/遍历完整历史。 4. **UI 保持不变** - 实时预览的展示逻辑、文案、截断规则均未改变。 ## 测试 - 新增 `tests/subagent-live-route.spec.ts`:覆盖批量接口成功聚合、只返回 running、跳过 Side Chat、缺服务 503、枚举失败 503、`ctx.get` 回退、bad-request。 - 新增 `tests/subagent-live-polling.spec.tsx`:覆盖每轮只发 1 次 `subagents.live`、不再调用 `subagents.history`、单在途不并发。 - 更新 `tests/subagent-activity.spec.ts` 与 `tests/subagent-jobs-view.spec.tsx`。 - `pnpm typecheck` ✅ - `pnpm build` ✅ - 相关测试 29 个全部通过 ✅ > 本机完整 `pnpm test` 仍有与本改动无关的环境性失败(node-pty 超时、Windows CRLF 断言、pty-deps 路径断言),不影响本 PR 逻辑。 * fix(subagent): live 预览以 catalog activity 为准,删除 agents 第二事实源 (#298) - host 路由 running 门改回 entry.activity === 'running'(与客户端卡片渲染门一致), 恢复 PR 承诺的 UI 不变:turn 间 idle 的 continuable 子代理仍显示最后活动 - 删除 ctx.agents 依赖与 SidebarAgent.status 新增(agents 缺失时不再静默 200 空 map; 503 契约收窄为只涉及 subagents,与文档/测试一致) - service 解析统一为 ctx.get('subagents')(兄弟路由惯例;插件未 inject subagents, 直接属性访问有抛错风险) - buildSubagentLiveApi 签名改收完整 Context,删除 SidebarSubagentLiveContext 窄接口 - api.ts 复用 LastActivity,删除重复类型 SubagentLiveEntry - 测试:route spec 删除 agents 相关用例;polling spec 新增页面隐藏停止轮询、 root 变化清空 live 并重启轮询两个用例 * fix(subagent): live 预览恢复最近 12 条消息窗口,排除陈旧活动 (#298) CR 反馈:新批量接口把完整 session event log 喂给 lastActivity, - 长时间只输出文本、早期调用过工具的 running 子代理会持续显示陈旧 tool - 无 tool 的长会话每次轮询反向扫描整个事件数组,重新引入与历史长度 相关的 CPU 成本 修复(与旧 subagents.history({ maxMessages: 12 }) 窗口语义一致): - lastActivity 增加可选 maxMessages 参数:反向扫描按表面消息 (user/message + assistant/message)计数,窗口满后更老事件一律跳过 - 路由传 LIVE_WINDOW_MESSAGES = 12(导出常量) - 回归测试:窗口外旧 tool 不返回;窗口内 tool 保留;非消息事件不消耗配额 --------- Co-authored-by: Menghuan1918 --- src/client/SubagentView.tsx | 136 ++++++------- src/client/api.ts | 11 ++ src/client/subagent-activity.ts | 67 ------- src/context-types.ts | 40 ++++ src/index.ts | 8 + src/subagent-activity.ts | 91 +++++++++ src/subagent-live-route.ts | 93 +++++++++ src/wire.ts | 1 + tests/subagent-activity.spec.ts | 50 ++++- tests/subagent-jobs-view.spec.tsx | 11 +- tests/subagent-live-polling.spec.tsx | 282 +++++++++++++++++++++++++++ tests/subagent-live-route.spec.ts | 168 ++++++++++++++++ 12 files changed, 815 insertions(+), 143 deletions(-) delete mode 100644 src/client/subagent-activity.ts create mode 100644 src/subagent-activity.ts create mode 100644 src/subagent-live-route.ts create mode 100644 tests/subagent-live-polling.spec.tsx create mode 100644 tests/subagent-live-route.spec.ts diff --git a/src/client/SubagentView.tsx b/src/client/SubagentView.tsx index 332845c..5ea2f74 100644 --- a/src/client/SubagentView.tsx +++ b/src/client/SubagentView.tsx @@ -41,7 +41,7 @@ import { isSideThreadSummary, rootAncestor, } from './subagent-detect.ts' -import { lastActivity } from './subagent-activity.ts' +import { type LastActivity } from '../subagent-activity.ts' import { SIDE_LABEL_PREFIX } from '../sidechat-core.ts' import { collectTreeJobs, @@ -150,56 +150,14 @@ function CatalogLoadingRows(props: { } /** - * The live lines of one RUNNING subagent card: the last text output and the - * last tool call of the child's history tail, refreshed every few seconds - * while the page is visible. Idle cards render nothing (a quiet topology); a - * running child with neither output yet reads "thinking…". + * The live lines of one RUNNING subagent card: a pure presentation of the + * batch `subagents.live` activity. The polling lives in one place (the + * SubagentView hook), not per card. A running child with neither output yet + * reads "thinking…". */ -function SubagentLiveLines(props: { - ctx: Context - parentSessionId: string - childSessionId: string - mode: SidebarSubagentAddress['mode'] - running: boolean - /** The page is visible (active tab + open panel): skip polling otherwise. */ - active: boolean -}) { - const { ctx, parentSessionId, childSessionId, mode, running, active } = props - const [live, setLive] = useState>({}) - const controllerRef = useRef(undefined) - const address = useMemo( - () => ({ parentSessionId, childSessionId, mode }), - [parentSessionId, childSessionId, mode], - ) - - const load = useCallback(async (): Promise => { - controllerRef.current?.abort() - const controller = new AbortController() - controllerRef.current = controller - try { - const response = await ctx.connection.api.subagents.history( - { ...address, maxMessages: 12 }, - controller.signal, - ) - if (!response.result.ok) return - setLive(lastActivity(response.result.value.events)) - } catch { - // Aborted by a newer pull or a wire failure: keep the last known lines. - } - }, [ctx, address]) - - useEffect(() => { - if (!active) return - void load() - if (!running) return - const timer = window.setInterval(() => { void load() }, POLL_MS) - return () => { window.clearInterval(timer) } - }, [load, running, active]) - - useEffect(() => () => { controllerRef.current?.abort() }, []) - - if (!running) return null - if (live.text === undefined && live.tool === undefined) { +function SubagentLiveLines(props: { live: LastActivity | undefined }) { + const { live } = props + if (live?.text === undefined && live?.tool === undefined) { return {t('subagentThinking')} } return ( @@ -219,6 +177,59 @@ function SubagentLiveLines(props: { ) } +/** + * One shared live-preview poller for the whole Subagent tree. Unlike the old + * per-card `subagents.history` timers, this sends at most ONE `subagents.live` + * request at a time: a recursive timeout starts only after the previous + * request settles, so a slow host never sees abort/restart storms. + */ +function useSubagentLive( + rootId: string | undefined, + active: boolean, +): Readonly> { + const [live, setLive] = useState>({}) + const controllerRef = useRef(undefined) + + // A new tree must never inherit another root's live previews. + useEffect(() => { setLive({}) }, [rootId]) + + useEffect(() => { + if (rootId === undefined || !active) return + const targetRootId = rootId + let disposed = false + let timer: number | undefined + + const schedule = (): void => { + if (disposed) return + timer = window.setTimeout(() => { void load() }, POLL_MS) + } + async function load(): Promise { + if (disposed) return + const controller = new AbortController() + controllerRef.current = controller + try { + const result = await api.subagentsLive(targetRootId, controller.signal) + if (!disposed) setLive(result.live) + } catch { + // Keep the last known live map; the next scheduled poll retries. + } finally { + if (controllerRef.current === controller) controllerRef.current = undefined + if (!disposed) schedule() + } + } + + void load() + return () => { + disposed = true + if (timer !== undefined) window.clearTimeout(timer) + controllerRef.current?.abort() + controllerRef.current = undefined + } + }, [rootId, active]) + + return live +} + interface RowsProps { parentSessionId: string catalog: SidebarSubagentCatalog | undefined @@ -227,16 +238,15 @@ interface RowsProps { level: number /** The currently-open session id (highlighted in the topology). */ currentSessionId: string - /** The page is visible (active tab + open panel): live polling pauses otherwise. */ - active: boolean - ctx: Context + /** The batch live-preview map (child id → latest activity). */ + live: Readonly> openChild: (address: SidebarSubagentAddress) => void refresh: (parentSessionId: string) => void } /** Render one topology level; branches are always expanded (lazy catalogs). */ function CatalogRows({ - parentSessionId, catalog, catalogs, byId, level, currentSessionId, active, ctx, + parentSessionId, catalog, catalogs, byId, level, currentSessionId, live, openChild, refresh, }: RowsProps) { const emptyLoading = catalog?.state === 'loading' && catalog.entries.length === 0 @@ -327,14 +337,9 @@ function CatalogRows({ {label} {secondary} - + {entry.activity === 'running' && ( + + )} {!knownLeaf && ( @@ -355,8 +360,7 @@ function CatalogRows({ byId={byId} level={level + 1} currentSessionId={currentSessionId} - active={active} - ctx={ctx} + live={live} openChild={openChild} refresh={refresh} /> @@ -658,6 +662,7 @@ export function SubagentView(props: { const rootId = useMemo(() => rootAncestor(byId, sessionId), [byId, sessionId]) const rootCatalog = rootId === undefined ? undefined : catalogs[rootId] const rootSummary = rootId === undefined ? undefined : byId[rootId] + const live = useSubagentLive(rootId, active) /** Catalog owners currently consuming live membership updates. */ const observedRef = useRef(new Set()) @@ -852,8 +857,7 @@ export function SubagentView(props: { byId={byId} level={1} currentSessionId={sessionId} - active={active} - ctx={ctx} + live={live} openChild={openChild} refresh={refresh} /> diff --git a/src/client/api.ts b/src/client/api.ts index 095a5d2..23c8d8a 100644 --- a/src/client/api.ts +++ b/src/client/api.ts @@ -7,6 +7,7 @@ * 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 { SidechatThreadInfo } from '../sidechat-core.ts' import type { BrowserProbeResult } from './browser.ts' @@ -80,6 +81,9 @@ export interface JobOutputResult { read: boolean } +/** The `subagents.live` response: running child id → latest activity. */ +export type SubagentLiveResult = { live: Record } + /** Terminal dependency status (mirror of the host's depsStatus; issue #140). */ export type TerminalDepsStatus = | { ok: true } @@ -245,6 +249,13 @@ export const api = { id, ...(reason !== undefined ? { reason } : {}), })), + /** + * 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. + */ + subagentsLive: (rootSessionId: string, signal?: AbortSignal) => + call('subagents.live', { rootSessionId }, signal), /** Create a Side Chat thread: a child session seeded with the parent's * full log up to now. Empty question = immediate create (Codex-style): * the thread opens empty, the first prompt carries the boundary. */ diff --git a/src/client/subagent-activity.ts b/src/client/subagent-activity.ts deleted file mode 100644 index b825eb6..0000000 --- a/src/client/subagent-activity.ts +++ /dev/null @@ -1,67 +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 history tail - * (`subagent.history` events, oldest → newest). Renders nothing itself — the - * SubagentLiveLine component turns this into the card's status lines. - * Kept framework-free so the parser is unit-testable in the node environment. - */ -import type { SidebarHistoryEntry } 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 history tail into the last text output + last tool call (each is - * the LAST occurrence in event order). Lifecycle events and raw - * `assistant/chunk` rows are ignored — the card shows what the subagent is - * doing right now, not its plumbing. - * @param entries - the tail page from `subagent.history` (oldest → newest). - * @returns the last text and/or tool call; an empty object when the tail has neither. - */ -export function lastActivity(entries: SidebarHistoryEntry[]): LastActivity { - let text: string | undefined - let tool: { name: string; args: string } | undefined - for (const entry of entries) { - const { type, data } = entry.event - if (type === 'assistant/message') { - const message = data.message as { content?: unknown } | undefined - const extracted = contentText(message?.content) - if (extracted !== undefined) text = extracted - } else if (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/context-types.ts b/src/context-types.ts index 9c2c32e..5686795 100644 --- a/src/context-types.ts +++ b/src/context-types.ts @@ -239,6 +239,41 @@ export interface SidebarAgentsService { resume?(options: unknown): Promise<{ agent: SidebarAgent; dispose(): Promise }> } +/** The host subagent runtime face (`ctx.subagents`; optional — the live + * batch route degrades to a 503 when the deployment lacks it). Only the + * read-only descendant enumeration this plugin needs is mirrored. */ +export interface SidebarSubagentsService { + /** + * Enumerate the root's complete session-backed subagent tree in stable + * pre-order without loading or resuming an Agent (mirror of + * `SubagentRuntime.listDescendants`). + */ + listDescendants( + rootSessionId: string, + signal?: AbortSignal, + ): Promise +} + +/** One descendant row of `ctx.subagents.listDescendants` (structural mirror). */ +export type SidebarSubagentDescendantEntry = + | { + kind: 'child' + id: string + activity: 'running' | 'inactive' + hasChildren: boolean + mode: 'one-shot' | 'continuable' + label?: string + parentId: string + depth: number + } + | { + kind: 'diagnostic' + id: string + reason: 'corrupt' | 'unsupported' | 'unavailable' + parentId: string + depth: number + } + /** The host agent-presets service face (mirror of the runtime agentPresets * service): resolves and mounts the preset composition a session recorded, * so a resumed or forked session rebuilds the same tool/prompt world its @@ -514,6 +549,11 @@ declare module 'cordis' { * resume the Side Chat thread agents). */ agents: SidebarAgentsService + /** + * The host subagent runtime (`ctx.subagents`; optional — the Subagent + * page's live batch route reads descendant catalogs through it). + */ + subagents: SidebarSubagentsService /** * The host agent-presets service (`ctx.get('agentPresets')`; optional — * absent deployments compose nothing and every session shares the host diff --git a/src/index.ts b/src/index.ts index ded9268..2deb39f 100644 --- a/src/index.ts +++ b/src/index.ts @@ -47,6 +47,7 @@ import { } from './pty-deps.ts' import { registerTools } from './tools.ts' import { buildJobsApi, type SidebarJobsRoutes } from './jobs-routes.ts' +import { buildSubagentLiveApi, type SidebarSubagentLiveRoutes } from './subagent-live-route.ts' import { buildSidechatApi } from './sidechat-routes.ts' import { readJsonBody, requireString, SidebarError, writeError, writeJson, writeOk } from './wire.ts' @@ -234,6 +235,10 @@ function buildApi( // job_output cursor is never consumed) and kill (the registry's stock // API). A deployment without the jobs registry downgrades kill to a 503. const jobsApi: SidebarJobsRoutes = buildJobsApi(ctx, resolved.readLimit) + // Subagent live previews: one batch request instead of N per-child + // `subagents.history` calls. The route degrades to a 503 when the host + // subagent runtime is absent (the page has no topology to show anyway). + const subagentLiveApi: SidebarSubagentLiveRoutes = buildSubagentLiveApi(ctx) return { 'session.cwd': (payload) => { const { sessionId, cwd } = cwdOf(payload) @@ -386,6 +391,9 @@ function buildApi( // exists. Kill is fenced to the owning session by the jobs registry. 'jobs.output': (payload) => jobsApi.output(payload), 'jobs.kill': (payload) => jobsApi.kill(payload), + // Subagent live previews: one batch request per refresh; the route folds + // the newest text/tool activity of every running child in the tree. + 'subagents.live': (payload) => subagentLiveApi.live(payload), // The effective terminal shell and its display name. The client uses // this to title terminal tabs with the shell name instead of a numbered // "Terminal N" label; the shell itself is configured through diff --git a/src/subagent-activity.ts b/src/subagent-activity.ts new file mode 100644 index 0000000..6a5d582 --- /dev/null +++ b/src/subagent-activity.ts @@ -0,0 +1,91 @@ +/** + * 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 + * `assistant/chunk` rows are ignored — the card shows what the subagent is + * doing right now, not its plumbing. 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 new file mode 100644 index 0000000..e1c93cf --- /dev/null +++ b/src/subagent-live-route.ts @@ -0,0 +1,93 @@ +/** + * The live-preview route of the Subagent page ('subagents.live'): one + * request per refresh instead of N per-child `subagents.history` calls. + * + * 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. + * + * 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. + */ +import type { Context, SidebarSubagentsService } from './context-types.ts' +import { SIDE_LABEL_PREFIX } from './sidechat-core.ts' +import { lastActivity, type LastActivity } from './subagent-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. + * @param payload - `{ rootSessionId }`. + * @returns `{ live: Record }`; children with + * no text/tool yet are omitted. + */ + 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. + */ +export function buildSubagentLiveApi(ctx: Context): SidebarSubagentLiveRoutes { + return { + async live(payload) { + const rootSessionId = requireString(payload, 'rootSessionId') + const subagents = ctx.get('subagents') as SidebarSubagentsService | undefined + if (subagents === undefined || typeof subagents.listDescendants !== 'function') { + throw new SidebarError( + 'subagents-unavailable', + 'the subagent service is not mounted in this deployment', + 503, + ) + } + let descendants + try { + descendants = await subagents.listDescendants(rootSessionId) + } catch (error) { + throw new SidebarError( + 'subagents-unavailable', + `subagent catalog read failed: ${error instanceof Error ? error.message : String(error)}`, + 503, + ) + } + + const live: Record = {} + 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 + // 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)?.events ?? [], + 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. + } + } + return { live } + }, + } +} diff --git a/src/wire.ts b/src/wire.ts index cf13622..ccea91c 100644 --- a/src/wire.ts +++ b/src/wire.ts @@ -19,6 +19,7 @@ export type SidebarErrorCode = | 'pty-deps-missing' | 'job-error' | 'sidechat-error' + | 'subagents-unavailable' | 'settings-rejected' | 'settings-conflict' | 'internal' diff --git a/tests/subagent-activity.spec.ts b/tests/subagent-activity.spec.ts index fe016de..603ef03 100644 --- a/tests/subagent-activity.spec.ts +++ b/tests/subagent-activity.spec.ts @@ -1,11 +1,11 @@ import { describe, expect, it } from 'vitest' -import { contentText, lastActivity } from '../src/client/subagent-activity.ts' -import type { SidebarHistoryEntry } from '../src/context-types.ts' +import { contentText, lastActivity } from '../src/subagent-activity.ts' +import type { SidebarSessionEvent } from '../src/context-types.ts' describe('subagent activity summary parser', () => { - /** One history entry from raw event fields. */ - const entry = (type: string, data: Record): SidebarHistoryEntry => ({ - event: { type, seq: 0, time: 0, data }, + /** 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', () => { @@ -37,7 +37,7 @@ describe('subagent activity summary parser', () => { }) }) - it('lastActivity keeps only the fields the tail actually has', () => { + 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"}' } }) @@ -64,4 +64,42 @@ describe('subagent activity summary parser', () => { ]) 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-jobs-view.spec.tsx b/tests/subagent-jobs-view.spec.tsx index e1bb464..9c5e37b 100644 --- a/tests/subagent-jobs-view.spec.tsx +++ b/tests/subagent-jobs-view.spec.tsx @@ -96,21 +96,24 @@ beforeEach(() => { killCalls.length = 0 vi.stubGlobal('fetch', async (url: string | URL | Request, init?: RequestInit) => { const method = String(url).split('/').pop() - const body = JSON.parse(String(init?.body)) as { sessionId: string; id: string } + const body = JSON.parse(String(init?.body)) as { sessionId?: string; id?: string; rootSessionId?: string } + if (method === 'subagents.live') { + return jsonResponse({ ok: true, value: { live: {} } }) + } if (method === 'jobs.output') { - outputCalls.push({ sessionId: body.sessionId, id: body.id }) + outputCalls.push({ sessionId: body.sessionId ?? '', id: body.id ?? '' }) // bash-9 stands for a job the model never read (read:false). return jsonResponse({ ok: true, value: { - text: body.id === 'bash-9' ? '' : `output-of-${body.id}`, + text: body.id === 'bash-9' ? '' : `output-of-${body.id ?? ''}`, truncated: false, read: body.id !== 'bash-9', }, }) } if (method === 'jobs.kill') { - killCalls.push({ sessionId: body.sessionId, id: body.id }) + killCalls.push({ sessionId: body.sessionId ?? '', id: body.id ?? '' }) return jsonResponse({ ok: true, value: { ok: true, outcome: 'requested' } }) } throw new Error(`unexpected fetch ${String(url)}`) diff --git a/tests/subagent-live-polling.spec.tsx b/tests/subagent-live-polling.spec.tsx new file mode 100644 index 0000000..4bfc4b0 --- /dev/null +++ b/tests/subagent-live-polling.spec.tsx @@ -0,0 +1,282 @@ +/** + * Component-level regression for the Subagent page live preview polling: + * one shared `subagents.live` request per refresh, never a per-child + * `subagents.history` call, and never a second in-flight request while a + * slow host is still answering. + */ +// @vitest-environment jsdom +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import { createElement, type ReactNode } from 'react' +import { createRoot, type Root } from 'react-dom/client' +import { act } from 'react-dom/test-utils' +import { SubagentView } from '../src/client/SubagentView.tsx' +import type { Context, SidebarSessionList } from '../src/context-types.ts' + +/** A subscribable sessions-list snapshot (mirror of the runtime list feed). */ +function makeStore(initial: SidebarSessionList) { + let snapshot = initial + const listeners = new Set<() => void>() + return { + getSnapshot: () => snapshot, + subscribe: (fn: () => void) => { + listeners.add(fn) + return () => { listeners.delete(fn) } + }, + set(next: SidebarSessionList) { + snapshot = next + for (const fn of [...listeners]) fn() + }, + } +} + +type Store = ReturnType + +/** The client context face SubagentView touches; history is spied for the + * regression assertion (the new page must never call it). */ +function makeCtx(store: Store, historySpy: ReturnType): Context { + return { + sessions: { + list: store, + setSubagentCatalogOpen: () => {}, + openSubagent: () => {}, + open: () => {}, + refreshSubagents: async () => {}, + }, + connection: { + api: { + sessions: { history: historySpy }, + subagents: { history: historySpy }, + }, + }, + } as unknown as Context +} + +/** Render `node` into a detached body container under React's act(). */ +function mount(node: ReactNode): { + container: HTMLDivElement + rerender: (next: ReactNode) => void + unmount: () => void +} { + const container = document.createElement('div') + document.body.append(container) + const root: Root = createRoot(container) + const rerender = (next: ReactNode): void => { act(() => { root.render(next) }) } + act(() => { root.render(node) }) + const unmount = (): void => { + act(() => { root.unmount() }) + container.remove() + } + return { container, rerender, unmount } +} + +function jsonResponse(value: unknown): Response { + return { ok: true, status: 200, json: async () => value } as unknown as Response +} + +/** A topology snapshot with two running direct subagents and a ready catalog. */ +function runningSnapshot(): SidebarSessionList { + return { + current: 'root', + byId: { + root: { id: 'root', displayTitle: '主会话' }, + a: { id: 'a', displayTitle: 'A', origin: 'subagent', parentId: 'root', running: true }, + b: { id: 'b', displayTitle: 'B', origin: 'subagent', parentId: 'root', running: true }, + }, + subagentsByParent: { + root: { + entries: [ + { kind: 'child', id: 'a', activity: 'running', hasChildren: false, mode: 'one-shot', label: 'A' }, + { kind: 'child', id: 'b', activity: 'running', hasChildren: false, mode: 'one-shot', label: 'B' }, + ], + parentAvailable: true, + state: 'ready', + error: null, + }, + }, + jobsBySession: {}, + } +} + +/** The same tree re-rooted under a new ancestor ('grand' becomes the root). */ +function reRootedSnapshot(): SidebarSessionList { + return { + current: 'root', + byId: { + grand: { id: 'grand', displayTitle: '主会话' }, + root: { id: 'root', displayTitle: 'R', origin: 'subagent', parentId: 'grand', running: true }, + a: { id: 'a', displayTitle: 'A', origin: 'subagent', parentId: 'root', running: true }, + b: { id: 'b', displayTitle: 'B', origin: 'subagent', parentId: 'root', running: true }, + }, + subagentsByParent: { + grand: { + entries: [ + { kind: 'child', id: 'root', activity: 'running', hasChildren: true, mode: 'continuable', label: 'R' }, + ], + parentAvailable: true, + state: 'ready', + error: null, + }, + root: { + entries: [ + { kind: 'child', id: 'a', activity: 'running', hasChildren: false, mode: 'one-shot', label: 'A' }, + { kind: 'child', id: 'b', activity: 'running', hasChildren: false, mode: 'one-shot', label: 'B' }, + ], + parentAvailable: true, + state: 'ready', + error: null, + }, + }, + jobsBySession: {}, + } +} + +beforeEach(() => { + Object.defineProperty(globalThis.navigator, 'language', { value: 'zh-CN', configurable: true }) +}) + +afterEach(() => { + vi.unstubAllGlobals() + vi.useRealTimers() + for (const el of document.querySelectorAll('body > div')) el.remove() +}) + +describe('SubagentView live polling', () => { + it('sends one subagents.live per poll and never calls per-child history', async () => { + vi.useFakeTimers() + const historySpy = vi.fn() + const liveCalls: string[] = [] + vi.stubGlobal('fetch', async (url: string | URL | Request, init?: RequestInit) => { + const method = String(url).split('/').pop() + if (method === 'subagents.live') { + const body = JSON.parse(String(init?.body)) as { rootSessionId?: string } + liveCalls.push(body.rootSessionId ?? '') + return jsonResponse({ ok: true, value: { live: {} } }) + } + throw new Error(`unexpected fetch ${String(url)}`) + }) + + const store = makeStore(runningSnapshot()) + const { container, unmount } = mount( + createElement(SubagentView, { sessionId: 'root', active: true, ctx: makeCtx(store, historySpy) }), + ) + // Initial load plus the first scheduled tick. + await act(async () => { await Promise.resolve() }) + expect(liveCalls).toEqual(['root']) + expect(historySpy).not.toHaveBeenCalled() + expect(container.textContent).toContain('思考中…') + + await act(async () => { await vi.advanceTimersByTimeAsync(3_000) }) + expect(liveCalls).toEqual(['root', 'root']) + expect(historySpy).not.toHaveBeenCalled() + unmount() + }) + + it('never starts a second request while one is in flight', async () => { + vi.useFakeTimers() + const historySpy = vi.fn() + const liveCalls: string[] = [] + let resolveFirst: ((response: Response) => void) | undefined + vi.stubGlobal('fetch', (url: string | URL | Request, init?: RequestInit) => { + const method = String(url).split('/').pop() + if (method === 'subagents.live') { + const body = JSON.parse(String(init?.body)) as { rootSessionId?: string } + liveCalls.push(body.rootSessionId ?? '') + return new Promise((resolve) => { resolveFirst = resolve }) + } + throw new Error(`unexpected fetch ${String(url)}`) + }) + + const store = makeStore(runningSnapshot()) + const { unmount } = mount( + createElement(SubagentView, { sessionId: 'root', active: true, ctx: makeCtx(store, historySpy) }), + ) + await act(async () => { await Promise.resolve() }) + expect(liveCalls).toEqual(['root']) + expect(resolveFirst).toBeTypeOf('function') + + // While the first request is pending, no timer can fire a second one. + await act(async () => { await vi.advanceTimersByTimeAsync(10_000) }) + expect(liveCalls).toEqual(['root']) + + // Settle the first request; only then does the next 3s tick fire. + await act(async () => { + resolveFirst?.(jsonResponse({ ok: true, value: { live: {} } })) + await Promise.resolve() + }) + await act(async () => { await vi.advanceTimersByTimeAsync(3_000) }) + expect(liveCalls).toEqual(['root', 'root']) + expect(historySpy).not.toHaveBeenCalled() + unmount() + }) + + it('stops polling and drops the in-flight request when the page hides', async () => { + vi.useFakeTimers() + const historySpy = vi.fn() + const liveCalls: string[] = [] + let resolveFirst: ((response: Response) => void) | undefined + vi.stubGlobal('fetch', (url: string | URL | Request, init?: RequestInit) => { + const method = String(url).split('/').pop() + if (method === 'subagents.live') { + const body = JSON.parse(String(init?.body)) as { rootSessionId?: string } + liveCalls.push(body.rootSessionId ?? '') + return new Promise((resolve) => { resolveFirst = resolve }) + } + throw new Error(`unexpected fetch ${String(url)}`) + }) + + const store = makeStore(runningSnapshot()) + const { rerender, unmount } = mount( + createElement(SubagentView, { sessionId: 'root', active: true, ctx: makeCtx(store, historySpy) }), + ) + await act(async () => { await Promise.resolve() }) + expect(liveCalls).toEqual(['root']) + + // Hide the page: the effect cleanup aborts the in-flight request and no + // timer may fire afterwards. + rerender(createElement(SubagentView, { sessionId: 'root', active: false, ctx: makeCtx(store, historySpy) })) + await act(async () => { await vi.advanceTimersByTimeAsync(10_000) }) + expect(liveCalls).toEqual(['root']) + + // Settling the stale response must neither render nor schedule a poll. + await act(async () => { + resolveFirst?.(jsonResponse({ ok: true, value: { live: { a: { text: 'stale' } } } })) + await Promise.resolve() + }) + await act(async () => { await vi.advanceTimersByTimeAsync(10_000) }) + expect(liveCalls).toEqual(['root']) + expect(historySpy).not.toHaveBeenCalled() + unmount() + }) + + it('clears the live map and re-polls when the topology root changes', async () => { + vi.useFakeTimers() + const historySpy = vi.fn() + const liveCalls: string[] = [] + vi.stubGlobal('fetch', async (url: string | URL | Request, init?: RequestInit) => { + const method = String(url).split('/').pop() + 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' } } : {} + return jsonResponse({ ok: true, value: { live } }) + } + throw new Error(`unexpected fetch ${String(url)}`) + }) + + const store = makeStore(runningSnapshot()) + const { container, unmount } = mount( + createElement(SubagentView, { sessionId: 'root', active: true, ctx: makeCtx(store, historySpy) }), + ) + await act(async () => { await Promise.resolve() }) + expect(liveCalls).toEqual(['root']) + expect(container.textContent).toContain('hello') + + // The tree is re-rooted under a new ancestor: old rows must not leak. + store.set(reRootedSnapshot()) + await act(async () => { await Promise.resolve() }) + expect(liveCalls).toEqual(['root', 'grand']) + expect(container.textContent).not.toContain('hello') + expect(historySpy).not.toHaveBeenCalled() + unmount() + }) +}) diff --git a/tests/subagent-live-route.spec.ts b/tests/subagent-live-route.spec.ts new file mode 100644 index 0000000..2922a98 --- /dev/null +++ b/tests/subagent-live-route.spec.ts @@ -0,0 +1,168 @@ +/** + * Host route tests for the Subagent live-preview batch API ('subagents.live'). + * The route must enumerate the tree ONCE, keep only catalog-running + * non-Side-Chat children, fold only non-empty activity from their session + * logs, and degrade to a 503 when the host subagent runtime is absent. + */ +import { describe, expect, it, vi } from 'vitest' +import { buildSubagentLiveApi } from '../src/subagent-live-route.ts' +import { SidebarError } from '../src/wire.ts' +import type { + Context, + SidebarSessionEvent, + SidebarSubagentDescendantEntry, + SidebarSubagentsService, +} from '../src/context-types.ts' + +/** One child descendant row. */ +function child( + id: string, + over: Partial> = {}, +): SidebarSubagentDescendantEntry { + return { + kind: 'child', + id, + activity: 'running', + hasChildren: false, + mode: 'one-shot', + parentId: 'root', + depth: 1, + ...over, + } +} + +/** One diagnostic descendant row. */ +function diagnostic(id: string): SidebarSubagentDescendantEntry { + return { kind: 'diagnostic', id, reason: 'corrupt', parentId: 'root', depth: 1 } +} + +/** A session with the given raw events. */ +function session(events: SidebarSessionEvent[]): { header: { cwd: string }; events: SidebarSessionEvent[] } { + return { header: { cwd: '/p' }, events } +} + +/** A context whose `get` serves only the subagents face, with a session store. */ +function ctxWith(subagents: unknown, sessions: unknown): Context { + return { + sessions, + get: (key: string) => (key === 'subagents' ? subagents : undefined), + } as unknown as Context +} + +describe('subagents.live route', () => { + it('returns non-empty activity for running children only', async () => { + const subagents: SidebarSubagentsService = { + listDescendants: vi.fn(async () => [ + child('running-a', { label: 'A' }), + child('running-b', { label: 'B' }), + child('inactive', { activity: 'inactive', label: 'C' }), + child('side-chat', { label: 'Side: chat' }), + diagnostic('corrupt-row'), + ]), + } + 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"}' } }, + ]) + } + 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"}' } }, + }, + }) + expect(subagents.listDescendants).toHaveBeenCalledWith('root') + }) + + it('omits children with no text/tool yet', async () => { + const subagents: SidebarSubagentsService = { + listDescendants: vi.fn(async () => [child('empty', { label: 'Empty' })]), + } + const sessions = { get: () => session([]) } + const api = buildSubagentLiveApi(ctxWith(subagents, sessions)) + await expect(api.live({ rootSessionId: 'root' })).resolves.toEqual({ live: {} }) + }) + + 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' }] } }, + } + 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' } }, + }) + }) + + it('skips a child whose session log is unavailable without failing the batch', async () => { + const subagents: SidebarSubagentsService = { + listDescendants: vi.fn(async () => [ + child('good', { label: 'Good' }), + child('bad', { label: 'Bad' }), + ]), + } + const sessions = { + get: (id: string) => { + if (id === 'good') { + return session([ + { type: 'assistant/message', seq: 0, time: 0, data: { message: { content: [{ type: 'text', text: 'ok' }] } } }, + ]) + } + throw new Error('missing') + }, + } + const api = buildSubagentLiveApi(ctxWith(subagents, sessions)) + await expect(api.live({ rootSessionId: 'root' })).resolves.toEqual({ + live: { good: { text: 'ok' } }, + }) + }) + + it('degrades to a 503 when the subagent runtime is absent', async () => { + const api = buildSubagentLiveApi(ctxWith(undefined, { get: () => undefined })) + await expect(api.live({ rootSessionId: 'root' })).rejects.toThrowError( + expect.objectContaining>({ code: 'subagents-unavailable', status: 503 }), + ) + }) + + it('degrades to a 503 when listDescendants fails', async () => { + const subagents: SidebarSubagentsService = { + listDescendants: vi.fn(async () => { throw new Error('projection unavailable') }), + } + const api = buildSubagentLiveApi(ctxWith(subagents, { get: () => undefined })) + await expect(api.live({ rootSessionId: 'root' })).rejects.toThrowError( + expect.objectContaining>({ code: 'subagents-unavailable', status: 503 }), + ) + }) + + it('rejects a missing rootSessionId as bad-request', async () => { + const api = buildSubagentLiveApi(ctxWith(undefined, { get: () => undefined })) + await expect(api.live({})).rejects.toThrowError( + expect.objectContaining>({ code: 'bad-request' }), + ) + }) +})