Merge origin/master into worktree-windows-runtime

# Conflicts:
#	.agents/notes/implemented/simplification/2026-07-04-fold-stdio-ui-helper.md
#	packages/support/acp-snapshot/src/harness.ts
#	packages/support/acp-snapshot/src/normalize.ts
#	packages/support/acp-snapshot/tests/harness.spec.ts
#	packages/support/acp-snapshot/tests/normalize.spec.ts
This commit is contained in:
Tianyi Cui
2026-07-21 17:14:44 +08:00
263 files changed
+12997 -276

No files matched your search

+28 -5
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'
@@ -150,12 +154,20 @@ export interface RunOptions {
}
/**
* Return a fixed-length spill root across POSIX and Windows after Windows adds its drive prefix.
* Derive one stable, fixed-length spill root owned by this scenario.
* Windows uses a two-character-shorter root because drive resolution adds its drive prefix.
* @param fixtureFile - The scenario fixture whose parent directory provides the stable identity.
* @param platform - the host platform, injectable for unit coverage.
* @returns the root-relative snapshot spill directory.
*/
export function snapshotSpillRoot(platform: NodeJS.Platform = process.platform): string {
return platform === 'win32' ? '/t/dsh-acp-snapshot-spill' : '/tmp/dsh-acp-snapshot-spill'
export function snapshotSpillRoot(
fixtureFile: string,
platform: NodeJS.Platform = process.platform,
): string {
const scenario = basename(dirname(fixtureFile))
const key = createHash('sha256').update(scenario).digest('hex').slice(0, 9)
const root = platform === 'win32' ? '/t' : '/tmp'
return `${root}/dsh-acp-snap-${key}`
}
/**
@@ -172,7 +184,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 = snapshotSpillRoot()
// Scenario ownership also matters: replay runs concurrently, and one teardown
// must never delete another scenario's in-flight full-output recovery file.
const spillRoot = snapshotSpillRoot(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
@@ -340,6 +354,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')
@@ -25,7 +25,7 @@ const LOCAL_SPILL_PATH_RE = new RegExp(
'g',
)
const SNAPSHOT_SPILL_PATH_RE = new RegExp(
String.raw`(?:[A-Za-z]:)?[\\/](?:tmp|t)[\\/]dsh-acp-snapshot-spill[\\/]session-[0-9a-f]{12}[\\/][0-9a-f]{12}-([A-Za-z0-9._~-]+?)`
String.raw`(?:[A-Za-z]:)?[\\/](?:tmp|t)[\\/](?: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,11 +60,24 @@ async function scenario(behavior: object): Promise<{ dir: string; fixtureFile: s
const boot: InputStep[] = [{ op: 'initialize' }, { op: 'newSession' }]
it('keeps the resolved snapshot spill root length stable across platforms', () => {
expect(snapshotSpillRoot('linux')).toBe('/tmp/dsh-acp-snapshot-spill')
expect(snapshotSpillRoot('win32')).toBe('/t/dsh-acp-snapshot-spill')
it('keeps scenario-owned snapshot spill root length stable across platforms', () => {
const fixtureFile = '/fixtures/scenario/session.jsonl'
const posix = snapshotSpillRoot(fixtureFile, 'linux')
const windows = snapshotSpillRoot(fixtureFile, 'win32')
expect(posix).toMatch(/^\/tmp\/dsh-acp-snap-[0-9a-f]{9}$/)
expect(windows).toMatch(/^\/t\/dsh-acp-snap-[0-9a-f]{9}$/)
expect(windows.length + 2).toBe(posix.length)
})
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({})
@@ -422,6 +435,22 @@ describe('runScenario', () => {
expect(env.childFiles).toBe(childFiles.join(delimiter))
})
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).toEqual([
snapshotSpillRoot(first.fixtureFile),
snapshotSpillRoot(second.fixtureFile),
])
})
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')
@@ -447,6 +476,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',
@@ -556,6 +600,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/],
@@ -189,19 +189,34 @@ describe('normalizeSessionLog', () => {
expect(out).not.toContain('/tmp/dsh-acp-snapshot-spill')
})
it('scrubs fixed snapshot spill paths with Windows drive and separators', () => {
it('scrubs scenario-owned snapshot spill paths', () => {
const ev = JSON.stringify({
type: 'tool/result', seq: 2, time: 5,
data: {
content: [{
type: 'text',
text: String.raw`Full formatted result stored at: C:\t\dsh-acp-snapshot-spill\session-c22bc3f1d2af\8a7b6c5d4e3f-bash.txt. Use read with offset/limit, or grep this path to search within it.`,
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('C:\\t\\dsh-acp-snapshot-spill')
expect(out).not.toContain('/tmp/dsh-acp-snap-012345678')
})
it('scrubs scenario-owned snapshot spill paths with Windows drive and separators', () => {
const ev = JSON.stringify({
type: 'tool/result', seq: 2, time: 5,
data: {
content: [{
type: 'text',
text: String.raw`Full formatted result stored at: C:\t\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('C:\\t\\dsh-acp-snap-012345678')
})
it('shares cwd-rooted path handling with stdout normalization', () => {
+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,
@@ -936,6 +936,7 @@ describe('scoped-dispatch invariants', () => {
['agent/turn-stop', [agent, 1]],
['agent/error', [agent, 1, 0, new Error('x')]],
['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"
},