fix: 遵守仓库硬约束(禁止修改 DSH 源码)——tasks.output 改为会话事件回放

- 回退对 harness peek 缝隙的依赖:输出查看不再调用注册表,改为从属主
  会话事件日志回放 task_output 的 tool/call+tool/result 对(模型已读内容)
- read:false 时面板提示「模型尚未读取」;'(no new output)' 增量与错误
  结果过滤;截断上限不变;kill 走注册表原生 API(缺席时 503)
- AGENTS.md 新增 §0 硬约束:禁止修改 DSH 源码、挂载只走 cordis.patch.yml
  + profile 机制;设计文档同步实施偏差
- 测试:tasks-routes 重写为回放语义(拼接/过滤/read 标志/截断/503),
  视图新增 read:false 提示用例;全量 369 通过
This commit is contained in:
Menghuan1918
2026-08-12 23:04:30 +08:00
parent 8c521be26e
commit b90251cce5
10 changed files with 304 additions and 166 deletions
+8
View File
@@ -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>/`),插件永远作为独立包被 profile 引用,不反向侵入 DSH。
- 需要 harness 没有的能力时,用 DSH **现成的只读/公开 API** 或插件自有路由实现(参考 §7 的 `tasks.output` 事件回放:读会话事件日志而非动注册表);如果确实做不到,先向用户说明取舍,而不是直接改 DSH。
---
## 1. 服务定位
- **服务名**:`betterSidebar`(即 `ctx.betterSidebar`)
@@ -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)`。
+14 -11
View File
@@ -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
? <pre ref={preRef} className={css.tasksPanePre}>{state.text}</pre>
: <div className={css.tasksPaneHint}>{t('taskNoOutput')}</div>}
: state.read
? <div className={css.tasksPaneHint}>{t('taskNoOutput')}</div>
: <div className={css.tasksPaneHint}>{t('taskNotReadYet')}</div>}
{state.truncated && <div className={css.tasksPaneHint}>{t('taskOutputTruncated')}</div>}
</>
)}
@@ -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']
+12 -8
View File
@@ -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<T>(method: string, payload: Record<string, unknown>, signal?: AbortSignal): Promise<T> {
@@ -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<TaskOutputResult>('tasks.output', scopePayload(scope, { id }), signal),
+2
View File
@@ -194,6 +194,7 @@ const zh = {
taskViewOutput: '查看输出',
taskHideOutput: '收起输出',
taskNoOutput: '暂无输出',
taskNotReadYet: '模型尚未读取该任务的输出,读取后会显示在这里',
taskOutputTruncated: '输出过长,已截断显示',
taskOutputError: '输出读取失败',
taskKill: '终止',
@@ -390,6 +391,7 @@ const en: Record<keyof typeof zh, string> = {
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',
+9 -9
View File
@@ -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'
}
+13 -22
View File
@@ -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
+101 -39
View File
@@ -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<string, string>()
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
+30 -2
View File
@@ -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}`,
+96 -55
View File
@@ -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<Partial<SidebarError>>({ 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<Partial<SidebarError>>({ code: 'bad-request' }))
expect(() => api.output({ sessionId: 's1' })).toThrowError(expect.objectContaining<Partial<SidebarError>>({ 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<Partial<SidebarError>>({ 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<Partial<SidebarError>>({ code: 'task-error', status: 503 }),
)
expect(api.output({ sessionId: 's1', id: 'bash-1' })).toEqual({ text: '', truncated: false, read: false })
})
})