feat(subagent): make final reports the continuable return contract

A continuable child could finish without giving its parent a usable
result: the report tool was described as optional and its default
delivery stayed quiet. Install the child-scoped report obligation (tool
description plus the tool:report prompt section), default reportDelivery
to wakeup, bind shipped subagent_fork delegations to one-shot so fork
keeps its prefix reuse, and extend the ACP snapshot harness to pin child
system prompts alongside child tool schemas.
This commit is contained in:
Hypatia May
2026-08-11 11:50:39 +08:00
parent 6d3cabf295
commit 76cf6cbd0b
79 changed files with 916 additions and 326 deletions

View File

@@ -46,6 +46,11 @@ function childToolSchemasSnapshot(index: number): string {
return `tool-schemas.${index}.expected.json`
}
/** Return the dedicated system-prompt sidecar for one child fixture index. */
function childSystemPromptSnapshot(index: number): string {
return `system-prompt.${index}.expected.md`
}
/** The optional full Windows-native stdout transcript. */
const WINDOWS_STDOUT_SNAPSHOT = 'stdout.expected.windows.jsonl'
@@ -116,6 +121,13 @@ export interface Scenario {
* request-header field.
*/
pinsChildToolSchemas?: readonly number[]
/**
* Child fixture indices whose own system prompt is pinned separately, where
* `1` names `session.1.jsonl` and `system-prompt.1.expected.md`. A child
* scope that installs its own prompt section (the continuable `report`
* guidance) composes a prompt the class pin cannot describe.
*/
pinsChildSystemPrompts?: 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
@@ -491,6 +503,18 @@ export function formatSystemPromptSnapshot(
return snapshot
}
/**
* Reject a child prompt sidecar that cannot own distinct, canonical prompt text.
* @param sidecar - committed child prompt snapshot.
* @param classPin - initial prompt snapshot owned by the scenario's header class.
* @param label - repository-relative fixture label for diagnostics.
*/
export function assertChildSystemPromptSnapshot(sidecar: string, classPin: string, label: string): void {
if (sidecar.trim().length === 0) throw new Error(`${label} must pin a non-empty prompt`)
if (!sidecar.endsWith('\n')) throw new Error(`${label} must end in a newline`)
if (sidecar === classPin) throw new Error(`${label} must differ from its class pin`)
}
/** Return the initial-prompt portion of a possibly multi-header snapshot. */
function initialSystemPromptSnapshot(snapshot: string): string {
const marker = snapshot.indexOf('\n<!-- request/header change ')
@@ -1192,6 +1216,7 @@ export function defineAcpSnapshotSuite(options: SnapshotSuiteOptions): void {
}
const childSchemaPins = new Set(scenario.pinsChildToolSchemas ?? [])
const childPromptPins = new Set(scenario.pinsChildSystemPrompts ?? [])
// Record writes live model fixtures; keyless refresh writes every comparable replayed
// fixture. Pinning JSONL keeps prefixes but moves prompts and schemas into sidecars.
@@ -1281,6 +1306,18 @@ export function defineAcpSnapshotSuite(options: SnapshotSuiteOptions): void {
schemaSets.slice(1),
))
}
for (const index of childPromptPins) {
const log = result.sessionLogs[index]
expect(log, `${mode}: no child session log at index ${index} to snapshot a prompt from`)
.toBeDefined()
const prompts = normalizedSystemPrompts((log as HarvestedLog).content, ctx)
expect(prompts.length, `${mode}: child ${index} produced no system prompt to snapshot`)
.toBeGreaterThan(0)
await writeFile(
join(dir, childSystemPromptSnapshot(index)),
formatSystemPromptSnapshot(prompts[0] as string),
)
}
}
for (const expected of stdoutExpectedVariants(scenario)) {
@@ -1340,6 +1377,13 @@ export function defineAcpSnapshotSuite(options: SnapshotSuiteOptions): void {
const parsed = parseToolSchemasSnapshot(sidecar)
childPinnedSchemas.set(index, [parsed.initial, ...parsed.changes])
}
const childPinnedPrompts = new Map<number, string>()
for (const index of childPromptPins) {
childPinnedPrompts.set(
index,
await readFile(join(dir, childSystemPromptSnapshot(index)), 'utf8'),
)
}
for (const [logIndex, log] of result.sessionLogs.entries()) {
const childSchemas = childPinnedSchemas.get(logIndex)
const expectedChanges = scenario.pinsHeader === true && logIndex === 0
@@ -1366,8 +1410,14 @@ export function defineAcpSnapshotSuite(options: SnapshotSuiteOptions): void {
expect(header, `session ${log.id}: request/header #${k + 1} diverged from the pinned (${pinningScenario.name}) header`)
.toEqual(expected)
if (expectedChanges === 0) {
expect(formatSystemPromptSnapshot(prompts[k] as string), `session ${log.id}: initial system prompt #${k + 1} diverged from ${promptSource.name}/${SYSTEM_PROMPT_SNAPSHOT}`)
.toEqual(initialPromptSnapshot)
// A pinned child owns its whole prompt: its scope-local sections
// are exactly what the class pin cannot describe.
const childPrompt = childPinnedPrompts.get(logIndex)
const promptOrigin = childPrompt === undefined
? `${promptSource.name}/${SYSTEM_PROMPT_SNAPSHOT}`
: childSystemPromptSnapshot(logIndex)
expect(formatSystemPromptSnapshot(prompts[k] as string), `session ${log.id}: initial system prompt #${k + 1} diverged from ${promptOrigin}`)
.toEqual(childPrompt ?? initialPromptSnapshot)
}
}
if (scenario.pinsHeader === true && logIndex === 0) {
@@ -1400,16 +1450,19 @@ 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, pinsChildToolSchemas } of scenarios) {
for (const { name, overridden, pinsNativeWindowsStdout, pinsChildToolSchemas, pinsChildSystemPrompts } of scenarios) {
const dir = join(snapshotsDir, name)
const declaredChildPins = new Set(pinsChildToolSchemas ?? [])
const childSidecars = (await readdir(dir, { withFileTypes: true }))
const files = (await readdir(dir, { withFileTypes: true }))
.filter(entry => entry.isFile())
.map(entry => /^tool-schemas\.([1-9]\d*)\.expected\.json$/.exec(entry.name))
.map(entry => entry.name)
const childIndices = (pattern: RegExp): Set<number> => new Set(files
.map(file => pattern.exec(file))
.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)
.map(match => Number(match[1])))
expect(childIndices(/^tool-schemas\.([1-9]\d*)\.expected\.json$/), `${name}: child tool-schema sidecars must match \`pinsChildToolSchemas\``)
.toEqual(new Set(pinsChildToolSchemas ?? []))
expect(childIndices(/^system-prompt\.([1-9]\d*)\.expected\.md$/), `${name}: child system-prompt sidecars must match \`pinsChildSystemPrompts\``)
.toEqual(new Set(pinsChildSystemPrompts ?? []))
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(
@@ -1492,13 +1545,11 @@ 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 () => {
it('every declared child 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) {
for (const index of scenario.pinsChildToolSchemas ?? []) {
expect(files[index], `${scenario.name}: child schema pin ${index} must name an existing session.<n>.jsonl fixture`)
.toBeDefined()
const file = childToolSchemasSnapshot(index)
@@ -1509,6 +1560,16 @@ export function defineAcpSnapshotSuite(options: SnapshotSuiteOptions): void {
expect(parsed.initial.length, `${scenario.name}/${file} must pin at least one schema`)
.toBeGreaterThan(0)
}
for (const index of scenario.pinsChildSystemPrompts ?? []) {
expect(files[index], `${scenario.name}: child prompt pin ${index} must name an existing session.<n>.jsonl fixture`)
.toBeDefined()
const file = childSystemPromptSnapshot(index)
const sidecar = await readFile(join(dir, file), 'utf8')
/* v8 ignore next -- registration guarantees every scenario class has resolved sources. */
const promptSource = promptSourceByClass.get(classOf(scenario)) ?? scenario
const classPin = await readFile(join(snapshotsDir, promptSource.name, SYSTEM_PROMPT_SNAPSHOT), 'utf8')
assertChildSystemPromptSnapshot(sidecar, initialSystemPromptSnapshot(classPin), `${scenario.name}/${file}`)
}
}
})