refactor(agent): unify sourced message delivery
This commit is contained in:
@@ -64,7 +64,7 @@ describe('multi-subagent coexistence (spawn + fork on one context)', () => {
|
||||
])
|
||||
|
||||
// Parent does one real turn first, so the fork has a completed turn to seed.
|
||||
parent.followup([{ type: 'text', text: 'parent q1' }])
|
||||
parent.followup({ content: [{ type: 'text', text: 'parent q1' }], source: { kind: 'user' } })
|
||||
await parent.whenIdle()
|
||||
const parentPrefixLen = parent.session.events.length
|
||||
|
||||
@@ -93,7 +93,7 @@ describe('multi-subagent coexistence (spawn + fork on one context)', () => {
|
||||
await forkRun.dispose()
|
||||
|
||||
// The parent is unaffected and keeps working after both delegations.
|
||||
parent.followup([{ type: 'text', text: 'parent q2' }])
|
||||
parent.followup({ content: [{ type: 'text', text: 'parent q2' }], source: { kind: 'user' } })
|
||||
await parent.whenIdle()
|
||||
const lastParentMessage = parent.session.events.findLast(e => e.type === 'assistant/message')
|
||||
expect(lastParentMessage?.type === 'assistant/message' && text(lastParentMessage.data.content)).toBe('parent turn two')
|
||||
|
||||
@@ -89,9 +89,9 @@ describe('dsh-subagent-fork', () => {
|
||||
|
||||
it('seeds every completed parent turn through the last turn/end', async () => {
|
||||
const { ctx, parent } = await setup([textResponse('first'), textResponse('second'), textResponse('child')])
|
||||
parent.followup([{ type: 'text', text: 'q1' }])
|
||||
parent.followup({ content: [{ type: 'text', text: 'q1' }], source: { kind: 'user' } })
|
||||
await parent.whenIdle()
|
||||
parent.followup([{ type: 'text', text: 'q2' }])
|
||||
parent.followup({ content: [{ type: 'text', text: 'q2' }], source: { kind: 'user' } })
|
||||
await parent.whenIdle()
|
||||
const parentPrefixLen = parent.session.events.length
|
||||
|
||||
@@ -108,7 +108,7 @@ describe('dsh-subagent-fork', () => {
|
||||
// Parent runs one turn, then we fork. The child's seeded log should contain
|
||||
// the parent's first turn, and the child should run its own new turn on top.
|
||||
const { ctx, parent } = await setup([textResponse('parent answer'), textResponse('child answer')])
|
||||
parent.followup([{ type: 'text', text: 'parent question' }])
|
||||
parent.followup({ content: [{ type: 'text', text: 'parent question' }], source: { kind: 'user' } })
|
||||
await parent.whenIdle()
|
||||
const parentPrefixLen = parent.session.events.length
|
||||
|
||||
@@ -137,10 +137,10 @@ describe('dsh-subagent-fork', () => {
|
||||
// open (a hanging model call), and fork while it's in flight. The seed must stop after the
|
||||
// balanced first turn; including the open turn would fail invariant replay during start.
|
||||
const { ctx, parent } = await setup([textResponse('done'), 'hang', textResponse('child')])
|
||||
parent.followup([{ type: 'text', text: 'q1' }])
|
||||
parent.followup({ content: [{ type: 'text', text: 'q1' }], source: { kind: 'user' } })
|
||||
await parent.whenIdle()
|
||||
// Start a second turn that hangs (open turn/start + open step, never ends).
|
||||
parent.followup([{ type: 'text', text: 'q2' }])
|
||||
parent.followup({ content: [{ type: 'text', text: 'q2' }], source: { kind: 'user' } })
|
||||
await new Promise(r => setTimeout(r, 20)) // let the hanging turn open
|
||||
|
||||
// Forking now must NOT throw (the open second turn is excluded from the seed).
|
||||
@@ -164,7 +164,7 @@ describe('dsh-subagent-fork', () => {
|
||||
textResponse('parent turn'),
|
||||
toolCallResponse('c1', STRUCTURED_OUTPUT_TOOL, { answer: 9 }),
|
||||
])
|
||||
parent.followup([{ type: 'text', text: 'warm up' }])
|
||||
parent.followup({ content: [{ type: 'text', text: 'warm up' }], source: { kind: 'user' } })
|
||||
await parent.whenIdle()
|
||||
const run = await start(ctx, 'fork', {
|
||||
prompt: [{ type: 'text', text: 'report structured' }],
|
||||
@@ -183,7 +183,7 @@ describe('dsh-subagent-fork', () => {
|
||||
// `readResult` must scan only child-owned events after the seed. The child emits no assistant
|
||||
// message, so scanning the whole log would incorrectly return the parent's distinctive text.
|
||||
const { ctx, parent } = await setup([textResponse('parent stale'), emptyStop])
|
||||
parent.followup([{ type: 'text', text: 'parent question' }])
|
||||
parent.followup({ content: [{ type: 'text', text: 'parent question' }], source: { kind: 'user' } })
|
||||
await parent.whenIdle()
|
||||
|
||||
const run = await start(ctx, 'fork', { prompt: [{ type: 'text', text: 'child question' }], parent })
|
||||
|
||||
@@ -36,7 +36,7 @@ Depth enforcement is internal to `startInProcessRun`: it reads the parent depth
|
||||
- An order-190 system-prompt section tells the child that the tool call is the terminal answer.
|
||||
- Both contributions are ordinary child-scoped registrations. An expert `system-prompt/assemble` listener may replace them and therefore owns preserving the structured-output protocol for that child.
|
||||
- A `tools/result` observer commits a staged value only after that execution's authoritative final tool result succeeds, including the enclosing `run_code` result for Code Mode sub-dispatch.
|
||||
- A monotonic tool guard blocks later calls after capture, and `agent/turn-stop` ends the turn after the structured result commits.
|
||||
- A monotonic tool guard blocks later calls after capture, and the structured-output execution's `concludeTurn()` marker ends the turn after the result commits.
|
||||
|
||||
A clean turn that never commits the required structured value reports `error`; the driver does not re-prompt. All registrations ride the child fiber and disappear with it.
|
||||
|
||||
|
||||
@@ -141,7 +141,7 @@ export async function startInProcessRun(
|
||||
|
||||
const result: Promise<SubagentResult> = (async () => {
|
||||
try {
|
||||
child.followup(request.prompt)
|
||||
child.followup({ content: request.prompt, source: { kind: 'user' } })
|
||||
await child.whenIdle()
|
||||
return readResult(
|
||||
child,
|
||||
|
||||
@@ -463,7 +463,7 @@ describe('in-process structured output', () => {
|
||||
textResponse('parent answer'),
|
||||
toolCallResponse('c1', STRUCTURED_OUTPUT_TOOL, { answer: 1 }),
|
||||
])
|
||||
parent.followup([{ type: 'text', text: 'hello' }])
|
||||
parent.followup({ content: [{ type: 'text', text: 'hello' }], source: { kind: 'user' } })
|
||||
await parent.whenIdle()
|
||||
expect(adapter.requests[0]!.system ?? '').not.toContain(STRUCTURED_OUTPUT_INSTRUCTION)
|
||||
const run = await ctx.subagents.start('spawn', structuredRequest(parent))
|
||||
@@ -479,7 +479,7 @@ describe('in-process structured output', () => {
|
||||
describe('scoped registration (each child owns its capture tool)', () => {
|
||||
it('a plain agent never sees the tool: nothing is registered globally at all', async () => {
|
||||
const { ctx, parent, adapter } = await setup([textResponse('parent answer')])
|
||||
parent.followup([{ type: 'text', text: 'hello' }])
|
||||
parent.followup({ content: [{ type: 'text', text: 'hello' }], source: { kind: 'user' } })
|
||||
await parent.whenIdle()
|
||||
// Scoped registration: the global view has no capture tool, ever.
|
||||
expect(ctx.tools.get(STRUCTURED_OUTPUT_TOOL)).toBeUndefined()
|
||||
@@ -493,7 +493,7 @@ describe('in-process structured output', () => {
|
||||
// Child turn: must see it, with the run's schema.
|
||||
toolCallResponse('c1', STRUCTURED_OUTPUT_TOOL, { answer: 42 }),
|
||||
])
|
||||
parent.followup([{ type: 'text', text: 'hello' }])
|
||||
parent.followup({ content: [{ type: 'text', text: 'hello' }], source: { kind: 'user' } })
|
||||
await parent.whenIdle()
|
||||
expect(toolNames(adapter.requests[0]!)).not.toContain(STRUCTURED_OUTPUT_TOOL)
|
||||
|
||||
@@ -571,7 +571,7 @@ describe('in-process structured output', () => {
|
||||
|
||||
it('a non-structured agent request keeps tools ABSENT when it had none (no tools: [] materialized)', async () => {
|
||||
const { parent, adapter } = await setup([textResponse('plain')])
|
||||
parent.followup([{ type: 'text', text: 'q' }])
|
||||
parent.followup({ content: [{ type: 'text', text: 'q' }], source: { kind: 'user' } })
|
||||
await parent.whenIdle()
|
||||
const request = adapter.requests[0]!
|
||||
expect(request.tools).toBeUndefined()
|
||||
|
||||
@@ -87,7 +87,7 @@ describe('startInProcessRun', () => {
|
||||
|
||||
it('seeds a forked child but reads only the child-owned output', async () => {
|
||||
const { ctx, parent } = await setup([textResponse('parent answer'), textResponse('child answer')])
|
||||
parent.followup([{ type: 'text', text: 'parent question' }])
|
||||
parent.followup({ content: [{ type: 'text', text: 'parent question' }], source: { kind: 'user' } })
|
||||
await parent.whenIdle()
|
||||
const seed = parent.session.events.slice()
|
||||
const run = await startInProcessRun(request(parent), { seed })
|
||||
|
||||
@@ -31,10 +31,10 @@ describe.skipIf(!process.env.DEEPSEEK_API_KEY)('spawn backend with-key smoke', (
|
||||
ctx = await spawnHarness(workdir)
|
||||
const parent = ctx.agentLoop.create(SessionId('e2e-parent'), { provider: 'deepseek', model: 'deepseek-v4-flash' })
|
||||
|
||||
parent.followup([{ type: 'text', text:
|
||||
parent.followup({ content: [{ type: 'text', text:
|
||||
'Use the subagent tool to delegate this exact task: "Use the bash tool to write the text '
|
||||
+ 'SUBAGENT_WAS_HERE into a file named proof.txt in the current directory." '
|
||||
+ 'After the subagent finishes, tell me it is done.' }])
|
||||
+ 'After the subagent finishes, tell me it is done.' }], source: { kind: 'user' } })
|
||||
await waitForIdle(ctx, parent)
|
||||
|
||||
// Verify the WORLD: the child actually wrote the file.
|
||||
|
||||
@@ -106,7 +106,7 @@ describe('dsh-subagent-spawn', () => {
|
||||
it('a fresh child does NOT inherit the parent conversation (its log starts empty before the prompt)', async () => {
|
||||
// Drive the parent through one real turn so it has history, THEN spawn.
|
||||
const { ctx, parent } = await setup([textResponse('parent turn'), textResponse('child sees nothing')])
|
||||
parent.followup([{ type: 'text', text: 'parent prompt' }])
|
||||
parent.followup({ content: [{ type: 'text', text: 'parent prompt' }], source: { kind: 'user' } })
|
||||
await parent.whenIdle()
|
||||
const parentEventCount = parent.session.events.length
|
||||
expect(parentEventCount).toBeGreaterThan(0)
|
||||
@@ -383,7 +383,7 @@ describe('dsh-subagent-spawn', () => {
|
||||
textResponse('parent answer'),
|
||||
textResponse('child answer'),
|
||||
])
|
||||
parent.followup([{ type: 'text', text: 'hi' }])
|
||||
parent.followup({ content: [{ type: 'text', text: 'hi' }], source: { kind: 'user' } })
|
||||
await parent.whenIdle()
|
||||
|
||||
const run = await start(ctx, 'spawn', {
|
||||
|
||||
Reference in New Issue
Block a user