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
+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 })
})
})