diff --git a/AGENTS.md b/AGENTS.md index a9eb40e..6ef601e 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -6,6 +6,14 @@ better-sidebar 从 v0.4.0 起暴露 `ctx.betterSidebar` 服务(Cordis context --- +## 0. 仓库硬约束(所有本仓库插件必须遵守) + +- **禁止修改 DeepSeek Harness (DSH) 源码**:对官方源码 checkout(`~/.dsh/source/current`)零写入——不得改 harness 包、不得把 harness 改动提交到它的分支。 +- **挂载只走 `cordis.patch.yml` + profile 机制**(`~/.dsh/profiles//`),插件永远作为独立包被 profile 引用,不反向侵入 DSH。 +- 需要 harness 没有的能力时,用 DSH **现成的只读/公开 API** 或插件自有路由实现(参考 §7 的 `tasks.output` 事件回放:读会话事件日志而非动注册表);如果确实做不到,先向用户说明取舍,而不是直接改 DSH。 + +--- + ## 1. 服务定位 - **服务名**:`betterSidebar`(即 `ctx.betterSidebar`) diff --git a/docs/plans/2026-08-12-subagent-background-tasks-design.md b/docs/plans/2026-08-12-subagent-background-tasks-design.md index e835485..3ace517 100644 --- a/docs/plans/2026-08-12-subagent-background-tasks-design.md +++ b/docs/plans/2026-08-12-subagent-background-tasks-design.md @@ -1,44 +1,43 @@ -# 子代理页后台命令区块(查看输出 + 强制终止)设计 +# 任务管理页后台命令区块(查看输出 + 强制终止)设计 > 2026-08-12 · v0.8.0 ## 目标 -Subagent 页面(当前树的主代理拓扑)在拓扑树下方同页显示**当前树全部会话**(主代理 + 子代理)的后台任务: +任务管理页(当前树的主代理拓扑,原「子代理」页)在拓扑树下方同页显示**当前树全部会话**(主代理 + 子代理)的后台任务: -- 点击任务行展开输出面板,实时查看累积输出(运行中每 2s 轮询,页面不可见时暂停); +- 点击任务行,输出显示在**侧边栏底部共享 Dock**(sticky 终端式,滚动时保持可见;运行中每 2s 轮询,页面不可见时暂停;自动跟随底部); - 运行中的任务行带两击确认的终止按钮; -- 不破坏模型契约:查看输出走**非消费 peek**,绝不推进 `task_output` 的读取游标、不置 `reported` 位。 +- **不破坏模型契约、不修改 DSH 源码**(本仓库规则:对官方源码 checkout 零写入,挂载只走 cordis.patch.yml + profile 机制):输出查看是**模型已读输出的回放**,绝不推进 `task_output` 的读取游标。 ## 数据流 ``` bash/tool run_in_background → ctx.tasks.start(owner = 执行 agent) api-proxy session/tasks 帧(TaskView[],无输出)→ 客户端 tasksBySession 镜像 -SubagentView 读镜像 → collectTreeTasks(树内会话) → 行渲染 / 展开面板 -展开面板 → POST /sidebar/api/tasks.output(非消费 peek)→ ctx.tasks.peek -终止 → POST /sidebar/api/tasks.kill → ctx.tasks.kill +SubagentView 读镜像 → collectTreeTasks(树内会话) → 行渲染(单 Dock,选中行) +点行 → POST /sidebar/api/tasks.output(事件回放)→ 扫描属主会话 events 里 + task_output 的 tool/call + tool/result 对 → 模型已读文本 +终止 → POST /sidebar/api/tasks.kill → ctx.tasks.kill(DSH 现成 API) ``` ## 关键决策 -1. **非消费 peek 缝隙(harness 侧,跨仓库改动)**:现有 `TaskService.read()` 是单一消费游标——UI 读取会偷走模型 `task_output` 的字节(或读到空)。因此新增: - - `BashProcess.peekOutput()`:非消费读自启动以来的全部缓冲(`readFrom(0)`,不推进 offset); - - `TaskHooks.peekOutput?()` + `TaskService.peek(id, caller)`:返回全量累积文本,**不消费、不标记 reported**; - - tool-bash / tool-pwsh 在 background `TaskStart.run()` hooks 里接 `peekOutput`(复用 render 管线)。 - - 模型工具(tool-tasks 的 task_output/task_kill)零改动。 -2. **列表不新增路由**:任务清单已由 harness 的 `session/tasks` 推送镜像提供,只加 `tasks.output` / `tasks.kill` 两个路由(信任围栏与其余路由一致)。 -3. **访问围栏**:caller 由请求的 `sessionId` 现场解析 `ctx.agents.get(sessionId)`,注册表按 owner session id 拒绝 foreign 任务;未知/foreign 统一映射 404 `task-error`(不可区分存在性,与 api-proxy 的 view 保密一致)。 +1. **输出 = 会话事件回放,零 DSH 写入**:`TaskService.read()` 是单一消费游标——UI 直接读会偷走模型 `task_output` 的字节(或读到空)。而 DSH 源码禁止修改,所以 `tasks.output` **不碰注册表**:扫描属主会话的 append-only 事件日志,取 `task_output` 调用的 `tool/call`(`arguments.task_id` 配对)与其 `tool/result`(finalize 后的文本,即模型实际收到的内容)按序拼接;`"(no new output)"` 增量与错误结果跳过;`read: false` 表示模型尚未读过。模型游标天然不被触碰。 + - 语义代价(用户已确认接受):面板只在模型读过之后才有内容——没有实时流。 +2. **列表不新增路由**:任务清单由 harness 的 `session/tasks` 推送镜像提供;只加 `tasks.output`(回放)与 `tasks.kill`(注册表原生 `kill`)两个路由(信任围栏与其余路由一致)。注册表缺席时 kill 降级 503(output 不需要注册表)。 +3. **终止的访问围栏**:caller 由请求的 `sessionId` 现场解析 `ctx.agents.get(sessionId)`,注册表按 owner session id 拒绝 foreign 任务;未知/foreign 统一映射 404 `task-error`。 4. **展示范围 = 整棵树**:与页面作用域一致(拓扑即当前树);树跨多会话时行尾显示属主标题。 -5. **两击确认终止**:首次点击进入「再次点击确认」态(3s 自动解除),防止误杀长任务。 -6. **输出上限**:路由按 `readLimit`(默认 512KB)截断并置 `truncated` 标记。 +5. **单共享 Dock(大量任务的形态)**:行内展开会让列表随任务数膨胀、排序变化时面板跳动;改为一个底部 sticky Dock + 单一轮询实例,行保持紧凑、排序变动不惊吓,选中行高亮。 +6. **两击确认终止**:首次点击进入「再次点击确认」态(3s 自动解除),防止误杀长任务。 +7. **输出上限**:路由按 `readLimit`(默认 512KB)截断并置 `truncated` 标记。 ## 实施偏差 -- 无。宿主不新增 `@deepseek-ai/*` 依赖(`ctx.tasks`/`ctx.agents` 走结构性镜像 + 运行时 `ctx.get`,可选服务降级 503)。 +- 曾实现 harness 侧非消费 peek 缝隙(`BashProcess.peekOutput` / `TaskService.peek`),因「禁止修改 DSH 源码」规则**整体回退**(harness 提交已 reset,checkout 恢复纯净快照),改用事件回放。 +- 宿主不新增 `@deepseek-ai/*` 依赖(`ctx.tasks`/`ctx.agents` 走结构性镜像 + 运行时 `ctx.get`,可选服务降级 503);`ctx.sessions.get(id).events` 为只读访问。 ## 验证 -- harness 单测:bash-local peek 不推进游标;tasks-local peek 幂等/不置 reported/围栏;tool-bash/pwsh 后台任务 peek 后 read 仍完整投递。 -- 插件单测:tasks-routes(caller 解析、截断、404/503 降级);subagent-tasks 纯函数;jsdom 视图(行渲染、展开取输出、两击终止、隐藏不轮询)。 -- 集成(真实 dsh web):启动 `run_in_background` bash → `/sidebar/api/tasks.output` 返回累积输出且模型 `task_output` 仍读到全部字节 → `/sidebar/api/tasks.kill` 后进程退出、任务结算 `killed (signal: SIGTERM)`、终态 peek 幂等。 +- 插件单测:tasks-routes(回放拼接/过滤/read 标志/截断/404/503 降级);subagent-tasks 纯函数;jsdom 视图(行渲染、单 Dock 切换、关闭、两击终止、read:false 提示、大量任务、隐藏不轮询、镜像清空 hook 顺序回归 #300)。 +- 集成(真实 dsh web):启动 `run_in_background` bash → 模型 `task_output` 读取 → `/sidebar/api/tasks.output` 返回模型已读内容 → `/sidebar/api/tasks.kill` 后进程退出、任务结算 `killed (signal: SIGTERM)`。 diff --git a/src/client/SubagentView.tsx b/src/client/SubagentView.tsx index 4f2ab7d..cd8e8b7 100644 --- a/src/client/SubagentView.tsx +++ b/src/client/SubagentView.tsx @@ -359,13 +359,13 @@ function CatalogRows({ /** * The shared output dock of the tasks section: ONE pane at the bottom of the - * sidebar body (sticky, terminal-like) shows the SELECTED task's FULL - * accumulated output via the host's NON-CONSUMING peek (`tasks.output`), - * refreshed every {@link TASK_POLL_MS} while the task runs and the page is - * visible. The model's own `task_output` cursor is never touched, so - * watching a stream cannot steal the agent's bytes. A single dock — not a - * panel per row — keeps the task list compact and stable when many tasks - * are running. + * sidebar body (sticky, terminal-like) shows the SELECTED task's output as + * the MODEL has read it so far (replayed from the owner session's event + * log), refreshed every {@link TASK_POLL_MS} while the task runs and the + * page is visible. The model's `task_output` cursor is never touched — the + * pane can never steal the agent's bytes, and it stays empty until the + * agent reads the task. A single dock — not a panel per row — keeps the + * task list compact and stable when many tasks are running. */ function TaskOutputPane(props: { ownerSessionId: string @@ -437,7 +437,9 @@ function TaskOutputPane(props: { <> {state.text.length > 0 ?
{state.text}
- :
{t('taskNoOutput')}
} + : state.read + ?
{t('taskNoOutput')}
+ :
{t('taskNotReadYet')}
} {state.truncated &&
{t('taskOutputTruncated')}
} )} @@ -448,9 +450,10 @@ function TaskOutputPane(props: { /** * The background-task section of the Subagent page: every task of the whole * current tree (main agent + subagents, owner-labeled), fed by the harness - * `session/tasks` push mirror. Clicking a row feeds its output to the shared - * bottom dock (non-consuming peek); live rows carry a two-click-confirm - * kill button. Renders nothing while the tree has no tasks. + * `session/tasks` push mirror. Clicking a row feeds its model-read output to + * the shared bottom dock (event replay — never the model's cursor); live + * rows carry a two-click-confirm kill button. Renders nothing while the + * tree has no tasks. */ function TasksSection(props: { byId: SidebarSessionList['byId'] diff --git a/src/client/api.ts b/src/client/api.ts index 20687bf..22db5fc 100644 --- a/src/client/api.ts +++ b/src/client/api.ts @@ -8,7 +8,6 @@ */ import { encodeHtmlUrl } from '../html-route.ts' import type { BrowserProbeResult } from './browser.ts' -import type { SidebarTaskStatus } from '../context-types.ts' /** One wire failure. */ export class SidebarApiError extends Error { @@ -61,13 +60,19 @@ export interface FsTextResult { kind: 'text'; content: string; truncated: boolea * `head` carries the first bytes (base64) for viewer detect sniffing. */ export interface FsBinaryResult { kind: 'binary'; size: number; truncated: boolean; head: string } -/** One tasks.output response: the peeked full text (capped) plus the wire status. */ +/** + * One tasks.output response: the output the MODEL has read so far for the + * task (replayed from the owner session's event log — the model's + * task_output cursor is never touched, so the pane can never steal the + * agent's bytes). `read` is false until the model actually called + * task_output for the task. + */ export interface TaskOutputResult { text: string /** True when the host capped the text at its output limit. */ truncated: boolean - status: SidebarTaskStatus - detail?: string + /** Whether the model has read the task at least once. */ + read: boolean } async function call(method: string, payload: Record, signal?: AbortSignal): Promise { @@ -156,10 +161,9 @@ export const api = { agentPtyClose: (uuid: string) => call<{ ok: true }>('agent-pty.close', { uuid }), /** - * Full accumulated output of one background task (the host's NON-CONSUMING - * peek — the model's task_output cursor and report bit are untouched, so a - * human watching a stream never steals the agent's bytes). The scope MUST - * be the task's OWNER session (the tasks registry fences by session id). + * The output the model has read so far for one background task (replayed + * from the owner session's event log — never the model's task_output + * cursor). The scope MUST be the task's OWNER session. */ taskOutput: (scope: SessionScope, id: string, signal?: AbortSignal) => call('tasks.output', scopePayload(scope, { id }), signal), diff --git a/src/client/locales.ts b/src/client/locales.ts index 4a37958..d16d165 100644 --- a/src/client/locales.ts +++ b/src/client/locales.ts @@ -194,6 +194,7 @@ const zh = { taskViewOutput: '查看输出', taskHideOutput: '收起输出', taskNoOutput: '暂无输出', + taskNotReadYet: '模型尚未读取该任务的输出,读取后会显示在这里', taskOutputTruncated: '输出过长,已截断显示', taskOutputError: '输出读取失败', taskKill: '终止', @@ -390,6 +391,7 @@ const en: Record = { taskViewOutput: 'View output', taskHideOutput: 'Hide output', taskNoOutput: 'No output yet', + taskNotReadYet: 'The model has not read this task\'s output yet; it will appear here once read', taskOutputTruncated: 'Output truncated', taskOutputError: 'Failed to read output', taskKill: 'Kill', diff --git a/src/context-types.ts b/src/context-types.ts index aab08ea..d6df297 100644 --- a/src/context-types.ts +++ b/src/context-types.ts @@ -48,7 +48,15 @@ export interface SidebarSessionHeader { /** The host session store face (`ctx.sessions.get(id)` returns the live session). */ export interface SidebarSessionStore { - get(id: string): { header: SidebarSessionHeader } | undefined + get(id: string): { + header: SidebarSessionHeader + /** + * The live session's append-only event log (immutable snapshot; absent + * on sessions the runtime has not hydrated). Read-only access — the + * tasks.output route replays `task_output` tool/result rows from it. + */ + events?: readonly SidebarSessionEvent[] + } | undefined } /** One loader entry's options slice (the connection row's resolved config). */ @@ -176,14 +184,6 @@ export interface SidebarTaskView { /** The host tasks registry face the sidebar routes touch (structural mirror of `TaskService`). */ export interface SidebarTasksService { - /** - * Non-consuming full-output read (never advances the model's read cursor, - * never marks the task reported). Throws for an unknown or foreign task. - */ - peek(id: string, caller?: SidebarAgent): { - text: string - snapshot: { status: SidebarTaskStatus; detail?: string } - } /** Request cancellation; throws for an unknown or foreign task. */ kill(id: string, caller?: SidebarAgent, reason?: string): 'requested' | 'already-finished' } diff --git a/src/index.ts b/src/index.ts index b3d67b3..400b09e 100644 --- a/src/index.ts +++ b/src/index.ts @@ -200,10 +200,11 @@ function buildApi( return { sessionId, cwd: sessionCwdOf(ctx, sessionId, clientCwd) } } // Background tasks: the LIST rides the harness's `session/tasks` push - // mirror, so these routes only read output (a non-consuming peek — the - // model's task_output cursor is never touched) and kill. A deployment - // without the tasks registry downgrades to a 503, like the settings routes. - const tasksApi: SidebarTasksRoutes | undefined = buildTasksApi(ctx, resolved.readLimit) + // mirror, so these routes only replay output the model has read (from the + // session's own event log — no DSH source is touched, the model's + // task_output cursor is never consumed) and kill (the registry's stock + // API). A deployment without the tasks registry downgrades kill to a 503. + const tasksApi: SidebarTasksRoutes = buildTasksApi(ctx, resolved.readLimit) return { 'session.cwd': (payload) => { const { sessionId, cwd } = cwdOf(payload) @@ -333,24 +334,14 @@ function buildApi( agentPtyRegistry.close(uuid) return { ok: true } }, - // Background tasks: read one task's FULL output (a non-consuming peek — - // the model's task_output cursor and report bit are untouched, so a - // human watching a stream never steals the agent's bytes), and kill one - // task. The task LIST itself arrives through the harness's session/tasks - // push mirror, so no list route exists. Access is fenced to the owning - // session by the tasks registry (the caller resolves from sessionId). - 'tasks.output': (payload) => { - if (tasksApi === undefined) { - throw new SidebarError('task-error', 'the background-task registry is not mounted in this deployment', 503) - } - return tasksApi.output(payload) - }, - 'tasks.kill': (payload) => { - if (tasksApi === undefined) { - throw new SidebarError('task-error', 'the background-task registry is not mounted in this deployment', 503) - } - return tasksApi.kill(payload) - }, + // Background tasks: read one task's output (a REPLAY of what the model + // has read so far, from the owner session's event log — the model's + // task_output cursor is never touched, so the human pane can never steal + // the agent's bytes), and kill one task. The task LIST itself arrives + // through the harness's session/tasks push mirror, so no list route + // exists. Kill is fenced to the owning session by the tasks registry. + 'tasks.output': (payload) => tasksApi.output(payload), + 'tasks.kill': (payload) => tasksApi.kill(payload), // The side card preferences. The settings service is optional in the // composition; while absent the routes report undefined and the client // keeps the schema defaults. Writes are revision-guarded: a stale editor diff --git a/src/tasks-routes.ts b/src/tasks-routes.ts index eb06419..95b18ae 100644 --- a/src/tasks-routes.ts +++ b/src/tasks-routes.ts @@ -1,50 +1,87 @@ /** * Background-task routes of the /sidebar JSON API ('tasks.output' / - * 'tasks.kill'). The task LIST itself needs no route: it arrives through the + * 'tasks.kill'). The task LIST needs no route: it arrives through the * harness's `session/tasks` push mirror (`tasksBySession` in the sessions - * list feed). These routes only: + * list feed). The routes: * - * - READ OUTPUT — a NON-CONSUMING peek (`ctx.tasks.peek`) that never advances - * the model's `task_output` cursor nor claims the completion report, so a - * human watching a stream cannot steal the agent's bytes; - * - KILL tasks (`ctx.tasks.kill`) with a forwarded reason. - * - * Both are fenced by the owning session: the caller Agent is resolved live - * from the requested sessionId (`ctx.agents.get`) and the tasks registry - * refuses anything that session does not own. Registry errors map to a 404 - * `task-error` (unknown/foreign ids), so the client cannot distinguish - * existence — matching the api-proxy's task-view secrecy. + * - 'tasks.output' — REPLAYS the output the MODEL has read so far from the + * owner session's OWN event log: `tool/call` rows of `task_output` name + * the task via `arguments.task_id`, and the paired `tool/result` rows + * carry the finalized content the model actually received. This touches + * NO DSH source (no peek seam, no consuming read): the model's + * `task_output` cursor is untouched by construction, and the pane stays + * empty until the agent reads the task. + * - 'tasks.kill' — the registry's stock `kill` (a pristine DSH API), + * fenced by the owning session via the live agent caller. Absent registry + * → 503, mirroring the settings routes' optional-service downgrade. */ -import type { Context, SidebarTaskStatus } from './context-types.ts' +import type { Context } from './context-types.ts' import { requireString, SidebarError } from './wire.ts' /** The two background-task routes of the sidebar API. */ export interface SidebarTasksRoutes { - /** Full accumulated output of one task (non-consuming peek, capped). */ - output(payload: unknown): { - text: string - truncated: boolean - status: SidebarTaskStatus - detail?: string - } + /** The output the model has read so far for one task (event replay, capped). */ + output(payload: unknown): { text: string; truncated: boolean; read: boolean } /** Request cancellation of one task (live tasks flip to stopping). */ kill(payload: unknown): { ok: true; outcome: 'requested' | 'already-finished' } } +/** The 'tool/result' message envelope inside a session event's data. */ +interface ToolResultMessageLike { + source?: { kind?: unknown; callId?: unknown } + content?: unknown +} + +/** One 'tool-result' content block (the inner blocks carry the text). */ +interface ToolResultBlockLike { + type?: unknown + content?: unknown + isError?: unknown +} + /** - * Build the tasks routes bound to the plugin context. Returns undefined when - * the deployment lacks the background-task registry (`ctx.tasks`) — the - * calling route then reports a 503, mirroring the settings routes' optional - * service downgrade. - * @param ctx - host plugin context (tasks/agents read lazily at call time). - * @param outputLimit - response cap for one output read in bytes; longer + * Extract the plain text of a finalized tool result: the text blocks inside + * the 'tool-result' block, joined with newlines. Error results and + * non-text blocks contribute nothing. + */ +function resultText(message: ToolResultMessageLike): string | undefined { + if (!Array.isArray(message.content)) return undefined + const parts: string[] = [] + for (const block of message.content) { + if (block === null || typeof block !== 'object') continue + const candidate = block as ToolResultBlockLike + if (candidate.type !== 'tool-result' || candidate.isError === true) continue + const inner = candidate.content + if (!Array.isArray(inner)) continue + for (const item of inner) { + if (item === null || typeof item !== 'object') continue + const textItem = item as { type?: unknown; text?: unknown } + if (textItem.type === 'text' && typeof textItem.text === 'string') { + parts.push(textItem.text) + } + } + } + return parts.length > 0 ? parts.join('\n') : undefined +} + +/** Whether a task_output result carries no new output — the controller's + * model-facing "(no new output)" body, noise for the human pane. */ +function isNoNewOutput(text: string): boolean { + return text.startsWith('(no new output)') +} + +/** + * Build the tasks routes bound to the plugin context. `output` reads only + * the session store; `kill` reads the tasks/agents services lazily and + * degrades to a 503 when the deployment lacks the registry. + * @param ctx - host plugin context. + * @param outputLimit - response cap for one output replay in bytes; longer * texts are sliced and flagged `truncated` (mirrors the fs.read cap). */ -export function buildTasksApi(ctx: Context, outputLimit: number): SidebarTasksRoutes | undefined { +export function buildTasksApi(ctx: Context, outputLimit: number): SidebarTasksRoutes { const tasks = ctx.get('tasks') - if (tasks === undefined) return undefined const agents = ctx.get('agents') - /** The live caller whose session id the fence compares against (absent → unowned only). */ + /** The live caller whose session id the registry fence compares against. */ const callerOf = (sessionId: string) => agents?.get(sessionId) /** Registry refusals become a 404 task-error; unknown and foreign ids are indistinguishable. */ const registryError = (error: unknown): SidebarError => @@ -53,20 +90,45 @@ export function buildTasksApi(ctx: Context, outputLimit: number): SidebarTasksRo output(payload) { const sessionId = requireString(payload, 'sessionId') const id = requireString(payload, 'id') - try { - const read = tasks.peek(id, callerOf(sessionId)) - const text = read.text - return { - text: text.length > outputLimit ? text.slice(0, outputLimit) : text, - truncated: text.length > outputLimit, - status: read.snapshot.status, - ...read.snapshot.detail !== undefined ? { detail: read.snapshot.detail } : {}, + // One pass over the owner session's event log: tool/call rows first + // map callId → task_id (a call always precedes its result), then each + // matching tool/result contributes the finalized text the model saw. + const session = ctx.sessions.get(sessionId) + const taskOf = new Map() + const parts: string[] = [] + let read = false + for (const event of session?.events ?? []) { + if (event.type === 'tool/call') { + const data = event.data as { name?: unknown; callId?: unknown; arguments?: unknown } + if (data.name !== 'task_output' || typeof data.callId !== 'string') continue + try { + const args = JSON.parse(typeof data.arguments === 'string' ? data.arguments : '') as { task_id?: unknown } + if (typeof args.task_id === 'string') taskOf.set(data.callId, args.task_id) + } catch { + // Malformed model arguments: not a task_output pair. + } + } else if (event.type === 'tool/result') { + const message = (event.data as { message?: unknown }).message as ToolResultMessageLike | undefined + if (message === undefined) continue + const callId = message.source?.callId + if (typeof callId !== 'string') continue + if (taskOf.get(callId) !== id) continue + read = true + const text = resultText(message) + if (text !== undefined && !isNoNewOutput(text)) parts.push(text) } - } catch (error) { - throw registryError(error) + } + const text = parts.join('\n') + return { + text: text.length > outputLimit ? text.slice(0, outputLimit) : text, + truncated: text.length > outputLimit, + read, } }, kill(payload) { + if (tasks === undefined) { + throw new SidebarError('task-error', 'the background-task registry is not mounted in this deployment', 503) + } const sessionId = requireString(payload, 'sessionId') const id = requireString(payload, 'id') const record = payload as { reason?: unknown } | null diff --git a/tests/subagent-tasks-view.spec.tsx b/tests/subagent-tasks-view.spec.tsx index b288804..e24f897 100644 --- a/tests/subagent-tasks-view.spec.tsx +++ b/tests/subagent-tasks-view.spec.tsx @@ -99,7 +99,15 @@ beforeEach(() => { const body = JSON.parse(String(init?.body)) as { sessionId: string; id: string } if (method === 'tasks.output') { outputCalls.push({ sessionId: body.sessionId, id: body.id }) - return jsonResponse({ ok: true, value: { text: `output-of-${body.id}`, truncated: false, status: 'running' } }) + // bash-9 stands for a task the model never read (read:false). + return jsonResponse({ + ok: true, + value: { + text: body.id === 'bash-9' ? '' : `output-of-${body.id}`, + truncated: false, + read: body.id !== 'bash-9', + }, + }) } if (method === 'tasks.kill') { killCalls.push({ sessionId: body.sessionId, id: body.id }) @@ -192,7 +200,7 @@ describe('SubagentView background tasks', () => { expect(container.textContent).toContain('output-of-bash-1') const second = container.querySelector('button[aria-label*="echo hi"]') as HTMLButtonElement await act(async () => { second.click() }) - // One dock, now fed by the second task (its owner session scopes the peek). + // One dock, now fed by the second task (its owner session scopes the replay). expect(outputCalls).toEqual([ { sessionId: 'root', id: 'bash-1' }, { sessionId: 'child', id: 'bash-2' }, @@ -203,6 +211,26 @@ describe('SubagentView background tasks', () => { unmount() }) + it('explains when the model has not read the task yet', async () => { + const snapshot = baseSnapshot() + snapshot.tasksBySession = { + root: [ + ...(snapshot.tasksBySession?.root ?? []), + { id: 'bash-9', kind: 'bash', label: 'unread cmd', status: 'running', startedAt: 9_000 }, + ], + } + const store = makeStore(snapshot) + const { container, unmount } = mount( + createElement(SubagentView, { sessionId: 'root', active: true, ctx: makeCtx(store) }), + ) + const row = container.querySelector('button[aria-label*="unread cmd"]') as HTMLButtonElement + await act(async () => { row.click() }) + // read:false → the pane explains the model has not read it (never the + // model's cursor, so there is nothing to steal yet). + expect(container.textContent).toContain('模型尚未读取该任务的输出') + unmount() + }) + it('stays compact and functional with many tasks', async () => { const many = Array.from({ length: 60 }, (_, index) => ({ id: `bash-${index + 10}`, diff --git a/tests/tasks-routes.spec.ts b/tests/tasks-routes.spec.ts index 3562f89..211b64c 100644 --- a/tests/tasks-routes.spec.ts +++ b/tests/tasks-routes.spec.ts @@ -1,17 +1,20 @@ /** - * Host route tests for the background-task API ('tasks.output' / 'tasks.kill'): - * caller resolution from the requested session, the non-consuming peek - * contract (capped, never reported), kill semantics, and the optional-service - * downgrade when the deployment lacks the tasks registry. + * Host route tests for the background-task API ('tasks.output' / 'tasks.kill'). + * 'tasks.output' REPLAYS the output the model has read so far from the owner + * session's event log (tool/call + tool/result pairs of task_output calls) — + * the model's cursor is never touched, so nothing is stolen and unread tasks + * report `read: false`. 'tasks.kill' uses the registry's stock kill, fenced + * by the owning session, with a 503 when the registry is absent. */ import { describe, expect, it, vi } from 'vitest' import { buildTasksApi } from '../src/tasks-routes.ts' import { SidebarError } from '../src/wire.ts' -import type { Context } from '../src/context-types.ts' +import type { Context, SidebarSessionEvent } from '../src/context-types.ts' -/** A context whose `get` serves only the tasks/agents faces. */ -function ctxWith(tasks: unknown, agents: unknown): Context { +/** A context whose `get` serves only the tasks/agents faces, with a session store. */ +function ctxWith(sessions: unknown, tasks: unknown, agents: unknown): Context { return { + sessions, get: (key: string) => (key === 'tasks' ? tasks : key === 'agents' ? agents : undefined), } as unknown as Context } @@ -19,49 +22,92 @@ function ctxWith(tasks: unknown, agents: unknown): Context { /** A stub live agent (the fence compares `id` only). */ const agent = (id: string) => ({ id, session: { header: { cwd: '/p' } } }) -describe('tasks.output route', () => { - it('peeks the full output with the live caller of the requested session', () => { - const tasks = { - peek: vi.fn(() => ({ text: 'line1\nline2\n', snapshot: { status: 'running' } })), - } - const agents = { get: vi.fn((id: string) => agent(id)) } - const api = buildTasksApi(ctxWith(tasks, agents), 512 * 1024)! - const value = api.output({ sessionId: 's1', id: 'bash-1' }) +/** One task_output tool/call event. */ +function taskOutputCall(seq: number, callId: string, taskId: string): SidebarSessionEvent { + return { type: 'tool/call', seq, time: seq, data: { name: 'task_output', callId, arguments: JSON.stringify({ task_id: taskId }) } } +} - expect(value).toEqual({ text: 'line1\nline2\n', truncated: false, status: 'running' }) - // The caller is the live agent of the OWNING session, and the registry - // call is a peek (non-consuming by construction of the seam contract). - expect(agents.get).toHaveBeenCalledWith('s1') - expect(tasks.peek).toHaveBeenCalledWith('bash-1', agent('s1')) +/** One tool/result event carrying the finalized text the model received. */ +function taskOutputResult(seq: number, callId: string, text: string, over: { isError?: boolean } = {}): SidebarSessionEvent { + return { + type: 'tool/result', + seq, + time: seq, + data: { + message: { + source: { kind: 'tool', callId }, + content: [{ + type: 'tool-result', + toolCallId: callId, + isError: over.isError === true, + content: [{ type: 'text', text }], + }], + }, + }, + } +} + +/** The owner session with the given event log. */ +function session(events: SidebarSessionEvent[]): unknown { + return { header: { cwd: '/p' }, events } +} + +describe('tasks.output route (event replay)', () => { + it('concatenates the task_output results the model read for the task, oldest first', () => { + const events = [ + taskOutputCall(0, 'c1', 'bash-1'), + taskOutputResult(1, 'c1', 'line1\nline2\n[status: running]'), + taskOutputCall(2, 'c2', 'bash-1'), + taskOutputResult(3, 'c2', 'line3\n[status: completed, exit code: 0]'), + // Another task's reads are ignored. + taskOutputCall(4, 'c3', 'bash-2'), + taskOutputResult(5, 'c3', 'other task output'), + ] + const api = buildTasksApi(ctxWith({ get: () => session(events) }, undefined, undefined), 512 * 1024) + expect(api.output({ sessionId: 's1', id: 'bash-1' })).toEqual({ + text: 'line1\nline2\n[status: running]\nline3\n[status: completed, exit code: 0]', + truncated: false, + read: true, + }) }) - it('carries the terminal detail and caps oversized output with the truncated flag', () => { - const tasks = { - peek: vi.fn(() => ({ - text: 'x'.repeat(10_000), - snapshot: { status: 'completed', detail: 'exit code: 0' }, - })), - } - const api = buildTasksApi(ctxWith(tasks, undefined), 100)! - const value = api.output({ sessionId: 's1', id: 'bash-1' }) + it('skips the controller\'s "(no new output)" deltas and error results', () => { + const events = [ + taskOutputCall(0, 'c1', 'bash-1'), + taskOutputResult(1, 'c1', 'real output\n[status: running]'), + taskOutputCall(2, 'c2', 'bash-1'), + taskOutputResult(3, 'c2', '(no new output)\n[status: running]'), + taskOutputCall(4, 'c3', 'bash-1'), + taskOutputResult(5, 'c3', 'boom', { isError: true }), + ] + const api = buildTasksApi(ctxWith({ get: () => session(events) }, undefined, undefined), 512 * 1024) + expect(api.output({ sessionId: 's1', id: 'bash-1' })).toEqual({ + text: 'real output\n[status: running]', + truncated: false, + read: true, + }) + }) + it('reports read:false until the model reads the task (no registry call at all)', () => { + const events = [taskOutputCall(0, 'c1', 'bash-2')] + const tasks = { kill: vi.fn() } + const api = buildTasksApi(ctxWith({ get: () => session(events) }, tasks, undefined), 100) + expect(api.output({ sessionId: 's1', id: 'bash-1' })).toEqual({ text: '', truncated: false, read: false }) + // The replay never touches the registry — the model's cursor is safe by construction. + expect(tasks.kill).not.toHaveBeenCalled() + }) + + it('caps oversized replays with the truncated flag', () => { + const events = [taskOutputCall(0, 'c1', 'bash-1'), taskOutputResult(1, 'c1', 'x'.repeat(10_000))] + const api = buildTasksApi(ctxWith({ get: () => session(events) }, undefined, undefined), 100) + const value = api.output({ sessionId: 's1', id: 'bash-1' }) expect(value.text).toBe('x'.repeat(100)) expect(value.truncated).toBe(true) - expect(value.status).toBe('completed') - expect(value.detail).toBe('exit code: 0') - }) - - it('maps registry refusals to a 404 task-error (unknown/foreign ids stay indistinguishable)', () => { - const tasks = { peek: vi.fn(() => { throw new Error('task bash-9 belongs to another session') }) } - const api = buildTasksApi(ctxWith(tasks, {}), 100)! - expect(() => api.output({ sessionId: 's1', id: 'bash-9' })).toThrowError( - expect.objectContaining>({ code: 'task-error', status: 404 }), - ) + expect(value.read).toBe(true) }) it('rejects a missing sessionId or id as bad-request', () => { - const tasks = { peek: vi.fn() } - const api = buildTasksApi(ctxWith(tasks, {}), 100)! + const api = buildTasksApi(ctxWith({ get: () => undefined }, undefined, undefined), 100) expect(() => api.output({ id: 'bash-1' })).toThrowError(expect.objectContaining>({ code: 'bad-request' })) expect(() => api.output({ sessionId: 's1' })).toThrowError(expect.objectContaining>({ code: 'bad-request' })) }) @@ -71,7 +117,7 @@ describe('tasks.kill route', () => { it('kills with the forwarded reason and the live caller', () => { const tasks = { kill: vi.fn(() => 'requested' as const) } const agents = { get: vi.fn((id: string) => agent(id)) } - const api = buildTasksApi(ctxWith(tasks, agents), 100)! + const api = buildTasksApi(ctxWith({ get: () => undefined }, tasks, agents), 100) expect(api.kill({ sessionId: 's1', id: 'bash-1', reason: 'user pressed stop' })) .toEqual({ ok: true, outcome: 'requested' }) expect(tasks.kill).toHaveBeenCalledWith('bash-1', agent('s1'), 'user pressed stop') @@ -79,29 +125,24 @@ describe('tasks.kill route', () => { it('defaults the reason when none is supplied', () => { const tasks = { kill: vi.fn(() => 'already-finished' as const) } - const api = buildTasksApi(ctxWith(tasks, undefined), 100)! + const api = buildTasksApi(ctxWith({ get: () => undefined }, tasks, undefined), 100) expect(api.kill({ sessionId: 's1', id: 'bash-1' })).toEqual({ ok: true, outcome: 'already-finished' }) expect(tasks.kill).toHaveBeenCalledWith('bash-1', undefined, 'user requested via sidebar') }) it('maps registry refusals to a 404 task-error', () => { const tasks = { kill: vi.fn(() => { throw new Error('unknown task bash-9') }) } - const api = buildTasksApi(ctxWith(tasks, {}), 100)! + const api = buildTasksApi(ctxWith({ get: () => undefined }, tasks, undefined), 100) expect(() => api.kill({ sessionId: 's1', id: 'bash-9' })).toThrowError( expect.objectContaining>({ code: 'task-error', status: 404 }), ) }) -}) -describe('optional-service downgrade', () => { - it('returns undefined (the caller maps to a 503) when ctx.tasks is absent', () => { - expect(buildTasksApi(ctxWith(undefined, {}), 100)).toBeUndefined() - }) - - it('resolves the caller as undefined when the agents registry is absent', () => { - const tasks = { peek: vi.fn(() => ({ text: '', snapshot: { status: 'running' } })), kill: vi.fn(() => 'requested' as const) } - const api = buildTasksApi(ctxWith(tasks, undefined), 100)! - api.output({ sessionId: 's1', id: 'bash-1' }) - expect(tasks.peek).toHaveBeenCalledWith('bash-1', undefined) + it('degrades to a 503 when the tasks registry is absent (output keeps working)', () => { + const api = buildTasksApi(ctxWith({ get: () => session([]) }, undefined, undefined), 100) + expect(() => api.kill({ sessionId: 's1', id: 'bash-1' })).toThrowError( + expect.objectContaining>({ code: 'task-error', status: 503 }), + ) + expect(api.output({ sessionId: 's1', id: 'bash-1' })).toEqual({ text: '', truncated: false, read: false }) }) })