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:
+1
-1
@@ -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" } }
|
||||
]}
|
||||
]
|
||||
}
|
||||
|
||||
+3
@@ -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' } })
|
||||
|
||||
Reference in New Issue
Block a user