Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .changeset/steer-goal-turn-boundary.md
Original file line number Diff line number Diff line change
@@ -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.
31 changes: 27 additions & 4 deletions packages/agent-core-v2/src/agent/rpc/rpcService.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down Expand Up @@ -112,14 +112,37 @@ export class AgentRPCService implements IAgentRPCService {

async steer(payload: SteerPayload): Promise<PromptLaunchResult | undefined> {
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 };
Comment on lines +132 to +133

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Preserve steer history when degrading to launch

When this branch is taken for an idle session, queued.launched is the turn started by promptService.enqueue(); that path uses a PromptStepRequest, and LoopService.startTurn persists a turn.prompt record. v1 AgentTurn.steer() records turn.steer before launching, so the v2 call now succeeds while history/transcript consumers see it as a normal prompt instead of a steer. Use a steer launch path for this degradation rather than returning the enqueue-launched prompt.

Useful? React with 👍 / 👎.

}
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 {
Expand Down
10 changes: 4 additions & 6 deletions packages/node-sdk/src/sdk-rpc-client-v2.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<void> {
const agent = await this.agentFacade(input.sessionId);
Expand Down
18 changes: 8 additions & 10 deletions packages/node-sdk/test/v1-v2-parity.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand Down
Loading