feat(subagent): add explicit child reports

This commit is contained in:
Dudu-0223
2026-07-31 22:45:21 +08:00
committed by Tianyi Cui
parent a1b3bebb61
commit 431fb4b035
76 changed files with 3831 additions and 143 deletions

View File

@@ -51,6 +51,8 @@ const WAIT_POLL_INTERVAL_MS = 10
* specified turn number. `waitForTurnEnd` holds the subprocess open until the
* selected session's latest complete raw-JSONL turn boundary is `turn/end`.
* `waitForTitleAfterTurnEnd` additionally waits for a later durable title.
* `waitForSubagentTurnEnd` applies the same work-turn boundary to one
* background child, whose progress has no ACP update to wait on.
* A standalone `cancel` may also wait for a cwd-relative readiness marker.
* All wait timeouts default to 10s.
*/
@@ -69,6 +71,7 @@ export type InputStep =
| { op: 'waitForFile'; path: string; timeoutMs?: number }
| { op: 'waitForTurnStart'; minimumTurn?: number; timeoutMs?: number }
| { op: 'waitForTurnEnd'; timeoutMs?: number }
| { op: 'waitForSubagentTurnEnd'; child?: number; timeoutMs?: number }
| { op: 'waitForTitleAfterTurnEnd'; timeoutMs?: number }
| { op: 'cancel'; waitForFile?: { path: string; timeoutMs?: number } }
@@ -291,6 +294,7 @@ export async function runScenario(input: InputScript, opts: RunOptions): Promise
(id) => { sessionId = id },
(id, timeoutMs, minimumTurn) => waitForPersistedTurnStart(sessionsRoot, id, timeoutMs, minimumTurn),
(id, timeoutMs) => waitForPersistedTurnEnd(sessionsRoot, id, timeoutMs),
(child, timeoutMs) => waitForPersistedChildTurnEnd(sessionsRoot, child, timeoutMs),
(id, timeoutMs) => waitForPersistedTitleAfterTurnEnd(sessionsRoot, id, timeoutMs),
)
// A permission exchange happens while a step's request is in flight, so
@@ -365,6 +369,7 @@ async function runStep(
setSessionId: (id: string) => void,
waitForTurnStart: (sessionId: string, timeoutMs?: number, minimumTurn?: number) => Promise<void>,
waitForTurnEnd: (sessionId: string, timeoutMs?: number) => Promise<void>,
waitForChildTurnEnd: (child: number, timeoutMs?: number) => Promise<void>,
waitForTitleAfterTurnEnd: (sessionId: string, timeoutMs?: number) => Promise<void>,
): Promise<void> {
switch (step.op) {
@@ -446,6 +451,9 @@ async function runStep(
await waitForTurnEnd(sessionId, step.timeoutMs)
return
}
case 'waitForSubagentTurnEnd':
await waitForChildTurnEnd(step.child ?? 1, step.timeoutMs)
return
case 'waitForTitleAfterTurnEnd': {
const sessionId = getSessionId()
if (sessionId === undefined) throw new Error('snapshot-harness: waitForTitleAfterTurnEnd before newSession')
@@ -519,6 +527,41 @@ async function waitForPersistedTurnEnd(
}, { interval: WAIT_POLL_INTERVAL_MS, timeout: timeoutMs })
}
/**
* Wait until the Nth harvested child Session closes a model work turn.
*
* Harvest order matches `session.1.jsonl`, `session.2.jsonl`, and so on. A
* continuable child appends its descriptor after any inherited history and
* before accepting its first prompt, so only a later request header proves its
* own model work reached a closed turn.
*/
async function waitForPersistedChildTurnEnd(
root: string,
child: number,
timeoutMs = DEFAULT_WAIT_TIMEOUT_MS,
): Promise<void> {
await vi.waitFor(async () => {
const log = (await harvestSessionLogs(root))[child]
if (log === undefined || !latestTurnIsClosed(log.content)
|| !hasRequestHeaderAfterDescriptor(log.content)) {
throw new Error(
`snapshot-harness: subagent child #${child} did not persist a closed work turn within ${timeoutMs}ms`,
)
}
}, { interval: WAIT_POLL_INTERVAL_MS, timeout: timeoutMs })
}
/** Whether a child log contains model work after its own descriptor event. */
function hasRequestHeaderAfterDescriptor(content: string): boolean {
const events = content.slice(0, content.lastIndexOf('\n') + 1)
.split('\n')
.filter(line => line.length > 0)
.map(line => JSON.parse(line) as { type?: unknown })
const descriptor = events.findLastIndex(event => event.type === 'subagent/descriptor')
return descriptor >= 0
&& events.slice(descriptor + 1).some(event => event.type === 'request/header')
}
/** Wait until a complete provider or fallback title record follows the latest closed turn. */
async function waitForPersistedTitleAfterTurnEnd(
root: string,

View File

@@ -40,6 +40,11 @@ const SYSTEM_PROMPT_SNAPSHOT = 'system-prompt.expected.md'
/** The structured tool-schema snapshot beside its owning header pin. */
const TOOL_SCHEMAS_SNAPSHOT = 'tool-schemas.expected.json'
/** Return the dedicated tool-schema sidecar for one child fixture index. */
function childToolSchemasSnapshot(index: number): string {
return `tool-schemas.${index}.expected.json`
}
/** The optional full Windows-native stdout transcript. */
const WINDOWS_STDOUT_SNAPSHOT = 'stdout.expected.windows.jsonl'
@@ -100,6 +105,13 @@ export interface Scenario {
* declare the same {@link expectedHeaderChanges}; meaningless off a pin.
*/
toolSchemasSource?: string
/**
* Child fixture indices whose own schema sequence is pinned separately,
* where `1` names `session.1.jsonl` and
* `tool-schemas.1.expected.json`. The class pin still owns every other
* request-header field.
*/
pinsChildToolSchemas?: readonly number[]
/**
* How many changed `request/header` snapshots this PINNING scenario's primary
* fixture legitimately carries (default 0). Their full prompt text is kept in
@@ -1008,6 +1020,8 @@ export function defineAcpSnapshotSuite(options: SnapshotSuiteOptions): void {
cwdAliases: result.cwdAliases,
}
const childSchemaPins = new Set(scenario.pinsChildToolSchemas ?? [])
// Record writes live model fixtures; keyless refresh writes every comparable replayed
// fixture. Pinning JSONL keeps prefixes but moves prompts and schemas into sidecars.
const scrub = scenario.pinsHeader === true
@@ -1080,6 +1094,18 @@ export function defineAcpSnapshotSuite(options: SnapshotSuiteOptions): void {
claimSharedSnapshot(schemaClaims, schemaPath, scenario.name, toolSchemasSnapshot)
await writeFile(schemaPath, toolSchemasSnapshot)
}
for (const index of childSchemaPins) {
const log = result.sessionLogs[index]
expect(log, `${mode}: no child session log at index ${index} to snapshot schemas from`)
.toBeDefined()
const schemaSets = normalizedToolSchemas((log as HarvestedLog).content, ctx)
expect(schemaSets.length, `${mode}: child ${index} produced no tool schemas to snapshot`)
.toBeGreaterThan(0)
await writeFile(join(dir, childToolSchemasSnapshot(index)), formatToolSchemasSnapshot(
schemaSets[0] as unknown[],
schemaSets.slice(1),
))
}
}
for (const expected of stdoutExpectedVariants(scenario)) {
@@ -1133,7 +1159,14 @@ export function defineAcpSnapshotSuite(options: SnapshotSuiteOptions): void {
header,
pinnedSchemaSets[index] as unknown[],
))
const childPinnedSchemas = new Map<number, unknown[][]>()
for (const index of childSchemaPins) {
const sidecar = await readFile(join(dir, childToolSchemasSnapshot(index)), 'utf8')
const parsed = parseToolSchemasSnapshot(sidecar)
childPinnedSchemas.set(index, [parsed.initial, ...parsed.changes])
}
for (const [logIndex, log] of result.sessionLogs.entries()) {
const childSchemas = childPinnedSchemas.get(logIndex)
const expectedChanges = scenario.pinsHeader === true && logIndex === 0
? scenario.expectedHeaderChanges ?? 0
: 0
@@ -1146,8 +1179,15 @@ export function defineAcpSnapshotSuite(options: SnapshotSuiteOptions): void {
.toBe(headers.length)
expect(schemaSets.length, `session ${log.id}: every request/header must carry an array-valued tools field`)
.toBe(headers.length)
if (childSchemas !== undefined) {
expect(childSchemas.length, `session ${log.id}: ${childToolSchemasSnapshot(logIndex)} has an unexpected tool-schema count`)
.toBe(schemaSets.length)
}
for (const [k, header] of headers.entries()) {
const expected = expectedChanges > 0 ? pinnedHeaders[k] : pinnedHeaders[0]
const classPin = expectedChanges > 0 ? pinnedHeaders[k] : pinnedHeaders[0]
const expected = childSchemas === undefined
? classPin
: { ...classPin as Record<string, unknown>, tools: childSchemas[k] }
expect(header, `session ${log.id}: request/header #${k + 1} diverged from the pinned (${pinningScenario.name}) header`)
.toEqual(expected)
if (expectedChanges === 0) {
@@ -1185,8 +1225,16 @@ export function defineAcpSnapshotSuite(options: SnapshotSuiteOptions): void {
it('every registered scenario has its required fixture files', async () => {
// Every scenario needs input, stdout, a primary session fixture, and matching optional sidecars.
for (const { name, overridden, pinsNativeWindowsStdout } of scenarios) {
for (const { name, overridden, pinsNativeWindowsStdout, pinsChildToolSchemas } of scenarios) {
const dir = join(snapshotsDir, name)
const declaredChildPins = new Set(pinsChildToolSchemas ?? [])
const childSidecars = (await readdir(dir, { withFileTypes: true }))
.filter(entry => entry.isFile())
.map(entry => /^tool-schemas\.([1-9]\d*)\.expected\.json$/.exec(entry.name))
.filter((match): match is RegExpExecArray => match !== null)
.map(match => Number(match[1]))
expect(new Set(childSidecars), `${name}: child tool-schema sidecars must match \`pinsChildToolSchemas\``)
.toEqual(declaredChildPins)
expect(existsSync(join(dir, 'input.json')), `${name}/input.json`).toBe(true)
expect(existsSync(join(dir, 'stdout.expected.jsonl')), `${name}/stdout.expected.jsonl`).toBe(true)
expect(
@@ -1269,6 +1317,26 @@ export function defineAcpSnapshotSuite(options: SnapshotSuiteOptions): void {
assertUniqueSnapshotContents('tool-schema', schemas)
})
it('every declared child tool-schema sidecar is canonical and names a real child', async () => {
for (const scenario of scenarios) {
const pins = scenario.pinsChildToolSchemas ?? []
if (pins.length === 0) continue
const dir = join(snapshotsDir, scenario.name)
const files = await sessionFixtures(dir)
for (const index of pins) {
expect(files[index], `${scenario.name}: child schema pin ${index} must name an existing session.<n>.jsonl fixture`)
.toBeDefined()
const file = childToolSchemasSnapshot(index)
const sidecar = await readFile(join(dir, file), 'utf8')
const parsed = parseToolSchemasSnapshot(sidecar)
expect(sidecar, `${scenario.name}/${file} must use canonical JSON formatting`)
.toBe(formatToolSchemasSnapshot(parsed.initial, parsed.changes))
expect(parsed.initial.length, `${scenario.name}/${file} must pin at least one schema`)
.toBeGreaterThan(0)
}
}
})
it('every committed JSONL has valid tool results and canonical fixture storage', async () => {
// Prompts and schemas always leave JSONL. Header pins retain prefixes;
// every other fixture tokenizes those too. Portable cwd tokens never

View File

@@ -9,7 +9,7 @@
]},
{ "file": "b/child/session.jsonl", "lines": [
{ "type": "session", "id": "eeeeeeee-1111-4222-8333-444444444444", "createdAt": 300, "cwd": "{{CWD}}", "parentSession": "{{SID}}", "delegationDepth": 1 },
{ "type": "request/header", "seq": 0, "time": 6, "data": { "header": { "config": { "model": "fake" }, "system": "SYS PROMPT", "tools": [{ "name": "t1", "description": "D1", "parameters": { "type": "object" } }] }, "reason": "initial" } }
{ "type": "request/header", "seq": 0, "time": 6, "data": { "header": { "config": { "model": "fake" }, "system": "SYS PROMPT", "tools": [{ "name": "child-only", "description": "Child D", "parameters": { "type": "object" } }] }, "reason": "initial" } }
]}
]
}

View File

@@ -0,0 +1,12 @@
{
"initial": [
{
"name": "child-only",
"description": "Child D",
"parameters": {
"type": "object"
}
}
],
"changes": []
}

View File

@@ -742,6 +742,83 @@ describe('runScenario', () => {
)).rejects.toThrow(/did not persist turn\/end within 20ms/)
})
it('waitForSubagentTurnEnd requires a closed child work turn', { timeout: 20_000 }, async () => {
const closed = await scenario({
prompt: 'hang-until-cancel',
persistLogsOnCancel: true,
logs: [
{
file: 'project/main/session.jsonl',
lines: [
{ type: 'session', version: 0, id: '{{SID}}', createdAt: 1, delegationDepth: 0 },
{ type: 'turn/end', seq: 1, time: 2, data: { turn: 1, reason: { kind: 'aborted' } } },
],
},
{
file: 'project/child/session.jsonl',
lines: [
{ type: 'session', version: 0, id: 'child-1', createdAt: 2, parentSession: '{{SID}}', delegationDepth: 1 },
{ type: 'subagent/descriptor', seq: 0, time: 1, data: {} },
{ type: 'turn/start', seq: 1, time: 2, data: { turn: 1 } },
{ type: 'request/header', seq: 2, time: 3, data: { header: {}, reason: 'initial' } },
{ type: 'turn/end', seq: 3, time: 4, data: { turn: 1, reason: { kind: 'completed' } } },
],
},
],
})
const result = await runScenario(
{
steps: [
...boot,
{ op: 'promptAndCancel', text: 'hang' },
{ op: 'waitForSubagentTurnEnd' },
],
},
{ agent: AGENT, mode: 'replay', fixtureFile: closed.fixtureFile },
)
expect(result.sessionLogs[1]?.parentSession).toBe(result.sessionId)
const seedOnly = await scenario({
prompt: 'hang-until-cancel',
persistLogsOnCancel: true,
logs: [
{
file: 'project/main/session.jsonl',
lines: [
{ type: 'session', version: 0, id: '{{SID}}', createdAt: 1, delegationDepth: 0 },
{ type: 'turn/end', seq: 1, time: 2, data: { turn: 1, reason: { kind: 'aborted' } } },
],
},
{
file: 'project/child/session.jsonl',
lines: [
{ type: 'session', version: 0, id: 'child-1', createdAt: 2, parentSession: '{{SID}}', delegationDepth: 1 },
{ type: 'turn/start', seq: 0, time: 1, data: { turn: 1, trigger: { kind: 'message' } } },
{ type: 'request/header', seq: 1, time: 2, data: { header: {}, reason: 'initial' } },
{ type: 'turn/end', seq: 2, time: 3, data: { turn: 1, reason: { kind: 'completed' } } },
{ type: 'subagent/descriptor', seq: 3, time: 4, data: {} },
],
},
],
})
await expect(runScenario(
{
steps: [
...boot,
{ op: 'promptAndCancel', text: 'hang' },
{ op: 'waitForSubagentTurnEnd', timeoutMs: 20 },
],
},
{ agent: AGENT, mode: 'replay', fixtureFile: seedOnly.fixtureFile },
)).rejects.toThrow(/subagent child #1 did not persist a closed work turn within 20ms/)
const missing = await scenario({})
await expect(runScenario(
{ steps: [...boot, { op: 'waitForSubagentTurnEnd', child: 2, timeoutMs: 20 }] },
{ agent: AGENT, mode: 'replay', fixtureFile: missing.fixtureFile },
)).rejects.toThrow(/subagent child #2 did not persist a closed work turn within 20ms/)
})
it('waitForTitleAfterTurnEnd times out when the title precedes the boundary', { timeout: 20_000 }, async () => {
const { fixtureFile } = await scenario({
prompt: 'hang-until-cancel',

View File

@@ -78,6 +78,7 @@ const REPLAY_SCENARIOS: Scenario[] = [
env: { DSH_PERMISSION_MODE: 'never' },
configPath: AGENT.configPath,
workspaceParent: tmpdir(),
pinsChildToolSchemas: [1],
prepareWorkspace: (cwd) => {
writeFileSync(join(cwd, 'seed.txt'), 'prepared at runtime')
},
@@ -89,7 +90,7 @@ const REPLAY_SCENARIOS: Scenario[] = [
const RECORD_SCENARIOS: Scenario[] = [
{ name: 'rec-pin', hasModelTurn: true, recorded: true, pinsHeader: true },
{ name: 'rec-child', hasModelTurn: true, recorded: true },
{ name: 'rec-child', hasModelTurn: true, recorded: true, pinsChildToolSchemas: [1] },
// recorded:false in record mode → registered but skipped (never re-recorded).
{ name: 'rec-skip', hasModelTurn: true, recorded: false, overridden: true },
]
@@ -118,6 +119,7 @@ function staleRefreshFixtures(dir: string): void {
writeFileSync(join(dir, 'plain-turn', 'stdout.expected.jsonl'), 'stale stdout\n')
writeFileSync(join(dir, 'pin-turn', 'system-prompt.expected.md'), 'STALE PROMPT\n')
writeFileSync(join(dir, 'pin-turn', 'tool-schemas.expected.json'), '{"initial":[{"name":"stale"}],"changes":[]}\n')
writeFileSync(join(dir, 'plain-turn', 'tool-schemas.1.expected.json'), '{"initial":[{"name":"stale-child"}],"changes":[]}\n')
const plainBehaviorFile = join(dir, 'plain-turn', 'behavior.json')
const plainBehavior = JSON.parse(readFileSync(plainBehaviorFile, 'utf8')) as Record<string, unknown>
@@ -180,6 +182,9 @@ describe('defineAcpSnapshotSuite: refresh write-back', () => {
const schemas = readFileSync(join(refreshDir, 'pin-turn', 'tool-schemas.expected.json'), 'utf8')
expect(schemas).toContain('"description": "D1"')
expect(schemas).not.toContain('"name":"stale"')
const childSchemas = readFileSync(join(refreshDir, 'plain-turn', 'tool-schemas.1.expected.json'), 'utf8')
expect(childSchemas).toContain('"name": "child-only"')
expect(childSchemas).not.toContain('stale-child')
const pinSession = readFileSync(join(refreshDir, 'pin-turn', 'session.jsonl'), 'utf8')
expect(pinSession).toContain('"cwd":"{{cwd}}"')
@@ -192,6 +197,8 @@ describe('defineAcpSnapshotSuite: record inventory write-back', () => {
expect(fixture).toContain('"type":"session"')
expect(fixture).toContain('"cwd":"{{cwd}}"')
expect(() => readFileSync(join(recordDir, 'rec-child', 'session.2.jsonl'), 'utf8')).toThrow()
expect(readFileSync(join(recordDir, 'rec-child', 'tool-schemas.1.expected.json'), 'utf8'))
.toContain('"name": "t1"')
})
})