Merge remote-tracking branch 'origin/codex/enforce-tool-cancellation' into worktree/explicit-turn-signal

# Conflicts:
#	docs/architecture.md
#	docs/cordis-catalog/events.md
#	docs/core-data-structures/core.md
#	docs/event-producer-consumer.md
#	packages/core/agent-loop/README.md
#	packages/core/agent-loop/src/agent.ts
#	packages/core/agent/README.md
#	packages/core/agent/src/types.ts
#	packages/ui/acp/src/index.ts
#	packages/ui/tui/src/index.ts
#	packages/ui/tui/tests/harness.ts
This commit is contained in:
Tianyi Cui
2026-07-21 12:48:46 +08:00
267 files changed
+13024 -278

No files matched your search

+24 -2
View File
@@ -18,8 +18,9 @@
import { cp, mkdtemp, readFile, readdir, rm } from 'node:fs/promises'
import { existsSync } from 'node:fs'
import { createHash } from 'node:crypto'
import { tmpdir } from 'node:os'
import { join, delimiter } from 'node:path'
import { basename, dirname, join, delimiter } from 'node:path'
import {
ClientSideConnection,
PROTOCOL_VERSION,
@@ -41,12 +42,15 @@ export type { AgentUnderTest } from './launcher.ts'
* the client observes the selected update (`agent_message_chunk` by default),
* then cancels and awaits completion. A named `waitForToolCallUpdate` keeps the
* step open for a terminal tool update that may follow the prompt response.
* `promptAndWaitForAgentMessage` arms an exact text-chunk waiter before sending
* the prompt, then keeps the application live until that later update arrives.
*/
export type InputStep =
| { op: 'initialize'; terminalOutput?: boolean }
| { op: 'newSession' }
| { op: 'newSessionExpectError'; additionalDirectories?: string[] }
| { op: 'prompt'; text: string }
| { op: 'promptAndWaitForAgentMessage'; text: string; waitForText: string }
| { op: 'promptExpectError'; text: string }
| {
op: 'promptAndCancel'
@@ -149,6 +153,13 @@ export interface RunOptions {
configPath?: string
}
/** Derive one stable, fixed-length spill root owned by this scenario. */
function scenarioSpillRoot(fixtureFile: string): string {
const scenario = basename(dirname(fixtureFile))
const key = createHash('sha256').update(scenario).digest('hex').slice(0, 9)
return `/tmp/dsh-acp-snap-${key}`
}
/**
* Run a scenario end-to-end against a freshly-spawned subprocess. Owns the
* child and its temp dirs; always tears them down. Returns the captured stdout
@@ -163,7 +174,9 @@ export async function runScenario(input: InputScript, opts: RunOptions): Promise
const sessionsRoot = await mkdtemp(join(tmpdir(), 'acp-snap-sessions-'))
// Fixed path length: spill-policy budgets the preview against the REAL path
// before stdout normalization, so tmpdir() length differences churn expected outputs.
const spillRoot = '/tmp/dsh-acp-snapshot-spill'
// Scenario ownership also matters: replay runs concurrently, and one teardown
// must never delete another scenario's in-flight full-output recovery file.
const spillRoot = scenarioSpillRoot(opts.fixtureFile)
// Everything past the temp-dir creation is followed by failure-safe cleanup,
// so a failure in workspace seeding, spawn, or any step never leaks resources.
let launched: LaunchedAcpTestAgent | undefined
@@ -331,6 +344,15 @@ async function runStep(
await client.prompt({ sessionId, prompt: [{ type: 'text', text: step.text }] })
return
}
case 'promptAndWaitForAgentMessage': {
const sessionId = getSessionId()
if (sessionId === undefined) throw new Error('snapshot-harness: promptAndWaitForAgentMessage before newSession')
const updateDone = waitForUpdate(update => update.sessionUpdate === 'agent_message_chunk'
&& update.content.type === 'text' && update.content.text === step.waitForText)
await client.prompt({ sessionId, prompt: [{ type: 'text', text: step.text }] })
await updateDone
return
}
case 'promptExpectError': {
const sessionId = getSessionId()
if (sessionId === undefined) throw new Error('snapshot-harness: promptExpectError before newSession')
@@ -20,7 +20,7 @@ const LOCAL_SPILL_PATH_RE = new RegExp(
'g',
)
const SNAPSHOT_SPILL_PATH_RE = new RegExp(
String.raw`/tmp/dsh-acp-snapshot-spill/session-[0-9a-f]{12}/[0-9a-f]{12}-([A-Za-z0-9._~-]+?)`
String.raw`/tmp/(?:dsh-acp-snap-[0-9a-f]{9}|dsh-acp-snapshot-spill)/session-[0-9a-f]{12}/[0-9a-f]{12}-([A-Za-z0-9._~-]+?)`
+ String.raw`(?=\. Use read with offset/limit|[\s)]|$)`,
'g',
)
@@ -152,6 +152,7 @@ async function handlePrompt(id: number | string): Promise<void> {
mode: process.env.DSH_SNAPSHOT,
override: process.env.DSH_SNAPSHOT_OVERRIDE ?? null,
childFiles: process.env.DSH_SNAPSHOT_CHILD_FILES ?? null,
spillRoot: process.env.DSH_SNAPSHOT_SPILL_ROOT ?? null,
})}`)
}
if (behavior.echoWorkspace === true) {
@@ -60,6 +60,15 @@ async function scenario(behavior: object): Promise<{ dir: string; fixtureFile: s
const boot: InputStep[] = [{ op: 'initialize' }, { op: 'newSession' }]
function environmentEcho(rawStdout: string): Record<string, unknown> {
const frames = rawStdout.trim().split('\n')
.map(line => JSON.parse(line) as { params?: { update?: { content?: { text?: unknown } } } })
const text = frames.map(frame => frame.params?.update?.content?.text)
.find(value => typeof value === 'string' && value.startsWith('env:'))
if (typeof text !== 'string') throw new Error('fake ACP agent did not echo its environment')
return JSON.parse(text.slice('env:'.length)) as Record<string, unknown>
}
describe('runScenario', () => {
it('surfaces an asynchronous child spawn failure through startup and close', async () => {
const { dir } = await scenario({})
@@ -309,6 +318,19 @@ describe('runScenario', () => {
expect(result.rawStdout).toContain(JSON.stringify(childFiles.join(delimiter)).slice(1, -1))
})
it('gives concurrent scenarios distinct equal-length spill roots', { timeout: 20_000 }, async () => {
const [first, second] = await Promise.all([scenario({ echoEnv: true }), scenario({ echoEnv: true })])
const results = await Promise.all([first, second].map(({ fixtureFile }) => runScenario(
{ steps: [...boot, { op: 'prompt', text: 'env?' }] },
{ agent: AGENT, mode: 'replay', fixtureFile },
)))
const roots = results.map(result => environmentEcho(result.rawStdout).spillRoot)
expect(roots.every(root => typeof root === 'string')).toBe(true)
expect(new Set(roots).size).toBe(2)
expect((roots[0] as string).length).toBe((roots[1] as string).length)
expect((roots[0] as string).length).toBe('/tmp/dsh-acp-snapshot-spill'.length)
})
it('seeds the workspace dir into the temp cwd before the run', { timeout: 20_000 }, async () => {
const { dir, fixtureFile } = await scenario({ echoWorkspace: true })
const workspaceDir = join(dir, 'workspace')
@@ -334,6 +356,21 @@ describe('runScenario', () => {
expect(result.rawStdout.indexOf('thinking about it')).toBeLessThan(result.rawStdout.indexOf('cancelled'))
})
it('promptAndWaitForAgentMessage keeps the app live through a matching later update', { timeout: 20_000 }, async () => {
const { fixtureFile } = await scenario({ prompt: 'respond' })
const result = await runScenario(
{
steps: [...boot, {
op: 'promptAndWaitForAgentMessage',
text: 'go',
waitForText: 'thinking about it',
}],
},
{ agent: AGENT, mode: 'replay', fixtureFile },
)
expect(result.rawStdout).toContain('thinking about it')
})
it('promptAndCancel can bracket cancellation with tool-call updates', { timeout: 20_000 }, async () => {
const { fixtureFile } = await scenario({
prompt: 'hang-until-cancel',
@@ -443,6 +480,7 @@ describe('runScenario', () => {
it.each([
[{ op: 'prompt', text: 'x' }, /prompt before newSession/],
[{ op: 'promptAndWaitForAgentMessage', text: 'x', waitForText: 'later' }, /promptAndWaitForAgentMessage before newSession/],
[{ op: 'promptExpectError', text: 'x' }, /promptExpectError before newSession/],
[{ op: 'promptAndCancel', text: 'x' }, /promptAndCancel before newSession/],
[{ op: 'cancel' }, /cancel before newSession/],
@@ -139,6 +139,21 @@ describe('normalizeSessionLog', () => {
expect(out).not.toContain('/tmp/dsh-acp-snapshot-spill')
})
it('scrubs scenario-owned snapshot spill paths', () => {
const ev = JSON.stringify({
type: 'tool/result', seq: 2, time: 5,
data: {
content: [{
type: 'text',
text: 'Full formatted result stored at: /tmp/dsh-acp-snap-012345678/session-c22bc3f1d2af/8a7b6c5d4e3f-bash.txt. Use read with offset/limit, or grep this path to search within it.',
}],
},
})
const out = normalizeSessionLog(`${header({ cwd: ctx.cwd })}\n${ev}\n`, ctx)
expect(out).toContain('{{spillLocator:bash.txt}}')
expect(out).not.toContain('/tmp/dsh-acp-snap-012345678')
})
it('scrubs the session id in the header', () => {
const out = normalizeSessionLog(`${header({ id: ctx.sessionIds[0] })}\n`, ctx)
expect(out).toContain('{{sessionId}}')
+1
View File
@@ -30,6 +30,7 @@
},
"devDependencies": {
"@deepseek-ai/dsh-agent": "workspace:^",
"@deepseek-ai/dsh-goal": "workspace:^",
"@deepseek-ai/dsh-llm": "workspace:^",
"@deepseek-ai/dsh-scope": "workspace:^",
"@deepseek-ai/dsh-session": "workspace:^",
@@ -8,6 +8,7 @@
import type { Events } from 'cordis'
import type { Scoped } from '@deepseek-ai/dsh-scope'
import type {} from '@deepseek-ai/dsh-agent'
import type {} from '@deepseek-ai/dsh-goal'
import type {} from '@deepseek-ai/dsh-session'
import type {} from '@deepseek-ai/dsh-subagent'
import type {} from '@deepseek-ai/dsh-system-prompt'
@@ -27,6 +28,7 @@ function adapt<K extends ScopedEventName>(
}
const scopedSubjectResolvers = Object.freeze({
'agent/cancel-requested': adapt<'agent/cancel-requested'>(args => args[0]),
'agent/created': adapt<'agent/created'>(args => args[0]),
'agent/disposed': adapt<'agent/disposed'>(args => args[0]),
'agent/error': adapt<'agent/error'>(args => args[0]),
@@ -43,6 +45,7 @@ const scopedSubjectResolvers = Object.freeze({
'agent/turn-continuation': adapt<'agent/turn-continuation'>(args => args[0]),
'agent/turn-stop': adapt<'agent/turn-stop'>(args => args[0]),
'approval/request': adapt<'approval/request'>(args => args[0].agent),
'goal/changed': adapt<'goal/changed'>(args => args[0]),
'session/created': null,
'session/disposed': null,
'session/event': null,
@@ -933,6 +933,7 @@ describe('scoped-dispatch invariants', () => {
'agent/disposed': [agent],
'agent/status': [agent, 'idle'],
'agent/queued': [agent, [], { source: { kind: 'user' }, steering: false }],
'agent/cancel-requested': [agent, { kind: 'user' }],
'agent/session-start': [agent, 'startup'],
'agent/pre-step': [agent, 1, 1, signal],
'agent/post-step': [agent, 1, 1, signal],
@@ -948,6 +949,7 @@ describe('scoped-dispatch invariants', () => {
const rows: [string, unknown[]][] = [
...Object.entries(agentRows),
['approval/request', [{ agent, toolName: 'echo' }, () => Promise.resolve('unavailable')]],
['goal/changed', [agent, { operation: 'create', ref: { id: 'goal-a', revision: 1 } }]],
['tools/pre-execute', [{ callId: 'c', name: 't', arguments: {}, agent }, () => Promise.resolve({ kind: 'allow' })]],
['tools/execute', [{ callId: 'c', name: 't', arguments: {}, agent }, () => Promise.resolve({ content: [], isError: false })]],
['tools/post-execute', [{ callId: 'c', name: 't', arguments: {}, agent }, { content: [], isError: false }, () => Promise.resolve({ kind: 'accept' })]],
@@ -23,6 +23,9 @@
{
"path": "../../core/agent"
},
{
"path": "../../goal/goal"
},
{
"path": "../../core/scope"
},