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
@@ -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": "child-only", "description": "Child D", "parameters": { "type": "object" } }] }, "reason": "initial" } }
{ "type": "request/header", "seq": 0, "time": 6, "data": { "header": { "config": { "model": "fake" }, "system": "SYS PROMPT\n\nCHILD GUIDANCE", "tools": [{ "name": "child-only", "description": "Child D", "parameters": { "type": "object" } }] }, "reason": "initial" } }
]}
]
}
@@ -0,0 +1,3 @@
SYS PROMPT
CHILD GUIDANCE
@@ -12,6 +12,7 @@ import {
type Scenario,
} from '../src/index.ts'
import {
assertChildSystemPromptSnapshot,
assertUniqueSnapshotContents,
claimSharedSnapshot,
fixtureContext,
@@ -85,6 +86,7 @@ const REPLAY_SCENARIOS: Scenario[] = [
configPath: AGENT.configPath,
workspaceParent: tmpdir(),
pinsChildToolSchemas: [1],
pinsChildSystemPrompts: [1],
prepareWorkspace: (cwd) => {
writeFileSync(join(cwd, 'seed.txt'), 'prepared at runtime')
},
@@ -126,6 +128,7 @@ function staleRefreshFixtures(dir: string): void {
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')
writeFileSync(join(dir, 'plain-turn', 'system-prompt.1.expected.md'), 'STALE CHILD PROMPT\n')
const plainBehaviorFile = join(dir, 'plain-turn', 'behavior.json')
const plainBehavior = JSON.parse(readFileSync(plainBehaviorFile, 'utf8')) as Record<string, unknown>
@@ -191,6 +194,8 @@ describe('defineAcpSnapshotSuite: refresh write-back', () => {
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 childPrompt = readFileSync(join(refreshDir, 'plain-turn', 'system-prompt.1.expected.md'), 'utf8')
expect(childPrompt).toBe('SYS PROMPT\n\nCHILD GUIDANCE\n')
const pinSession = readFileSync(join(refreshDir, 'pin-turn', 'session.jsonl'), 'utf8')
expect(pinSession).toContain('"cwd":"{{cwd}}"')
@@ -591,6 +596,34 @@ describe('formatSystemPromptSnapshot', () => {
})
})
describe('assertChildSystemPromptSnapshot', () => {
const label = 'plain-turn/system-prompt.1.expected.md'
it('accepts a distinct non-empty canonical child prompt', () => {
expect(() => {
assertChildSystemPromptSnapshot('SYS PROMPT\n\nCHILD GUIDANCE\n', 'SYS PROMPT\n', label)
}).not.toThrow()
})
it('rejects an empty or non-canonical child prompt', () => {
expect(() => { assertChildSystemPromptSnapshot('\n', 'SYS PROMPT\n', label) }).toThrow(/non-empty prompt/)
expect(() => {
assertChildSystemPromptSnapshot('CHILD GUIDANCE', 'SYS PROMPT\n', label)
}).toThrow(/end in a newline/)
})
it('rejects a child prompt that duplicates its class pin', () => {
const classSnapshot = readFileSync(join(REPLAY_DIR, 'pin-turn', 'system-prompt.expected.md'), 'utf8')
const marker = classSnapshot.indexOf('\n<!-- request/header change ')
expect(marker).toBeGreaterThan(0)
const initialClassPin = classSnapshot.slice(0, marker)
expect(() => {
assertChildSystemPromptSnapshot(initialClassPin, initialClassPin, label)
}).toThrow(/must differ from its class pin/)
})
})
describe('headerChangeCount', () => {
it('counts changed request headers, ignoring anchors, blanks, and other lines', () => {
const change = JSON.stringify({ type: 'request/header', seq: 2, time: 9, data: { reason: 'change' } })