From 8110bdc3eccc04e63617f4cb010496f692cfa785 Mon Sep 17 00:00:00 2001 From: "haozhe.yang" Date: Fri, 7 Aug 2026 16:33:36 +0800 Subject: [PATCH] fix(agent-core-v2): degrade idle-session steer to turn launch like v1 - return the enqueue-launched turn instead of rejecting with prompt.not_found when no prompt is pending at steer time - report steer as queued when a manual compaction holds the context - sync title/lastPrompt metadata on main-agent steer, matching v1 - update the v1-v2 parity test to assert converged behavior --- .changeset/steer-goal-turn-boundary.md | 5 +++ .../agent-core-v2/src/agent/rpc/rpcService.ts | 31 ++++++++++++++++--- packages/node-sdk/src/sdk-rpc-client-v2.ts | 10 +++--- packages/node-sdk/test/v1-v2-parity.test.ts | 18 +++++------ 4 files changed, 44 insertions(+), 20 deletions(-) create mode 100644 .changeset/steer-goal-turn-boundary.md diff --git a/.changeset/steer-goal-turn-boundary.md b/.changeset/steer-goal-turn-boundary.md new file mode 100644 index 0000000000..cfbb8a2424 --- /dev/null +++ b/.changeset/steer-goal-turn-boundary.md @@ -0,0 +1,5 @@ +--- +"@moonshot-ai/kimi-code": patch +--- + +Fix a spurious "Failed to steer" error when sending a message while a goal run is between turns. diff --git a/packages/agent-core-v2/src/agent/rpc/rpcService.ts b/packages/agent-core-v2/src/agent/rpc/rpcService.ts index f3d089ab76..87e8005acb 100644 --- a/packages/agent-core-v2/src/agent/rpc/rpcService.ts +++ b/packages/agent-core-v2/src/agent/rpc/rpcService.ts @@ -6,7 +6,7 @@ import { IAgentTokenCountingService } from '#/agent/tokenCounting/tokenCounting' import { IAgentFullCompactionService } from '#/agent/fullCompaction/fullCompaction'; import { IEventBus } from '#/app/event/eventBus'; import { IEventService } from '#/app/event/event'; -import { ErrorCodes, Error2 } from '#/errors'; +import { ErrorCodes, Error2, isError2 } from '#/errors'; import { IAgentPermissionModeService } from '#/agent/permissionMode/permissionMode'; import { IAgentScopeContext } from '#/agent/scopeContext/scopeContext'; import { @@ -112,14 +112,37 @@ export class AgentRPCService implements IAgentRPCService { async steer(payload: SteerPayload): Promise { this.telemetry.track2('input_steer', { parts: payload.input.length }); + if (this.scopeContext.agentId === MAIN_AGENT_ID) { + // A steer is user input like a prompt — and can even launch the + // session's first turn (e.g. goal mode) — so keep title/lastPrompt in + // sync the same way, matching v1. + await this.updatePromptMetadata(promptMetadataTextFromPayload(payload)); + } const queued = await this.promptService.enqueue({ message: { role: 'user', content: [...payload.input], toolCalls: [], } }); - const [steered] = await this.promptService.steer([queued.id]); - const turn = await steered?.launched; - return turn === undefined ? undefined : { turn_id: turn.id }; + if (queued.state !== 'pending') { + // No active prompt at enqueue time, so the enqueue itself already + // launched this input as its own turn (idle session, or a goal-turn + // boundary where the previous turn just ended) — v1's + // steer-degrades-to-launch end state. Return that turn instead of + // rejecting on a steer-by-id that can never find the record pending. + const turn = await queued.launched; + return turn === undefined ? undefined : { turn_id: turn.id }; + } + try { + const [steered] = await this.promptService.steer([queued.id]); + const turn = await steered?.launched; + return turn === undefined ? undefined : { turn_id: turn.id }; + } catch (error) { + // Pending but nothing active to steer into (a manual compaction holds + // the context): the message stays queued and launches once compaction + // finishes, so report it as queued rather than failing the steer. + if (isError2(error) && error.code === ErrorCodes.PROMPT_NOT_FOUND) return undefined; + throw error; + } } cancel({ turnId }: CancelPayload): void { diff --git a/packages/node-sdk/src/sdk-rpc-client-v2.ts b/packages/node-sdk/src/sdk-rpc-client-v2.ts index de0da25c77..d30e7c14a0 100644 --- a/packages/node-sdk/src/sdk-rpc-client-v2.ts +++ b/packages/node-sdk/src/sdk-rpc-client-v2.ts @@ -1608,12 +1608,10 @@ export class SDKRpcClientV2 extends SDKRpcClientBase { } /** - * Facade (`agentRPCService.steer`). Mid-turn steers match v1 (the input - * joins the running turn). The idle-session case diverges by design and is - * pinned in the parity tests: v1 launches a fresh turn off a steer and - * updates title/lastPrompt like a prompt; v2's enqueue launches the turn - * first, so the follow-up `steer()` finds nothing pending and rejects with - * `prompt.not_found` — and the v2 RPC path never touches the metadata. + * Facade (`agentRPCService.steer`). Matches v1 on both paths: mid-turn + * steers join the running turn, and an idle-session steer degrades to + * launching a fresh turn (the enqueue launches it directly) while + * title/lastPrompt are updated like a prompt's. */ override async steer(input: SessionPromptRpcInput): Promise { const agent = await this.agentFacade(input.sessionId); diff --git a/packages/node-sdk/test/v1-v2-parity.test.ts b/packages/node-sdk/test/v1-v2-parity.test.ts index 4dc9c236d5..20fac23f50 100644 --- a/packages/node-sdk/test/v1-v2-parity.test.ts +++ b/packages/node-sdk/test/v1-v2-parity.test.ts @@ -2423,28 +2423,26 @@ describe('v1↔v2 agent interaction parity', () => { } }); - it('steer on an idle session: v1 launches a turn, v2 rejects prompt.not_found (pinned)', async () => { + it('steer on an idle session: both engines launch a turn and update metadata', async () => { const restoreEnv = scrubConfigEnv(); const pair = await makeSessionParityPair(); try { await createOnBoth(pair, { id: 'session_parity_agent_steer' }); const input = { sessionId: 'session_parity_agent_steer' } as const; - // Pinned divergence: v1 treats an idle steer like a prompt — it - // launches a fresh turn and updates title/lastPrompt. v2's steer RPC - // enqueues first (which itself launches the turn), so the follow-up - // steer step finds no pending prompt and rejects with prompt.not_found; - // the v2 path never touches the metadata. + // v1 treats an idle steer like a prompt — it launches a fresh turn and + // updates title/lastPrompt. v2's steer RPC enqueues first (which itself + // launches the turn) and converges on the same end state: the launched + // turn is returned instead of rejecting, and the metadata is updated. await pair.v1.steer({ ...input, input: [{ type: 'text', text: 'steer text' }] }); - await expect( - pair.v2.steer({ ...input, input: [{ type: 'text', text: 'steer text' }] }), - ).rejects.toMatchObject({ code: 'prompt.not_found' }); + await pair.v2.steer({ ...input, input: [{ type: 'text', text: 'steer text' }] }); const [v1List, v2List] = await Promise.all([ pair.v1.listSessions(), pair.v2.listSessions(), ]); expect(v1List[0]?.title).toBe('steer text'); expect(v1List[0]?.lastPrompt).toBe('steer text'); - expect(v2List[0]?.lastPrompt).not.toBe('steer text'); + expect(v2List[0]?.title).toBe('steer text'); + expect(v2List[0]?.lastPrompt).toBe('steer text'); await settleTurns(); } finally { await closeSessionPair(pair);