From 5f0689de88edcf64cae859235a8b08c7dee2decc Mon Sep 17 00:00:00 2001 From: "Adwait M." Date: Wed, 29 Jul 2026 20:57:09 +0530 Subject: [PATCH 1/2] feat(acp): expose steering over ACP via _session/steering extension method --- .changeset/acp-steering.md | 7 +++++++ packages/acp-adapter/src/server.ts | 19 ++++++++++++++++++- packages/acp-adapter/src/session.ts | 27 +++++++++++++++++++++++++++ 3 files changed, 52 insertions(+), 1 deletion(-) create mode 100644 .changeset/acp-steering.md diff --git a/.changeset/acp-steering.md b/.changeset/acp-steering.md new file mode 100644 index 0000000000..a20b2f9dd7 --- /dev/null +++ b/.changeset/acp-steering.md @@ -0,0 +1,7 @@ +--- +"@moonshot-ai/kimi-code": minor +--- + +Expose steering over ACP: map the `_session/steering` extension method to the existing `Session.steer()` in the core SDK. + +The `AcpServer.initialize()` response now advertises `_meta.steering.supported: true` so ACP clients can detect the capability at runtime. When a client calls `_session/steering` with a `sessionId` and `prompt` blocks during an active turn, the adapter converts the blocks and injects them into the running turn via `Session.steer()`. If no turn is active, the adapter returns `{ outcome: 'noActiveTurn' }` so the host can fall back to a regular `session/prompt` instead of starting a detached turn. diff --git a/packages/acp-adapter/src/server.ts b/packages/acp-adapter/src/server.ts index a9ef407bee..b5ca89a52f 100644 --- a/packages/acp-adapter/src/server.ts +++ b/packages/acp-adapter/src/server.ts @@ -20,6 +20,7 @@ import { type AvailableCommand, type CancelNotification, type ClientCapabilities, + type ContentBlock, type Implementation, type InitializeRequest, type InitializeResponse, @@ -335,6 +336,7 @@ export class AcpServer implements Agent { : TERMINAL_AUTH_METHOD, ], ...(this.agentInfo ? { agentInfo: this.agentInfo } : {}), + _meta: { steering: { supported: true } }, }; } @@ -843,8 +845,23 @@ export class AcpServer implements Agent { */ async extMethod( method: string, - _params: Record, + params: Record, ): Promise> { + if (method === '_session/steering') { + const sessionId = params['sessionId'] as string | undefined; + const prompt = params['prompt'] as readonly ContentBlock[] | undefined; + if (typeof sessionId !== 'string' || sessionId.length === 0) { + throw RequestError.invalidParams(undefined, 'Missing or invalid sessionId'); + } + const acpSession = this.sessions.get(sessionId); + if (!acpSession) { + throw RequestError.invalidParams(undefined, `Unknown sessionId: ${sessionId}`); + } + if (!Array.isArray(prompt) || prompt.length === 0) { + throw RequestError.invalidParams(undefined, 'Steering requires at least one content block'); + } + return acpSession.steer(prompt); + } throw RequestError.methodNotFound(method); } diff --git a/packages/acp-adapter/src/session.ts b/packages/acp-adapter/src/session.ts index 747b44ea9c..b862ad1aa0 100644 --- a/packages/acp-adapter/src/session.ts +++ b/packages/acp-adapter/src/session.ts @@ -284,6 +284,33 @@ export class AcpSession { await this.session.cancel(); } + /** + * Forward an ACP `_session/steering` extension request to the + * underlying SDK session's {@link Session.steer} method. + * + * Steer injects the given prompt parts into the currently running + * turn without starting a new one — the output of the steered content + * continues to stream through the existing {@link prompt} call's + * `session/update` notifications. + * + * When no turn is currently active, the method returns + * `{ outcome: 'noActiveTurn' }` so the ACP host can fall back to a + * regular `session/prompt` instead of the adapter starting a detached + * turn whose lifecycle the host cannot control. This avoids the + * `startedNewTurn` problem observed in other ACP adapters (see + * kimi-code issue #2370). + */ + async steer(blocks: readonly ContentBlock[]): Promise<{ outcome: string }> { + const parts = acpBlocksToPromptParts(blocks); + + if (this.currentTurnId === undefined) { + return { outcome: 'noActiveTurn' }; + } + + await this.session.steer(parts); + return { outcome: 'injected' }; + } + /** * Seed the per-session `slash command name → skill name` map used by * {@link prompt} to intercept `/skill: ...` inputs. Called by From 059263b6bc4b73584cbc9a5c7453d2f4523a71a4 Mon Sep 17 00:00:00 2001 From: "Adwait M." Date: Wed, 29 Jul 2026 21:12:36 +0530 Subject: [PATCH 2/2] acp: compress images in steer path and guard against turn-boundary race --- packages/acp-adapter/src/session.ts | 33 +++++++++++++++++++++++++++-- 1 file changed, 31 insertions(+), 2 deletions(-) diff --git a/packages/acp-adapter/src/session.ts b/packages/acp-adapter/src/session.ts index b862ad1aa0..2e0a0e19e1 100644 --- a/packages/acp-adapter/src/session.ts +++ b/packages/acp-adapter/src/session.ts @@ -301,13 +301,42 @@ export class AcpSession { * kimi-code issue #2370). */ async steer(blocks: readonly ContentBlock[]): Promise<{ outcome: string }> { - const parts = acpBlocksToPromptParts(blocks); + const sessionDir = this.session.summary?.sessionDir; + const track = this.track; + const parts = await compressPromptImageParts(acpBlocksToPromptParts(blocks), { + originalsDir: + sessionDir === undefined ? undefined : sessionMediaOriginalsDir(sessionDir), + maxImageEdgePx: this.harness?.imageLimits?.maxEdgePx(), + telemetry: + track === undefined + ? undefined + : { + track: (event, properties) => + track(event, properties === undefined ? undefined : { ...properties }), + }, + }); if (this.currentTurnId === undefined) { return { outcome: 'noActiveTurn' }; } - await this.session.steer(parts); + let newTurnStarted = false; + const unsub = this.session.onEvent((event) => { + if (event.type === 'turn.started') { + newTurnStarted = true; + } + }); + + try { + await this.session.steer(parts); + } finally { + unsub(); + } + + if (newTurnStarted) { + return { outcome: 'noActiveTurn' }; + } + return { outcome: 'injected' }; }