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
7 changes: 7 additions & 0 deletions .changeset/acp-steering.md
Original file line number Diff line number Diff line change
@@ -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.
19 changes: 18 additions & 1 deletion packages/acp-adapter/src/server.ts
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,7 @@ import {
type AvailableCommand,
type CancelNotification,
type ClientCapabilities,
type ContentBlock,
type Implementation,
type InitializeRequest,
type InitializeResponse,
Expand Down Expand Up @@ -335,6 +336,7 @@ export class AcpServer implements Agent {
: TERMINAL_AUTH_METHOD,
],
...(this.agentInfo ? { agentInfo: this.agentInfo } : {}),
_meta: { steering: { supported: true } },
};
}

Expand Down Expand Up @@ -843,8 +845,23 @@ export class AcpServer implements Agent {
*/
async extMethod(
method: string,
_params: Record<string, unknown>,
params: Record<string, unknown>,
): Promise<Record<string, unknown>> {
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);
}

Expand Down
56 changes: 56 additions & 0 deletions packages/acp-adapter/src/session.ts
Original file line number Diff line number Diff line change
Expand Up @@ -284,6 +284,62 @@ 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 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' };
}

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' };
}

/**
* Seed the per-session `slash command name → skill name` map used by
* {@link prompt} to intercept `/skill:<name> ...` inputs. Called by
Expand Down