mirror of
https://github.com/agentclientprotocol/claude-agent-acp.git
synced 2026-11-10 19:32:21 +00:00
Compare commits
2 Commits
v0.104.1
...
refactor/v
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
3dba368bc4 | ||
|
|
bce309f482 |
@@ -16,7 +16,7 @@ import type { AcpPermissionRequest } from "./permissions/presentation.js";
|
||||
* Client-facing surface the agent calls back into. This is the subset of ACP
|
||||
* client methods the agent actually uses, expressed as a narrow interface so
|
||||
* tests can supply lightweight mocks. In the v1 agent it is backed by
|
||||
* `ClientConnection` (`v1/acp-agent.ts`) over the SDK's typed `AgentContext`.
|
||||
* `ClientConnection` (`v1/client-connection.ts`) over the SDK's typed `AgentContext`.
|
||||
* The v2 session output passes one that only takes `sessionUpdate`
|
||||
* (`v2/stream.ts`).
|
||||
*/
|
||||
|
||||
@@ -32,8 +32,8 @@ import {
|
||||
ClaudeAcpAgent,
|
||||
computeSessionFingerprint,
|
||||
runPromptWithCancellation,
|
||||
type SteerRequest,
|
||||
} from "../../v1/acp-agent.js";
|
||||
import type { SteerRequest } from "../../v1/steering.js";
|
||||
import { buildConfigOptions, discoverCustomAgents } from "../../session-config-options.js";
|
||||
import { createFastModeConfigOption } from "../../session-fast-mode.js";
|
||||
import {
|
||||
|
||||
@@ -1,15 +1,12 @@
|
||||
import {
|
||||
agent as acpAgent,
|
||||
AgentApp,
|
||||
AgentContext,
|
||||
AuthenticateRequest,
|
||||
AuthMethod,
|
||||
AuthStatusResponse,
|
||||
AuthStatusUpdateNotification,
|
||||
AvailableCommand,
|
||||
CancelNotification,
|
||||
ClientCapabilities,
|
||||
CompleteElicitationNotification,
|
||||
CreateElicitationRequest,
|
||||
CreateElicitationResponse,
|
||||
DisableProviderRequest,
|
||||
@@ -36,7 +33,6 @@ import {
|
||||
RequestError,
|
||||
SetProviderRequest,
|
||||
SetProviderResponse,
|
||||
RequestPermissionRequest,
|
||||
RequestPermissionResponse,
|
||||
ResumeSessionRequest,
|
||||
ResumeSessionResponse,
|
||||
@@ -151,6 +147,20 @@ import {
|
||||
clientSupportsAsyncTasks,
|
||||
} from "../async-tasks.js";
|
||||
import { SessionBackgroundWork } from "./background-work.js";
|
||||
import {
|
||||
ASYNC_TASK_STOP_METHOD,
|
||||
parseAsyncTaskStopRequest,
|
||||
type AsyncTaskStopRequest,
|
||||
type AsyncTaskStopResponse,
|
||||
} from "./async-task-stop.js";
|
||||
import {
|
||||
STEER_METHOD,
|
||||
STEER_PRIORITY_LATER,
|
||||
STEER_PRIORITY_NOW,
|
||||
parseSteerRequest,
|
||||
type SteerRequest,
|
||||
type SteerResponse,
|
||||
} from "./steering.js";
|
||||
import {
|
||||
AUTH_STATUS_UPDATE_METHOD,
|
||||
type AuthStatus,
|
||||
@@ -305,7 +315,7 @@ import {
|
||||
} from "../session-usage.js";
|
||||
import { previewPatchContent } from "../diff.js";
|
||||
import { backgroundedBashToolCall, withRunningTerminal } from "../tool-calls/background.js";
|
||||
import { ChangedMetaFilter } from "./tool-calls/changed-meta-filter.js";
|
||||
import { ChangedMetaClient, ClientConnection } from "./client-connection.js";
|
||||
import { ToolCallFieldTracker } from "../tool-calls/field-tracker.js";
|
||||
import { ClientCapabilities as ToolCallClientCapabilities } from "../tool-calls/client-capabilities.js";
|
||||
import {
|
||||
@@ -566,112 +576,6 @@ const TURN_NO_RESULT_MESSAGE =
|
||||
"The turn ended without a result: the agent went idle while this prompt was still in flight " +
|
||||
"(e.g. the model stream dropped mid-turn). Any partial output may be incomplete; please retry.";
|
||||
|
||||
/** Custom (extension) request method a client uses to steer the turn that is
|
||||
* currently running: the message is injected into the in-flight turn rather
|
||||
* than queued as a separate `session/prompt`. Named `_session/steering` per the
|
||||
* agreed ACP steering wire protocol; advertised to clients via the top-level
|
||||
* `InitializeResponse._meta.steering.supported`. */
|
||||
const STEER_METHOD = "_session/steering";
|
||||
|
||||
/** Stops one Claude background task without cancelling the parent prompt turn. */
|
||||
const ASYNC_TASK_STOP_METHOD = "_session/async_task/stop";
|
||||
|
||||
type AsyncTaskStopRequest = {
|
||||
sessionId: string;
|
||||
asyncTaskId: string;
|
||||
};
|
||||
|
||||
type AsyncTaskStopResponse = {
|
||||
stopped: boolean;
|
||||
};
|
||||
|
||||
function parseAsyncTaskStopRequest(value: unknown): AsyncTaskStopRequest {
|
||||
if (!value || typeof value !== "object" || Array.isArray(value)) {
|
||||
throw RequestError.invalidParams(undefined, "async task stop params must be an object");
|
||||
}
|
||||
const params = value as Record<string, unknown>;
|
||||
const sessionId = typeof params.sessionId === "string" ? params.sessionId.trim() : "";
|
||||
const asyncTaskId = typeof params.asyncTaskId === "string" ? params.asyncTaskId.trim() : "";
|
||||
if (!sessionId) {
|
||||
throw RequestError.invalidParams(
|
||||
undefined,
|
||||
"async task stop params require a non-empty sessionId",
|
||||
);
|
||||
}
|
||||
if (!asyncTaskId) {
|
||||
throw RequestError.invalidParams(
|
||||
undefined,
|
||||
"async task stop params require a non-empty asyncTaskId",
|
||||
);
|
||||
}
|
||||
return { sessionId, asyncTaskId };
|
||||
}
|
||||
|
||||
/** How urgently the SDK delivers a steered message relative to the running
|
||||
* turn — an internal Claude implementation detail, not part of the wire
|
||||
* contract. `now` pre-empts the current generation, while `later` waits for a
|
||||
* pending permission/elicitation callback to settle instead of cancelling its
|
||||
* ACP request and hiding the client's user-input card (IJAI-1191). */
|
||||
const STEER_PRIORITY_NOW = "now" as const;
|
||||
const STEER_PRIORITY_LATER = "later" as const;
|
||||
|
||||
/** Request-level steering options. `promptRequired` is opt-in so existing Hosts
|
||||
* keep the established idle fallback behavior. */
|
||||
type SteerMeta = {
|
||||
[key: string]: unknown;
|
||||
steering?: {
|
||||
idleBehavior?: "promptRequired";
|
||||
};
|
||||
};
|
||||
|
||||
/** Params of a {@link STEER_METHOD} request. Shaped like the relevant subset of
|
||||
* a `PromptRequest` so the same `promptToClaude` conversion applies. Delivery
|
||||
* priority is deliberately NOT exposed here — it's an internal detail the agent
|
||||
* chooses (see {@link STEER_PRIORITY}). */
|
||||
export type SteerRequest = {
|
||||
sessionId: string;
|
||||
prompt: PromptRequest["prompt"];
|
||||
_meta?: SteerMeta | null;
|
||||
};
|
||||
|
||||
/** Result of a {@link STEER_METHOD} request. The legacy `startedNewTurn` result
|
||||
* remains the default idle behavior; `promptRequired` is returned only when the
|
||||
* Host explicitly opts into the host-owned fallback in request `_meta`. */
|
||||
export type SteerResponse =
|
||||
| { outcome: "injected" }
|
||||
| { outcome: "startedNewTurn" }
|
||||
| { outcome: "promptRequired"; reason: "noRunningTurn" };
|
||||
|
||||
/** Validate raw JSON-RPC params into a {@link SteerRequest}. Kept minimal — the
|
||||
* content blocks are handed to `promptToClaude`, which tolerates unknown block
|
||||
* types — but `sessionId` and a non-empty `prompt` array are required. */
|
||||
function parseSteerRequest(params: unknown): SteerRequest {
|
||||
if (!params || typeof params !== "object") {
|
||||
throw RequestError.invalidParams(undefined, "steer params must be an object");
|
||||
}
|
||||
const { sessionId, prompt, _meta } = params as Record<string, unknown>;
|
||||
if (typeof sessionId !== "string" || sessionId.length === 0) {
|
||||
throw RequestError.invalidParams(undefined, "steer params require a non-empty sessionId");
|
||||
}
|
||||
if (!Array.isArray(prompt) || prompt.length === 0) {
|
||||
throw RequestError.invalidParams(undefined, "steer params require a non-empty prompt array");
|
||||
}
|
||||
const steering =
|
||||
_meta && typeof _meta === "object" ? (_meta as Record<string, unknown>).steering : undefined;
|
||||
const idleBehavior =
|
||||
steering && typeof steering === "object"
|
||||
? (steering as Record<string, unknown>).idleBehavior
|
||||
: undefined;
|
||||
if (idleBehavior !== undefined && idleBehavior !== "promptRequired") {
|
||||
throw RequestError.invalidParams(undefined, "unsupported steering idleBehavior");
|
||||
}
|
||||
return {
|
||||
sessionId,
|
||||
prompt: prompt as PromptRequest["prompt"],
|
||||
_meta: _meta as SteerMeta | null | undefined,
|
||||
};
|
||||
}
|
||||
|
||||
/** Who started a turn. `idleCompaction` is the adapter's own `/compact` (see
|
||||
* `IdleCompaction`): it is not a user prompt, so it neither disarms idle
|
||||
* compaction, asks for a title from its prompt, nor clears the published prompt suggestion. */
|
||||
@@ -1751,116 +1655,6 @@ const LIMIT_FAILURE_CATEGORY = {
|
||||
error_max_structured_output_retries: "provider_error",
|
||||
} as const satisfies Record<string, ClaudeFailureKind>;
|
||||
|
||||
/**
|
||||
* Bridges {@link AcpClient} to the connection-scoped {@link AgentContext}
|
||||
* exposed by `AgentApp.connect(...)` as `connection.client`. The peer handle is
|
||||
* valid for the entire connection lifetime, so it is captured once at
|
||||
* construction.
|
||||
*/
|
||||
class ClientConnection implements AcpClient {
|
||||
constructor(private readonly ctx: AgentContext) {}
|
||||
|
||||
sessionUpdate(params: AcpSessionNotification): Promise<void> {
|
||||
return this.ctx.notify(methods.client.session.update, asSdkSessionNotification(params));
|
||||
}
|
||||
|
||||
requestPermission(
|
||||
params: RequestPermissionRequest,
|
||||
signal?: AbortSignal,
|
||||
): Promise<RequestPermissionResponse> {
|
||||
return this.ctx.request(methods.client.session.requestPermission, params, {
|
||||
cancellationSignal: signal,
|
||||
stopWaitingOnCancel: true,
|
||||
});
|
||||
}
|
||||
|
||||
readTextFile(params: ReadTextFileRequest): Promise<ReadTextFileResponse> {
|
||||
return this.ctx.request(methods.client.fs.readTextFile, params);
|
||||
}
|
||||
|
||||
writeTextFile(params: WriteTextFileRequest): Promise<WriteTextFileResponse> {
|
||||
return this.ctx.request(methods.client.fs.writeTextFile, params);
|
||||
}
|
||||
|
||||
createElicitation(
|
||||
params: CreateElicitationRequest,
|
||||
signal?: AbortSignal,
|
||||
): Promise<CreateElicitationResponse> {
|
||||
return this.ctx.request(methods.client.elicitation.create, params, {
|
||||
cancellationSignal: signal,
|
||||
stopWaitingOnCancel: true,
|
||||
});
|
||||
}
|
||||
|
||||
completeElicitation(params: CompleteElicitationNotification): Promise<void> {
|
||||
return this.ctx.notify(methods.client.elicitation.complete, params);
|
||||
}
|
||||
|
||||
authStatusUpdate(params: AuthStatusUpdateNotification): Promise<void> {
|
||||
return this.ctx.notify(methods.client.auth.statusUpdate, params);
|
||||
}
|
||||
|
||||
extNotification(method: string, params: Record<string, unknown>): Promise<void> {
|
||||
return this.ctx.notify(method, params);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* The client that the agent talks to. For an AIR client, every session update
|
||||
* passes the {@link ChangedMetaFilter} last, after the native subagent routing,
|
||||
* so a `tool_call_update` carries only the `_meta` keys that changed. ACP
|
||||
* merges only the top-level tool call fields, not the `_meta` keys. So another
|
||||
* client gets the full `_meta` on each update.
|
||||
*/
|
||||
class ChangedMetaClient implements AcpClient {
|
||||
private readonly filter = new ChangedMetaFilter();
|
||||
|
||||
constructor(
|
||||
private readonly inner: AcpClient,
|
||||
private readonly airClient: () => boolean,
|
||||
) {}
|
||||
|
||||
async sessionUpdate(params: AcpSessionNotification): Promise<void> {
|
||||
if (!this.airClient()) return this.inner.sessionUpdate(params);
|
||||
const update = this.filter.apply(params.update as SessionNotification["update"]);
|
||||
if (update) await this.inner.sessionUpdate({ ...params, update } as AcpSessionNotification);
|
||||
}
|
||||
|
||||
requestPermission(
|
||||
params: AcpPermissionRequest,
|
||||
signal?: AbortSignal,
|
||||
): Promise<RequestPermissionResponse> {
|
||||
return this.inner.requestPermission(params, signal);
|
||||
}
|
||||
|
||||
readTextFile(params: ReadTextFileRequest): Promise<ReadTextFileResponse> {
|
||||
return this.inner.readTextFile(params);
|
||||
}
|
||||
|
||||
writeTextFile(params: WriteTextFileRequest): Promise<WriteTextFileResponse> {
|
||||
return this.inner.writeTextFile(params);
|
||||
}
|
||||
|
||||
createElicitation(
|
||||
params: CreateElicitationRequest,
|
||||
signal?: AbortSignal,
|
||||
): Promise<CreateElicitationResponse> {
|
||||
return this.inner.createElicitation(params, signal);
|
||||
}
|
||||
|
||||
completeElicitation(params: CompleteElicitationNotification): Promise<void> {
|
||||
return this.inner.completeElicitation(params);
|
||||
}
|
||||
|
||||
authStatusUpdate(params: AuthStatusUpdateNotification): Promise<void> {
|
||||
return this.inner.authStatusUpdate(params);
|
||||
}
|
||||
|
||||
extNotification(method: string, params: Record<string, unknown>): Promise<void> {
|
||||
return this.inner.extNotification(method, params);
|
||||
}
|
||||
}
|
||||
|
||||
function raceWithAbort<T>(operation: Promise<T>, signal: AbortSignal): Promise<T> {
|
||||
return new Promise<T>((resolve, reject) => {
|
||||
const cleanup = () => signal.removeEventListener("abort", onAbort);
|
||||
|
||||
35
src/v1/async-task-stop.ts
Normal file
35
src/v1/async-task-stop.ts
Normal file
@@ -0,0 +1,35 @@
|
||||
import { RequestError } from "@agentclientprotocol/sdk/unstable";
|
||||
|
||||
/** Stops one Claude background task without cancelling the parent prompt turn. */
|
||||
export const ASYNC_TASK_STOP_METHOD = "_session/async_task/stop";
|
||||
|
||||
export type AsyncTaskStopRequest = {
|
||||
sessionId: string;
|
||||
asyncTaskId: string;
|
||||
};
|
||||
|
||||
export type AsyncTaskStopResponse = {
|
||||
stopped: boolean;
|
||||
};
|
||||
|
||||
export function parseAsyncTaskStopRequest(value: unknown): AsyncTaskStopRequest {
|
||||
if (!value || typeof value !== "object" || Array.isArray(value)) {
|
||||
throw RequestError.invalidParams(undefined, "async task stop params must be an object");
|
||||
}
|
||||
const params = value as Record<string, unknown>;
|
||||
const sessionId = typeof params.sessionId === "string" ? params.sessionId.trim() : "";
|
||||
const asyncTaskId = typeof params.asyncTaskId === "string" ? params.asyncTaskId.trim() : "";
|
||||
if (!sessionId) {
|
||||
throw RequestError.invalidParams(
|
||||
undefined,
|
||||
"async task stop params require a non-empty sessionId",
|
||||
);
|
||||
}
|
||||
if (!asyncTaskId) {
|
||||
throw RequestError.invalidParams(
|
||||
undefined,
|
||||
"async task stop params require a non-empty asyncTaskId",
|
||||
);
|
||||
}
|
||||
return { sessionId, asyncTaskId };
|
||||
}
|
||||
129
src/v1/client-connection.ts
Normal file
129
src/v1/client-connection.ts
Normal file
@@ -0,0 +1,129 @@
|
||||
import {
|
||||
AgentContext,
|
||||
AuthStatusUpdateNotification,
|
||||
CompleteElicitationNotification,
|
||||
CreateElicitationRequest,
|
||||
CreateElicitationResponse,
|
||||
methods,
|
||||
ReadTextFileRequest,
|
||||
ReadTextFileResponse,
|
||||
RequestPermissionRequest,
|
||||
RequestPermissionResponse,
|
||||
SessionNotification,
|
||||
WriteTextFileRequest,
|
||||
WriteTextFileResponse,
|
||||
} from "@agentclientprotocol/sdk/unstable";
|
||||
import type { AcpClient } from "../acp-client.js";
|
||||
import { AcpSessionNotification, asSdkSessionNotification } from "../acp-subagents.js";
|
||||
import { ChangedMetaFilter } from "./tool-calls/changed-meta-filter.js";
|
||||
import type { AcpPermissionRequest } from "../permissions/presentation.js";
|
||||
|
||||
/**
|
||||
* Bridges {@link AcpClient} to the connection-scoped {@link AgentContext}
|
||||
* exposed by `AgentApp.connect(...)` as `connection.client`. The peer handle is
|
||||
* valid for the entire connection lifetime, so it is captured once at
|
||||
* construction.
|
||||
*/
|
||||
export class ClientConnection implements AcpClient {
|
||||
constructor(private readonly ctx: AgentContext) {}
|
||||
|
||||
sessionUpdate(params: AcpSessionNotification): Promise<void> {
|
||||
return this.ctx.notify(methods.client.session.update, asSdkSessionNotification(params));
|
||||
}
|
||||
|
||||
requestPermission(
|
||||
params: RequestPermissionRequest,
|
||||
signal?: AbortSignal,
|
||||
): Promise<RequestPermissionResponse> {
|
||||
return this.ctx.request(methods.client.session.requestPermission, params, {
|
||||
cancellationSignal: signal,
|
||||
stopWaitingOnCancel: true,
|
||||
});
|
||||
}
|
||||
|
||||
readTextFile(params: ReadTextFileRequest): Promise<ReadTextFileResponse> {
|
||||
return this.ctx.request(methods.client.fs.readTextFile, params);
|
||||
}
|
||||
|
||||
writeTextFile(params: WriteTextFileRequest): Promise<WriteTextFileResponse> {
|
||||
return this.ctx.request(methods.client.fs.writeTextFile, params);
|
||||
}
|
||||
|
||||
createElicitation(
|
||||
params: CreateElicitationRequest,
|
||||
signal?: AbortSignal,
|
||||
): Promise<CreateElicitationResponse> {
|
||||
return this.ctx.request(methods.client.elicitation.create, params, {
|
||||
cancellationSignal: signal,
|
||||
stopWaitingOnCancel: true,
|
||||
});
|
||||
}
|
||||
|
||||
completeElicitation(params: CompleteElicitationNotification): Promise<void> {
|
||||
return this.ctx.notify(methods.client.elicitation.complete, params);
|
||||
}
|
||||
|
||||
authStatusUpdate(params: AuthStatusUpdateNotification): Promise<void> {
|
||||
return this.ctx.notify(methods.client.auth.statusUpdate, params);
|
||||
}
|
||||
|
||||
extNotification(method: string, params: Record<string, unknown>): Promise<void> {
|
||||
return this.ctx.notify(method, params);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* The client that the agent talks to. For an AIR client, every session update
|
||||
* passes the {@link ChangedMetaFilter} last, after the native subagent routing,
|
||||
* so a `tool_call_update` carries only the `_meta` keys that changed. ACP
|
||||
* merges only the top-level tool call fields, not the `_meta` keys. So another
|
||||
* client gets the full `_meta` on each update.
|
||||
*/
|
||||
export class ChangedMetaClient implements AcpClient {
|
||||
private readonly filter = new ChangedMetaFilter();
|
||||
|
||||
constructor(
|
||||
private readonly inner: AcpClient,
|
||||
private readonly airClient: () => boolean,
|
||||
) {}
|
||||
|
||||
async sessionUpdate(params: AcpSessionNotification): Promise<void> {
|
||||
if (!this.airClient()) return this.inner.sessionUpdate(params);
|
||||
const update = this.filter.apply(params.update as SessionNotification["update"]);
|
||||
if (update) await this.inner.sessionUpdate({ ...params, update } as AcpSessionNotification);
|
||||
}
|
||||
|
||||
requestPermission(
|
||||
params: AcpPermissionRequest,
|
||||
signal?: AbortSignal,
|
||||
): Promise<RequestPermissionResponse> {
|
||||
return this.inner.requestPermission(params, signal);
|
||||
}
|
||||
|
||||
readTextFile(params: ReadTextFileRequest): Promise<ReadTextFileResponse> {
|
||||
return this.inner.readTextFile(params);
|
||||
}
|
||||
|
||||
writeTextFile(params: WriteTextFileRequest): Promise<WriteTextFileResponse> {
|
||||
return this.inner.writeTextFile(params);
|
||||
}
|
||||
|
||||
createElicitation(
|
||||
params: CreateElicitationRequest,
|
||||
signal?: AbortSignal,
|
||||
): Promise<CreateElicitationResponse> {
|
||||
return this.inner.createElicitation(params, signal);
|
||||
}
|
||||
|
||||
completeElicitation(params: CompleteElicitationNotification): Promise<void> {
|
||||
return this.inner.completeElicitation(params);
|
||||
}
|
||||
|
||||
authStatusUpdate(params: AuthStatusUpdateNotification): Promise<void> {
|
||||
return this.inner.authStatusUpdate(params);
|
||||
}
|
||||
|
||||
extNotification(method: string, params: Record<string, unknown>): Promise<void> {
|
||||
return this.inner.extNotification(method, params);
|
||||
}
|
||||
}
|
||||
73
src/v1/steering.ts
Normal file
73
src/v1/steering.ts
Normal file
@@ -0,0 +1,73 @@
|
||||
import { PromptRequest, RequestError } from "@agentclientprotocol/sdk/unstable";
|
||||
|
||||
/** Custom (extension) request method a client uses to steer the turn that is
|
||||
* currently running: the message is injected into the in-flight turn rather
|
||||
* than queued as a separate `session/prompt`. Named `_session/steering` per the
|
||||
* agreed ACP steering wire protocol; advertised to clients via the top-level
|
||||
* `InitializeResponse._meta.steering.supported`. */
|
||||
export const STEER_METHOD = "_session/steering";
|
||||
|
||||
/** How urgently the SDK delivers a steered message relative to the running
|
||||
* turn — an internal Claude implementation detail, not part of the wire
|
||||
* contract. `now` pre-empts the current generation, while `later` waits for a
|
||||
* pending permission/elicitation callback to settle instead of cancelling its
|
||||
* ACP request and hiding the client's user-input card (IJAI-1191). */
|
||||
export const STEER_PRIORITY_NOW = "now" as const;
|
||||
export const STEER_PRIORITY_LATER = "later" as const;
|
||||
|
||||
/** Request-level steering options. `promptRequired` is opt-in so existing Hosts
|
||||
* keep the established idle fallback behavior. */
|
||||
type SteerMeta = {
|
||||
[key: string]: unknown;
|
||||
steering?: {
|
||||
idleBehavior?: "promptRequired";
|
||||
};
|
||||
};
|
||||
|
||||
/** Params of a {@link STEER_METHOD} request. Shaped like the relevant subset of
|
||||
* a `PromptRequest` so the same `promptToClaude` conversion applies. Delivery
|
||||
* priority is deliberately NOT exposed here — it's an internal detail the agent
|
||||
* chooses (see {@link STEER_PRIORITY}). */
|
||||
export type SteerRequest = {
|
||||
sessionId: string;
|
||||
prompt: PromptRequest["prompt"];
|
||||
_meta?: SteerMeta | null;
|
||||
};
|
||||
|
||||
/** Result of a {@link STEER_METHOD} request. The legacy `startedNewTurn` result
|
||||
* remains the default idle behavior; `promptRequired` is returned only when the
|
||||
* Host explicitly opts into the host-owned fallback in request `_meta`. */
|
||||
export type SteerResponse =
|
||||
| { outcome: "injected" }
|
||||
| { outcome: "startedNewTurn" }
|
||||
| { outcome: "promptRequired"; reason: "noRunningTurn" };
|
||||
|
||||
/** Validate raw JSON-RPC params into a {@link SteerRequest}. Kept minimal — the
|
||||
* content blocks are handed to `promptToClaude`, which tolerates unknown block
|
||||
* types — but `sessionId` and a non-empty `prompt` array are required. */
|
||||
export function parseSteerRequest(params: unknown): SteerRequest {
|
||||
if (!params || typeof params !== "object") {
|
||||
throw RequestError.invalidParams(undefined, "steer params must be an object");
|
||||
}
|
||||
const { sessionId, prompt, _meta } = params as Record<string, unknown>;
|
||||
if (typeof sessionId !== "string" || sessionId.length === 0) {
|
||||
throw RequestError.invalidParams(undefined, "steer params require a non-empty sessionId");
|
||||
}
|
||||
if (!Array.isArray(prompt) || prompt.length === 0) {
|
||||
throw RequestError.invalidParams(undefined, "steer params require a non-empty prompt array");
|
||||
}
|
||||
const steering =
|
||||
_meta && typeof _meta === "object" ? (_meta as Record<string, unknown>).steering : undefined;
|
||||
const idleBehavior =
|
||||
steering && typeof steering === "object"
|
||||
? (steering as Record<string, unknown>).idleBehavior
|
||||
: undefined;
|
||||
if (idleBehavior !== undefined && idleBehavior !== "promptRequired") {
|
||||
throw RequestError.invalidParams(undefined, "unsupported steering idleBehavior");
|
||||
}
|
||||
return {
|
||||
sessionId,
|
||||
prompt: prompt as PromptRequest["prompt"],
|
||||
_meta: _meta as SteerMeta | null | undefined,
|
||||
};
|
||||
}
|
||||
Reference in New Issue
Block a user