Fix subagent in-process result scoping (Codex review round 1)

Two merge-blocking bugs in the shared in-process run driver, both rooted in
`readResult` scanning the whole child session and deriving the stop reason only
from `turn/end`:

- A pre-turn `cancel()` cleared the queued prompt before any `turn/end` was
  logged, so the run settled `error` instead of `aborted`, violating the
  `SubagentRun.cancel()` contract. The driver now tracks that a cancel was
  requested and maps the no-turn case to `aborted`.
- A fork child whose own turn produced no `assistant/message` returned the
  SEEDED parent's last message as a `completed` success. `readResult` now scopes
  to the child's OWN events (after the seed prefix), so a message-less child
  yields empty output.

Both fixes carry a regression test proven to go red on the pre-fix driver.

Also: correct the `SubagentRun.id` / event-payload docs (it is the child AGENT
id, not a session id — the backend mints distinct tokens); refresh the stale
`coding-agent` welcome string (subagent is now a tool); and replace the stale
`TODO(sub-agents)` "deferred" prose in the Agent interface, core.md, and
architecture.md with an accurate pointer to the realized seam.
This commit is contained in:
Tianyi Cui
2026-06-22 06:47:20 +08:00
parent 7aabd2a3df
commit b82c310db3
11 changed files with 104 additions and 43 deletions
@@ -10,11 +10,16 @@ import AgentLoop from '@deepseek-ai/dsh-agent-loop'
import * as Invariants from '@deepseek-ai/dsh-invariants'
import SubagentService from '@deepseek-ai/dsh-subagent'
import { MockAdapter, textResponse } from '../../../core/agent-loop/tests/mock-adapter.ts'
import type { StreamChunk } from '@deepseek-ai/dsh-llm'
import * as fork from '../src/index.ts'
import { completedTurnPrefix } from '../src/index.ts'
type Script = ConstructorParameters<typeof MockAdapter>[0]
/** A bare `stop` finish that streams no content → the turn ends `completed`
* with NO `assistant/message` of its own. */
const emptyStop: StreamChunk[] = [{ type: 'finish', reason: { kind: 'stop' } }]
/**
* Drives the REAL fork backend with a real loop + scripted mock MODEL + the
* real dsh-invariants plugin. The invariants plugin re-replays a seeded child
@@ -132,6 +137,26 @@ describe('dsh-subagent-fork', () => {
await run.dispose()
})
it('does NOT return the seeded parent output when the child produces no message of its own', async () => {
// Regression: readResult must scope to the child's OWN events (after the
// seed). The parent completes a turn with a distinctive assistant message,
// then the fork child's own turn finishes with a bare `stop` and NO
// assistant/message. Scanning the whole (seeded) log would return the
// parent's "parent stale" message with stopReason 'completed'; scoped to the
// child's own events the output is empty.
const { ctx, parent } = await setup([textResponse('parent stale'), emptyStop])
parent.send([{ type: 'text', text: 'parent question' }])
await parent.whenIdle()
const run = ctx.subagents.start('fork', { prompt: [{ type: 'text', text: 'child question' }], parent })
const result = await run.result
// The child completed its own (empty) turn — completed, but with NO output
// borrowed from the seeded parent prefix.
expect(result.stopReason).toBe('completed')
expect(result.output).toEqual([])
await run.dispose()
})
it('advertises depthLimit but not outputSchema/toolFilter', async () => {
const { ctx } = await setup([])
expect(ctx.subagents.getProvider('fork')!.capabilities).toEqual({ outputSchema: false, depthLimit: true, toolFilter: false })