Merge remote-tracking branch 'origin/master' into jsonl-packed-chunk-rows

Master removed the stdio demo (#702c8cc30) — accept the deletion; this
branch's packChunks passthrough survives in acp-demo (auto-merged), and
cli-demo/tui-demo arrived from master without one (the follow-up snapshot
PR decides which demos expose the switch). Generated catalogs regenerated
over merged sources; the hand-written session.md durability paragraph
re-weaves this branch's lossless-encoding wording with master's invariant-
companion sentence.
This commit is contained in:
kingwl
2026-07-22 15:46:12 +08:00
1242 changed files with 57560 additions and 15291 deletions

View File

@@ -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) {
@@ -299,7 +300,10 @@ function flushLogsAndExit(): void {
`setTimeout(() => process.stdout.write(${JSON.stringify(`${frame}\n`)}), 50)`,
`setTimeout(() => process.stderr.write(${JSON.stringify('late inherited stderr\n')}), 75)`,
].join(';')
spawn(process.execPath, ['-e', code], { stdio: ['ignore', 1, 2] }).unref()
spawn(process.execPath, ['-e', code], {
detached: true,
stdio: ['ignore', 'inherit', 'inherit'],
}).unref()
}
process.exit(0)
}

View File

@@ -5,7 +5,7 @@ import { delimiter, join } from 'node:path'
import { fileURLToPath } from 'node:url'
import { afterAll, describe, expect, it, vi } from 'vitest'
import { PROTOCOL_VERSION } from '@agentclientprotocol/sdk'
import { runScenario, type AgentUnderTest, type InputStep } from '../src/harness.ts'
import { runScenario, snapshotSpillRoot, type AgentUnderTest, type InputStep } from '../src/harness.ts'
import { launchAcpTestAgent } from '../src/launcher.ts'
const fsControl = vi.hoisted(() => ({ cleanupFailure: undefined as Error | undefined }))
@@ -60,6 +60,24 @@ async function scenario(behavior: object): Promise<{ dir: string; fixtureFile: s
const boot: InputStep[] = [{ op: 'initialize' }, { op: 'newSession' }]
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({})
@@ -134,6 +152,9 @@ describe('runScenario', () => {
update.sessionUpdate === 'agent_message_chunk'
&& update.content.type === 'text'
&& update.content.text === 'late inherited stdout')
// Arm rejection handling before close may exhaust the stream; the later assertion still
// observes the original promise and turns a missing inherited frame into the test failure.
void lateUpdate.catch(() => undefined)
await launched.close()
@@ -171,6 +192,97 @@ describe('runScenario', () => {
}
})
it('preserves the child error when the requested signal sets an exit marker', async () => {
const { dir } = await scenario({})
const launched = launchAcpTestAgent({ agent: AGENT, cwd: dir })
await launched.spawned
const childFailure = Object.assign(new Error('signal failed as the child exited'), { code: 'EPERM' })
const originalKill = launched.child.kill.bind(launched.child)
const kill = vi.spyOn(launched.child, 'kill').mockImplementation((signal) => {
expect(signal).toBe('SIGTERM')
originalKill('SIGKILL')
Object.defineProperty(launched.child, 'signalCode', { configurable: true, enumerable: true, writable: true, value: 'SIGTERM' })
return true
})
try {
launched.child.emit('error', childFailure)
await expect(launched.close('SIGTERM')).rejects.toBe(childFailure)
expect(kill).toHaveBeenCalledOnce()
} finally {
kill.mockRestore()
if (launched.child.exitCode === null && launched.child.signalCode === null) originalKill('SIGKILL')
}
})
it('preserves the child error when the requested signal publishes its exit marker later', async () => {
const { dir } = await scenario({})
const launched = launchAcpTestAgent({ agent: AGENT, cwd: dir })
await launched.spawned
const childFailure = Object.assign(new Error('signal failed before the delayed exit marker'), { code: 'EPERM' })
const originalKill = launched.child.kill.bind(launched.child)
const kill = vi.spyOn(launched.child, 'kill').mockImplementation((signal) => {
expect(signal).toBe('SIGTERM')
setTimeout(() => { originalKill('SIGKILL') }, 10)
return true
})
try {
launched.child.emit('error', childFailure)
await expect(launched.close('SIGTERM')).rejects.toBe(childFailure)
expect(kill).toHaveBeenCalledOnce()
} finally {
kill.mockRestore()
if (launched.child.exitCode === null && launched.child.signalCode === null) originalKill('SIGKILL')
}
})
it('preserves the child error when fallback refusal races with an exit marker', async () => {
const { dir } = await scenario({})
const launched = launchAcpTestAgent({ agent: AGENT, cwd: dir })
await launched.spawned
const childFailure = Object.assign(new Error('signal failed while the child exited'), { code: 'EPERM' })
const originalKill = launched.child.kill.bind(launched.child)
const kill = vi.spyOn(launched.child, 'kill').mockImplementation((signal) => {
if (signal === 'SIGTERM') return true
originalKill('SIGKILL')
Object.defineProperty(launched.child, 'signalCode', { configurable: true, enumerable: true, writable: true, value: 'SIGKILL' })
return false
})
try {
launched.child.emit('error', childFailure)
await expect(launched.close('SIGTERM')).rejects.toBe(childFailure)
expect(kill).toHaveBeenNthCalledWith(1, 'SIGTERM')
expect(kill).toHaveBeenNthCalledWith(2, 'SIGKILL')
} finally {
kill.mockRestore()
if (launched.child.exitCode === null && launched.child.signalCode === null) originalKill('SIGKILL')
}
})
it('preserves the child error after accepted fallback termination drains', async () => {
const { dir } = await scenario({})
const launched = launchAcpTestAgent({ agent: AGENT, cwd: dir })
await launched.spawned
const childFailure = Object.assign(new Error('requested signal failed before fallback'), { code: 'EPERM' })
const originalKill = launched.child.kill.bind(launched.child)
const kill = vi.spyOn(launched.child, 'kill').mockImplementation((signal) => {
if (signal === 'SIGTERM') return true
return originalKill('SIGKILL')
})
try {
launched.child.emit('error', childFailure)
await expect(launched.close('SIGTERM')).rejects.toBe(childFailure)
expect(kill).toHaveBeenNthCalledWith(1, 'SIGTERM')
expect(kill).toHaveBeenNthCalledWith(2, 'SIGKILL')
} finally {
kill.mockRestore()
if (launched.child.exitCode === null && launched.child.signalCode === null) originalKill('SIGKILL')
}
})
it('rejects promptly when fallback termination emits an error', async () => {
const { dir } = await scenario({})
const launched = launchAcpTestAgent({ agent: AGENT, cwd: dir })
@@ -285,7 +397,11 @@ describe('runScenario', () => {
expect(result.sessionLogs[0]?.createdAt).toBe(42)
expect(result.sessionLogs[0]?.content).toContain('turn/start')
// The harvested log embeds the run's REAL temp cwd (template-substituted).
expect(result.sessionLogs[0]?.content).toContain(result.cwd)
// The cwd is JSON-encoded in the log line, so compare the parsed field
// rather than substring-matching a raw path (which breaks when the path
// separator is escaped inside JSON text on Windows).
const sessionLine = result.sessionLogs[0]?.content.split('\n').find(l => l.includes('"type":"session"')) ?? '{}'
expect((JSON.parse(sessionLine) as { cwd?: string }).cwd).toBe(result.cwd)
})
it('forwards override/child fixture paths into the child env and captures stderr', { timeout: 20_000 }, async () => {
@@ -306,7 +422,33 @@ describe('runScenario', () => {
expect(result.stderr).toContain('fake bin booted')
expect(result.rawStdout).toContain('replay.override.json')
// Child paths ride one env var, joined with the platform delimiter.
expect(result.rawStdout).toContain(JSON.stringify(childFiles.join(delimiter)).slice(1, -1))
// Parse the fake bin's env-probe chunk rather than substring-matching a
// JSON-encoded path (the escaping breaks raw-substring compares on Windows).
const envChunk = result.rawStdout.split('\n')
.map(l => l.trim())
.filter(l => l.length > 0)
.map(l => JSON.parse(l) as { params?: { update?: { content?: { text?: string } } } })
.find(f => f.params?.update?.content?.text?.startsWith('env:'))
const env = JSON.parse((envChunk?.params?.update?.content?.text ?? 'env:{}').slice('env:'.length)) as {
childFiles: string | null
}
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 () => {
@@ -334,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',
@@ -443,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/],

View File

@@ -44,6 +44,56 @@ describe('normalizeStdout', () => {
expect(out).not.toContain(ctx.sessionIds[0] as string)
})
it('canonicalizes only cwd-rooted path separators', () => {
const windowsCtx: NormalizeContext = {
sessionIds: [],
cwd: String.raw`C:\Users\runner\AppData\Local\Temp\acp-snapshot`,
}
const raw = JSON.stringify({
jsonrpc: '2.0',
method: 'session/update',
params: {
path: `${windowsCtx.cwd}\\nested\\proof.txt`,
regex: String.raw`\d+\w+`,
command: String.raw`printf "\\n"`,
},
})
const frame = JSON.parse(normalizeStdout(raw, windowsCtx)) as {
params: { path: string; regex: string; command: string }
}
expect(frame.params).toEqual({
path: '{{cwd}}/nested/proof.txt',
regex: String.raw`\d+\w+`,
command: String.raw`printf "\\n"`,
})
})
it('canonicalizes generated relative path fields and text markers without rewriting other text', () => {
const raw = JSON.stringify({
path: String.raw`nested\AGENTS.md`,
content: String.raw`<path>.\nested\task.txt</path>
Additional instructions from: nested\AGENTS.md`,
regex: String.raw`\d+\w+`,
})
const frame = JSON.parse(normalizeStdout(raw, { sessionIds: [], cwd: '/unused' })) as {
path: string
content: string
regex: string
}
expect(frame).toEqual({
path: 'nested/AGENTS.md',
content: '<path>./nested/task.txt</path>\nAdditional instructions from: nested/AGENTS.md',
regex: String.raw`\d+\w+`,
})
})
it('can preserve native cwd-rooted separators for a platform golden', () => {
const windowsCtx: NormalizeContext = { sessionIds: [], cwd: String.raw`C:\work\snapshot` }
const raw = JSON.stringify({ path: `${windowsCtx.cwd}\\nested\\proof.txt` })
const frame = JSON.parse(normalizeStdout(raw, windowsCtx, { cwdPathMode: 'native' })) as { path: string }
expect(frame.path).toBe(String.raw`{{cwd}}\nested\proof.txt`)
})
it('scrubs a stray UUID not in the known list', () => {
const raw = JSON.stringify({ jsonrpc: '2.0', method: 'x', params: { id: 'aaaaaaaa-bbbb-cccc-dddd-eeeeeeeeeeee' } })
expect(normalizeStdout(raw, ctx)).toContain('{{sessionId}}')
@@ -55,6 +105,24 @@ describe('normalizeStdout', () => {
expect(out).not.toContain('"id"')
})
it('stabilizes the timestamp carried by session title updates', () => {
const raw = JSON.stringify({
jsonrpc: '2.0',
method: 'session/update',
params: {
sessionId: ctx.sessionIds[0],
update: {
sessionUpdate: 'session_info_update',
title: 'Stable title',
updatedAt: '2026-07-20T17:03:13.689Z',
},
},
})
const out = normalizeStdout(raw, ctx)
expect(out).toContain('"updatedAt":"{{updatedAt}}"')
expect(out).not.toContain('2026-07-20T17:03:13.689Z')
})
it('throws on a non-JSON stdout line (the purity check)', () => {
const raw = `${JSON.stringify({ jsonrpc: '2.0', id: 1 })}\noops a log leaked\n`
expect(() => normalizeStdout(raw, ctx)).toThrow()
@@ -139,6 +207,48 @@ 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 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', () => {
const windowsCtx: NormalizeContext = { sessionIds: [], cwd: String.raw`C:\work\snapshot` }
const ev = JSON.stringify({
type: 'tool/result', seq: 2, time: 5,
data: { path: `${windowsCtx.cwd}\\nested\\proof.txt` },
})
expect(normalizeSessionLog(`${header({ cwd: windowsCtx.cwd })}\n${ev}\n`, windowsCtx))
.toContain('{{cwd}}/nested/proof.txt')
expect(normalizeSessionLog(`${header({ cwd: windowsCtx.cwd })}\n${ev}\n`, windowsCtx, { cwdPathMode: 'native' }))
.toContain(String.raw`{{cwd}}\\nested\\proof.txt`)
})
it('scrubs the session id in the header', () => {
const out = normalizeSessionLog(`${header({ id: ctx.sessionIds[0] })}\n`, ctx)
expect(out).toContain('{{sessionId}}')

View File

@@ -15,9 +15,11 @@ import {
normalizedToolSchemas,
parseToolSchemasSnapshot,
refreshFixtureReplacements,
scenarioSkipped,
sessionFixtureNames,
restorePinnedToolSchemas,
stabilizeRefreshLog,
stdoutExpectedVariants,
unknownToolCallIds,
} from '../src/suite.ts'
@@ -230,6 +232,48 @@ describe('sessionFixtureNames', () => {
})
})
describe('stdoutExpectedVariants', () => {
const scenario: Scenario = {
name: 'windows-native',
hasModelTurn: true,
recorded: true,
pinsNativeWindowsStdout: true,
}
it('adds the native sidecar after the shared golden on Windows', () => {
expect(stdoutExpectedVariants(scenario, 'win32')).toEqual([
{ file: 'stdout.expected.jsonl', cwdPathMode: 'canonical' },
{ file: 'stdout.expected.windows.jsonl', cwdPathMode: 'native' },
])
})
it('keeps only the shared golden on other platforms or without the declaration', () => {
expect(stdoutExpectedVariants(scenario, 'linux')).toEqual([
{ file: 'stdout.expected.jsonl', cwdPathMode: 'canonical' },
])
expect(stdoutExpectedVariants({ ...scenario, pinsNativeWindowsStdout: false }, 'win32')).toEqual([
{ file: 'stdout.expected.jsonl', cwdPathMode: 'canonical' },
])
})
})
describe('scenarioSkipped', () => {
const authored: Scenario = { name: 'authored', hasModelTurn: true, recorded: false }
const posix: Scenario = { name: 'posix-cancel', hasModelTurn: true, recorded: false, posixOnly: true }
it('skips authored scenarios only while recording', () => {
expect(scenarioSkipped(authored, true, 'linux')).toBe(true)
expect(scenarioSkipped(authored, false, 'linux')).toBe(false)
})
it('skips posixOnly scenarios on Windows and nowhere else', () => {
expect(scenarioSkipped(posix, false, 'win32')).toBe(true)
expect(scenarioSkipped(posix, false, 'linux')).toBe(false)
expect(scenarioSkipped(posix, false, 'darwin')).toBe(false)
expect(scenarioSkipped(authored, false, 'win32')).toBe(false)
})
})
describe('fixtureContext', () => {
it('reads the fixture header id and cwd', () => {
const ctx = fixtureContext('{"type":"session","id":"abc","cwd":"/rec"}\n{"type":"turn/start"}\n')
@@ -407,6 +451,36 @@ describe('refreshFixtureReplacements', () => {
})
describe('stabilizeRefreshLog', () => {
it('aligns volatile times across a newly inserted log event', () => {
const fresh = [
'{"type":"session","id":"same","createdAt":200}',
'{"type":"turn/start","seq":0,"time":21}',
'{"type":"user/message","seq":1,"time":22}',
'{"type":"session/title","seq":2,"time":999}',
'{"type":"step/start","seq":3,"time":1000}',
'{"type":"request/header","seq":4,"time":1001}',
'',
].join('\n')
const existing = [
'{"type":"session","id":"same","createdAt":100}',
'{"type":"turn/start","seq":0,"time":11}',
'{"type":"user/message","seq":1,"time":12}',
'{"type":"step/start","seq":2,"time":13}',
'{"type":"request/header","seq":3,"time":14}',
'',
].join('\n')
expect(stabilizeRefreshLog(fresh, existing, [])).toBe([
'{"type":"session","id":"same","createdAt":100}',
'{"type":"turn/start","seq":0,"time":11}',
'{"type":"user/message","seq":1,"time":12}',
'{"type":"session/title","seq":2,"time":12}',
'{"type":"step/start","seq":3,"time":13}',
'{"type":"request/header","seq":4,"time":14}',
'',
].join('\n'))
})
it('keeps volatile fixture fields while preserving fresh meaningful payloads', () => {
const fresh = [
'{"type":"session","id":"new-child","createdAt":200,"cwd":"/new","parentSession":"new-parent","seedLength":1}',