fix(sidechat): stamp the seed with fork markers so the child inherits no pending inbox

sidechat.start passed `seed` without `meta.isSeeded: true` +
`inheritedEventCount`, so dsh-session treated the whole seed as the
child's OWN events and the child's Inbox constructor replayed the
parent's `agent/inbox/spliced` history — inheriting whatever input sat
UNCLAIMED in the parent at the click moment:

- next-turn: a queued follow-up behind a still-running turn;
- next-step (the reported long-context case, parent queue empty): a
  tool-result context / steering spliced between step boundaries.

The first side prompt then claimed [phantom, boundary, question] — the
stale parent message was sent to the model BEFORE the boundary and
logged as a user/message. The fix passes the exact marker pair the
host's own session.fork uses (api-session-controller); ownEvents() now
stops at the end-seed boundary and the inherited inbox replays empty.

Regression tests run the REAL Session.create + Inbox in both
directions (old shape inherits the phantom, marked shape stays empty);
routes tests pin the two create options. Verified: pnpm typecheck /
test (5 pre-existing unrelated UI-spec failures on main unchanged) /
build.
This commit is contained in:
Menghuan1918
2026-09-06 00:59:03 +08:00
parent a5c52b3f1b
commit 93ad87dfe7
5 changed files with 146 additions and 1 deletions
+13
View File
@@ -27,6 +27,7 @@ import type { Agent, AgentSetup, CreateAgentOptions, ResumeAgentOptions } from '
import { snapshotSubagentDescriptor } from '@deepseek-ai/dsh-subagent'
import type { Context as CordisContext } from '@deepseek-ai/cordis'
import type { SessionEvent, SessionId } from '@deepseek-ai/dsh-session'
import { SessionLogOffset } from '@deepseek-ai/dsh-session'
import type {
Context,
SidebarAgentPresetsService,
@@ -238,16 +239,28 @@ export function buildSidechatApi(ctx: Context): SidechatRoutes {
data: descriptor as unknown as Record<string, unknown>,
}
const seed = [...inheritance.seed, descriptorEvent]
// Fork-marker fields (the exact shape the host's own session.fork uses,
// api-session-controller): without `isSeeded` + `inheritedEventCount` the
// session treats the whole seed as the child's OWN events, so the child's
// Inbox constructor replays the parent's `agent/inbox/spliced` events and
// inherits whatever input sat UNCLAIMED in the parent at the click moment
// (a queued follow-up, or a tool-result context spliced into next-step
// between step boundaries of a long-running turn). The first side prompt
// would then claim and send that stale message BEFORE the boundary +
// question. The marker keeps `ownEvents()` at the end-seed boundary, so
// the inherited inbox replays to empty.
const options: CreateAgentOptions = {
sessionId: childId,
meta: {
...(parentSession.header.cwd === undefined ? {} : { cwd: parentSession.header.cwd }),
parentSession: parentSession.id,
isSeeded: true,
origin: 'subagent',
delegationDepth: (parentSession.header.delegationDepth ?? 0) + 1,
...(agentPreset === undefined ? {} : { agentPreset }),
},
seed: seed as unknown as readonly SessionEvent[],
inheritedEventCount: SessionLogOffset(seed.length),
agentOptions: { ...parent.options },
setup,
signal: AbortSignal.timeout(CREATE_TIMEOUT_MS),