diff --git a/examples/steering.ts b/examples/steering.ts new file mode 100644 index 00000000..f9f34e46 --- /dev/null +++ b/examples/steering.ts @@ -0,0 +1,444 @@ +#!/usr/bin/env tsx + +/** + * Steering demo — mid-turn edition. + * + * The plain `steering.ts` example steers a single-message answer ("count to + * 30"). There the injected prompt can only take effect *after* that message is + * finished: a turn made of one model step has no earlier boundary for Codex to + * inject at, so `turn/steer` appends the message and the model reads it on its + * next step — which is the end. + * + * This example instead gives Codex a genuinely multi-step task: a "treasure + * hunt" where each clue file only reveals the *name of the next clue*. Because + * the reads are sequential and dependent, the agent cannot batch them or read + * ahead — the turn contains several model steps. A steering message injected + * part-way through is therefore picked up *between* steps and visibly changes + * what the agent does next (it stops the hunt early). + * + * Run it with: + * node --import tsx examples/steering.ts + * # or: npm run example:steering:multistep + * + * Auth: uses your existing Codex login in ~/.codex. If you instead export + * CODEX_API_KEY / OPENAI_API_KEY it will authenticate with that. + * + * Knobs (env): + * STEERING_EXAMPLE_MODEL model id (default gpt-5.6-sol) + * STEER_AFTER_TOOL_CALLS inject the steer after N clue reads (default 2) + * NO_COLOR disable ANSI colors + */ + +import * as acp from "@agentclientprotocol/sdk"; +import {type ChildProcess, spawn} from "node:child_process"; +import {fileURLToPath} from "node:url"; +import {mkdtemp, rm, writeFile} from "node:fs/promises"; +import os from "node:os"; +import path from "node:path"; +import {Readable, Writable} from "node:stream"; + +const STEERING_METHOD = "_session/steering"; +const EXAMPLE_TIMEOUT_MS = 60_000; +const DEFAULT_EXAMPLE_MODEL = "gpt-5.6-sol"; +const exampleModel = process.env["STEERING_EXAMPLE_MODEL"] ?? DEFAULT_EXAMPLE_MODEL; + +const parsedSteerAfter = Number(process.env["STEER_AFTER_TOOL_CALLS"] ?? "2"); +const STEER_AFTER_TOOL_CALLS = Number.isFinite(parsedSteerAfter) && parsedSteerAfter > 0 + ? Math.floor(parsedSteerAfter) + : 2; + +const repositoryRoot = path.resolve(path.dirname(fileURLToPath(import.meta.url)), ".."); + +// One note per clue file. The hunt is CLUE_NOTES.length steps long. +const CLUE_NOTES = ["compass", "lantern", "map", "brass key", "torch", "rope", "chart", "chest"]; + +const initialPrompt = [ + "You're solving a treasure hunt inside this folder.", + "Start by reading the file `clue-1.txt`.", + "Each clue holds one note to remember and tells you the exact filename of the next clue.", + "Follow the trail, reading EXACTLY ONE clue per step — use a separate read for each file,", + "and do NOT list the folder or read several files at once.", + "Keep going until a clue tells you to STOP, then reply with the full ordered list of notes you collected.", +].join(" "); + +const steeringPrompt = [ + "Change of plan — stop the treasure hunt immediately.", + "Do not open any more clues.", + "Just tell me the notes you've collected so far and which clue number you stopped on.", +].join(" "); + +type SteeringRequest = { + sessionId: acp.SessionId; + prompt: acp.ContentBlock[]; +}; + +type SteeringResponse = { + outcome: "injected" | "startedNewTurn"; +}; + +type ThreadStatusType = "active" | "idle" | "systemError"; +type StateListener = () => void; + +let trackedSessionId: acp.SessionId | null = null; +const toolCallsSeen = new Set(); +let finishedTransitions = 0; +let lastChannel: string | null = null; +const stateListeners = new Set(); + +// --------------------------------------------------------------------------- +// Tiny ANSI helpers (no dependencies). Honors NO_COLOR and non-TTY output. +// --------------------------------------------------------------------------- +const useColor = Boolean(process.stdout.isTTY) && !process.env["NO_COLOR"]; +const paint = (code: string) => (text: string): string => (useColor ? `\x1b[${code}m${text}\x1b[0m` : text); +const c = { + bold: paint("1"), + dim: paint("2"), + red: paint("31"), + green: paint("32"), + yellow: paint("33"), + magenta: paint("35"), + cyan: paint("36"), +}; + +function isRecord(value: unknown): value is Record { + return typeof value === "object" && value !== null && !Array.isArray(value); +} + +function createAgentEnvironment(): NodeJS.ProcessEnv { + const configString = process.env["CODEX_CONFIG"]; + let config: Record = {}; + if (configString) { + const parsedConfig: unknown = JSON.parse(configString); + if (!isRecord(parsedConfig)) { + throw new Error("CODEX_CONFIG must contain a JSON object"); + } + config = parsedConfig; + } + return { + ...process.env, + CODEX_CONFIG: JSON.stringify({ + ...config, + model: exampleModel, + }), + }; +} + +function supportsSteering(response: acp.InitializeResponse): boolean { + const steering = response._meta?.["steering"]; + return isRecord(steering) && steering["supported"] === true; +} + +function readThreadStatus(update: acp.SessionUpdate): ThreadStatusType | undefined { + if (update.sessionUpdate !== "session_info_update") { + return undefined; + } + const codex = update._meta?.["codex"]; + if (!isRecord(codex)) { + return undefined; + } + const threadStatus = codex["threadStatus"]; + if (!isRecord(threadStatus)) { + return undefined; + } + const type = threadStatus["type"]; + return type === "active" || type === "idle" || type === "systemError" ? type : undefined; +} + +function notifyStateListeners(): void { + for (const listener of stateListeners) { + listener(); + } +} + +// --------------------------------------------------------------------------- +// Streaming output: group consecutive chunks of the same kind under a header +// so thinking / agent text / tool calls stay visually separated. +// --------------------------------------------------------------------------- +function writeChannel(channel: string, label: string, text: string): void { + if (lastChannel !== channel) { + process.stdout.write(`\n${label}\n`); + lastChannel = channel; + } + process.stdout.write(text); +} + +function writeEvent(line: string): void { + process.stdout.write(`\n${line}\n`); + lastChannel = null; +} + +function recordSessionUpdate(params: acp.SessionNotification): void { + if (params.sessionId !== trackedSessionId) { + return; + } + + const update = params.update; + switch (update.sessionUpdate) { + case "agent_message_chunk": + if (update.content.type === "text") { + writeChannel("message", c.bold(c.cyan("🤖 agent")), update.content.text); + } + break; + case "agent_thought_chunk": + if (update.content.type === "text") { + writeChannel("thought", c.dim("💭 thinking"), c.dim(update.content.text)); + } + break; + case "tool_call": { + const isNew = !toolCallsSeen.has(update.toolCallId); + toolCallsSeen.add(update.toolCallId); + writeEvent(c.yellow(`🔧 tool call #${toolCallsSeen.size}: ${update.title} [${update.status}]`)); + if (isNew) { + notifyStateListeners(); + } + break; + } + case "tool_call_update": + if (update.status) { + writeEvent(c.dim(` ↳ ${update.toolCallId} [${update.status}]`)); + } + break; + } + + const threadStatus = readThreadStatus(update); + if (threadStatus === "idle" || threadStatus === "systemError") { + finishedTransitions += 1; + notifyStateListeners(); + } +} + +async function waitForState( + predicate: () => boolean, + description: string, + timeoutMs = EXAMPLE_TIMEOUT_MS, +): Promise { + if (predicate()) { + return; + } + + await new Promise((resolve, reject) => { + const timeout = setTimeout(() => { + stateListeners.delete(checkState); + reject(new Error(`Timed out waiting for ${description}`)); + }, timeoutMs); + const checkState = (): void => { + if (!predicate()) { + return; + } + clearTimeout(timeout); + stateListeners.delete(checkState); + resolve(); + }; + stateListeners.add(checkState); + }); +} + +async function createTreasureHunt(): Promise<{workspaceDir: string; clueCount: number}> { + const workspaceDir = await mkdtemp(path.join(os.tmpdir(), "codex-steering-")); + const clueCount = CLUE_NOTES.length; + for (let index = 0; index < clueCount; index += 1) { + const step = index + 1; + const note = CLUE_NOTES[index]; + const isLast = step === clueCount; + const nextInstruction = isLast + ? "This is the final clue. STOP here — do not open any more files. Report every note you collected, in order." + : `When ready, read the next clue in the file named "clue-${step + 1}.txt".`; + const body = + `Treasure hunt — clue ${step} of ${clueCount}\n\n` + + `Note to remember #${step}: ${note}\n\n` + + `${nextInstruction}\n`; + await writeFile(path.join(workspaceDir, `clue-${step}.txt`), body, "utf8"); + } + return {workspaceDir, clueCount}; +} + +function printHeader(workspaceDir: string, clueCount: number): void { + const line = "─".repeat(66); + console.log(c.bold(`\n${line}`)); + console.log(c.bold(" Codex ACP — mid-turn steering demo")); + console.log(line); + console.log(` model : ${c.cyan(exampleModel)}`); + console.log(` workspace : ${c.dim(workspaceDir)}`); + console.log(` clue files : ${clueCount} (clue-1.txt … clue-${clueCount}.txt)`); + console.log(` steer after : ${STEER_AFTER_TOOL_CALLS} tool call(s)`); + console.log(line); + console.log(c.dim(" Task: follow the treasure-hunt chain, one clue at a time.")); + console.log(c.dim(" Mid-turn we inject a steering message telling it to stop early.")); + console.log(`${line}\n`); +} + +function printBanner(text: string): void { + const line = "═".repeat(66); + process.stdout.write(`\n${c.magenta(line)}\n${c.magenta(c.bold(` ${text}`))}\n${c.magenta(line)}\n`); + lastChannel = null; +} + +function printSummary(clueCount: number, cluesAtSteer: number, stopReason: string, steered: boolean): void { + const line = "─".repeat(66); + const stoppedEarly = toolCallsSeen.size < clueCount; + console.log(`\n\n${c.bold(line)}`); + console.log(c.bold(" Summary")); + console.log(line); + console.log(` tool calls total : ${toolCallsSeen.size} of up to ${clueCount} clues`); + console.log(` steered after : ${steered ? `${cluesAtSteer} clue(s)` : "not steered"}`); + console.log(` stop reason : ${stopReason}`); + console.log(line); + if (!steered) { + console.log(c.yellow(" • The turn finished before we could steer. Lower STEER_AFTER_TOOL_CALLS")); + console.log(c.yellow(" or use a slower model to catch the turn while it is still running.")); + } else if (stoppedEarly) { + console.log(c.green(" ✔ The agent stopped BEFORE reading every clue — the steering message")); + console.log(c.green(" was picked up mid-turn and changed its course.")); + } else { + console.log(c.yellow(" • The agent read every clue. Steering still applied, but the turn was")); + console.log(c.yellow(" short — try a longer chain (add CLUE_NOTES) or steer earlier.")); + } + console.log(`${line}\n`); +} + +async function stopAgent(agentProcess: ChildProcess): Promise { + if (agentProcess.stdin && !agentProcess.stdin.destroyed && !agentProcess.stdin.writableEnded) { + agentProcess.stdin.end(); + } + if (agentProcess.exitCode !== null || agentProcess.signalCode !== null) { + return; + } + + await new Promise((resolve) => { + const timeout = setTimeout(resolve, 2_000); + agentProcess.once("exit", () => { + clearTimeout(timeout); + resolve(); + }); + }); + if (agentProcess.exitCode === null && agentProcess.signalCode === null) { + agentProcess.kill(); + } +} + +async function main(): Promise { + const {workspaceDir, clueCount} = await createTreasureHunt(); + const npmCommand = process.platform === "win32" ? "npm.cmd" : "npm"; + const agentProcess = spawn(npmCommand, ["run", "--silent", "start"], { + cwd: repositoryRoot, + env: createAgentEnvironment(), + stdio: ["pipe", "pipe", "inherit"], + }); + if (!agentProcess.stdin || !agentProcess.stdout) { + throw new Error("Failed to open stdio pipes for the ACP agent"); + } + const stream = acp.ndJsonStream( + Writable.toWeb(agentProcess.stdin), + Readable.toWeb(agentProcess.stdout) as ReadableStream, + ); + + try { + await acp.client({name: "steering-multistep-example"}) + .onRequest(acp.methods.client.session.requestPermission, (ctx) => { + // A real client would prompt the user here. To keep the demo + // hands-free we auto-approve each read once. + const {toolCall, options} = ctx.params; + const allow = options.find((option) => option.kind === "allow_once") ?? options[0]; + if (!allow) { + return {outcome: {outcome: "cancelled"}}; + } + writeEvent(c.green(` ✔ auto-approving: ${toolCall.title ?? toolCall.toolCallId} → "${allow.name}"`)); + return {outcome: {outcome: "selected", optionId: allow.optionId}}; + }) + .onNotification(acp.methods.client.session.update, (ctx) => { + recordSessionUpdate(ctx.params); + }) + .connectWith(stream, async (agent) => { + const initializeResponse = await agent.request(acp.methods.agent.initialize, { + protocolVersion: acp.PROTOCOL_VERSION, + clientInfo: { + name: "steering-multistep-example", + version: "1.0.0", + }, + }); + if (!supportsSteering(initializeResponse)) { + throw new Error("The agent did not advertise steering support"); + } + + const apiKey = process.env["CODEX_API_KEY"] ?? process.env["OPENAI_API_KEY"]; + if (apiKey && initializeResponse.authMethods?.some((method) => method.id === "api-key")) { + await agent.request(acp.methods.agent.authenticate, { + methodId: "api-key", + _meta: { + "api-key": {apiKey}, + }, + }); + } + + const session = await agent.request(acp.methods.agent.session.new, { + cwd: workspaceDir, + mcpServers: [], + }); + trackedSessionId = session.sessionId; + + printHeader(workspaceDir, clueCount); + process.stdout.write(c.dim(`📤 prompt → ${initialPrompt}\n`)); + + let promptDone = false; + const promptPromise = agent.request(acp.methods.agent.session.prompt, { + sessionId: trackedSessionId, + prompt: [{type: "text", text: initialPrompt}], + }).finally(() => { + promptDone = true; + notifyStateListeners(); + }); + promptPromise.catch(() => {}); + + // Let the agent work through a couple of clues, then steer mid-turn. + await Promise.race([ + waitForState( + () => toolCallsSeen.size >= STEER_AFTER_TOOL_CALLS || promptDone, + `the agent to open ${STEER_AFTER_TOOL_CALLS} clue(s)`, + ).catch(() => {}), + promptPromise.then(() => undefined, () => undefined), + ]); + + const cluesAtSteer = toolCallsSeen.size; + const turnAlreadyFinished = promptDone || finishedTransitions > 0; + let steered = false; + + if (turnAlreadyFinished) { + writeEvent(c.red("⚠ The turn finished before we could steer — skipping the steering step.")); + } else { + steered = true; + printBanner(`Injecting steering message after ${cluesAtSteer} clue(s)`); + process.stdout.write(`${c.magenta(`✋ steer → ${steeringPrompt}`)}\n`); + lastChannel = null; + + const steeringResponse = await agent.request(STEERING_METHOD, { + sessionId: trackedSessionId, + prompt: [{type: "text", text: steeringPrompt}], + }); + if (steeringResponse.outcome !== "injected" && steeringResponse.outcome !== "startedNewTurn") { + throw new Error(`Unexpected steering response: ${JSON.stringify(steeringResponse)}`); + } + writeEvent(c.magenta(c.bold(` outcome: ${steeringResponse.outcome}`))); + if (steeringResponse.outcome === "injected") { + writeEvent(c.dim(" → injected into the running turn; the agent picks it up at its next step.")); + } else { + writeEvent(c.dim(" → the turn had already ended, so this started a fresh turn.")); + } + } + + const promptResponse = await promptPromise; + printSummary(clueCount, cluesAtSteer, promptResponse.stopReason, steered); + + await agent.request(acp.methods.agent.session.close, { + sessionId: trackedSessionId, + }); + }); + } finally { + await stopAgent(agentProcess); + await rm(workspaceDir, {recursive: true, force: true}); + } +} + +main().catch((error: unknown) => { + console.error("Steering multistep example failed:", error); + process.exitCode = 1; +}); diff --git a/examples/tsconfig.json b/examples/tsconfig.json new file mode 100644 index 00000000..b71b3eec --- /dev/null +++ b/examples/tsconfig.json @@ -0,0 +1,13 @@ +{ + "extends": "../tsconfig.json", + "compilerOptions": { + "rootDir": ".", + "declaration": false, + "declarationMap": false, + "sourceMap": false + }, + "include": [ + "steering.ts" + ], + "exclude": [] +} diff --git a/package.json b/package.json index 8aaa0b42..253b575b 100644 --- a/package.json +++ b/package.json @@ -33,11 +33,13 @@ "package:win-x64": "cd dist/bin && zip codex-acp-x64-windows.zip codex-acp-x64-windows.exe", "package:win-arm64": "cd dist/bin && zip codex-acp-arm64-windows.zip codex-acp-arm64-windows.exe", "start": "node --import tsx src/index.ts", + "example:steering": "node --import tsx examples/steering.ts", + "example:steering:multistep": "node --import tsx examples/steering.ts", "generate-types": "./node_modules/.bin/codex app-server generate-ts --out src/app-server", "test": "vitest run", "test:e2e": "npm run build && RUN_E2E_TESTS=true vitest run src/__tests__/CodexACPAgent/e2e", "test:watch": "vitest", - "typecheck": "tsc --noEmit", + "typecheck": "tsc --noEmit && tsc --noEmit -p examples/tsconfig.json", "codex-test": "tsx .claude/skills/run-codex/scripts/run-codex-test.ts" }, "homepage": "https://github.com/agentclientprotocol/codex-acp#readme", diff --git a/src/AcpExtensions.ts b/src/AcpExtensions.ts index 5a10ea5e..5d8e4602 100644 --- a/src/AcpExtensions.ts +++ b/src/AcpExtensions.ts @@ -1,5 +1,6 @@ import type { ClientContext, + ContentBlock, LoadSessionResponse, NewSessionResponse, ResumeSessionResponse, @@ -7,6 +8,7 @@ import type { } from "@agentclientprotocol/sdk"; export const LEGACY_SET_SESSION_MODEL_METHOD = "session/set_model"; +export const SESSION_STEERING_METHOD = "_session/steering"; export const GOAL_CONTROL_METHOD = "_codex/session/goal_control"; export type LegacySessionModel = { @@ -43,13 +45,15 @@ export type ExtMethodRequest = AuthenticationStatusRequest | AuthenticationLogoutRequest | LegacySetSessionModelExtRequest + | SessionSteeringExtRequest | GoalControlExtRequest export function isExtMethodRequest(request: { method: string, params: Record }): request is ExtMethodRequest { return request.method === "authentication/status" || request.method === "authentication/logout" || request.method === LEGACY_SET_SESSION_MODEL_METHOD - || request.method === GOAL_CONTROL_METHOD; + || request.method === GOAL_CONTROL_METHOD + || request.method === SESSION_STEERING_METHOD; } export type AuthenticationStatusRequest = { method: "authentication/status", params: {} } @@ -79,3 +83,24 @@ export async function legacySetSessionModel( ): Promise { return await connection.request(LEGACY_SET_SESSION_MODEL_METHOD, params); } + +export type SessionSteerRequest = { + sessionId: SessionId; + prompt: ContentBlock[]; +} + +export type SessionSteeringResponse = { + outcome: "injected" | "startedNewTurn" | "failed"; +} + +export type SessionSteeringExtRequest = { + method: typeof SESSION_STEERING_METHOD; + params: SessionSteerRequest; +} + +export async function steerSessionWithFallback( + connection: Pick, + params: SessionSteerRequest, +): Promise { + return await connection.request(SESSION_STEERING_METHOD, params); +} diff --git a/src/CodexAcpClient.ts b/src/CodexAcpClient.ts index b7664d36..67950100 100644 --- a/src/CodexAcpClient.ts +++ b/src/CodexAcpClient.ts @@ -37,6 +37,7 @@ import type { ThreadGoalStatus, ThreadSourceKind, TurnCompletedNotification, + TurnSteerResponse, UserInput, } from "./app-server/v2"; import packageJson from "../package.json"; @@ -833,6 +834,14 @@ export class CodexAcpClient { }); } + async steerTurn(params: { threadId: string, turnId: string, prompt: acp.ContentBlock[] }): Promise { + return await this.codexClient.turnSteer({ + threadId: params.threadId, + expectedTurnId: params.turnId, + input: buildPromptItems(params.prompt), + }); + } + async fetchAvailableModels(): Promise { const models: Model[] = []; let cursor: string | null = null; diff --git a/src/CodexAcpServer.ts b/src/CodexAcpServer.ts index 24a883e7..57ae1519 100644 --- a/src/CodexAcpServer.ts +++ b/src/CodexAcpServer.ts @@ -35,6 +35,7 @@ import { import type {TokenCount} from "./TokenCount"; import {toPromptUsage} from "./TokenCount"; import {CodexCommands} from "./CodexCommands"; +import {SteeringQueue} from "./SteeringQueue"; import type {QuotaMeta} from "./QuotaMeta"; import {logger} from "./Logger"; import {sanitizeMcpServerName} from "./McpServerName"; @@ -46,9 +47,12 @@ import { type LegacySessionModelState, type LegacySetSessionModelRequest, type LegacySetSessionModelResponse, + type SessionSteerRequest, + type SessionSteeringResponse, GOAL_CONTROL_METHOD, isExtMethodRequest, LEGACY_SET_SESSION_MODEL_METHOD, + SESSION_STEERING_METHOD, } from "./AcpExtensions"; import { createCollabAgentToolCallUpdate, @@ -163,6 +167,7 @@ export class CodexAcpServer { private readonly pendingMcpStartupSessions: Map; private readonly pendingTurnStarts: Map; private readonly activePrompts: Map; + private readonly steeringQueues: Map; private readonly closingSessions: Map; private readonly sessionGenerations: Map; private readonly sessionOpenGenerations: Map; @@ -178,6 +183,7 @@ export class CodexAcpServer { this.pendingMcpStartupSessions = new Map(); this.pendingTurnStarts = new Map(); this.activePrompts = new Map(); + this.steeringQueues = new Map(); this.closingSessions = new Map(); this.sessionGenerations = new Map(); this.sessionOpenGenerations = new Map(); @@ -238,6 +244,11 @@ export class CodexAcpServer { } }, authMethods: getCodexAuthMethods(_params.clientCapabilities), + _meta: { + steering: { + supported: true, + }, + }, }; } @@ -255,6 +266,8 @@ export class CodexAcpServer { } case LEGACY_SET_SESSION_MODEL_METHOD: return await this.unstable_setSessionModel(this.parseLegacySetSessionModelParams(methodRequest.params)); + case SESSION_STEERING_METHOD: + return await this.executeOrQueueSteeringRequest(this.parseSessionSteerParams(methodRequest.params)); case GOAL_CONTROL_METHOD: { const sessionState = this.sessions.get(methodRequest.params.sessionId); if (!sessionState) { @@ -589,6 +602,7 @@ export class CodexAcpServer { this.pendingMcpStartupSessions.delete(params.sessionId); this.pendingTurnStarts.delete(params.sessionId); this.activePrompts.delete(params.sessionId); + this.steeringQueues.delete(params.sessionId); } this.endSessionCloseFence(params.sessionId); } @@ -861,6 +875,233 @@ export class CodexAcpServer { }; } + /** + * Handles one incoming steering request, serialising it against any other + * steer already in flight for the same session. + * + * Every session gets its own {@link SteeringQueue}: the request is enqueued + * and awaited, so concurrent steers for one session run strictly one at a + * time, in arrival order, and can never race to inject into — or start — + * rival turns. Steers for different sessions use different queues and run + * concurrently. Once the queue drains to idle it is removed from the map, + * so no per-session entry leaks after the session goes quiet (the identity + * check guards against deleting a queue a later request has since reused). + * + * @param params The target session id and the prompt to steer with. + * @returns Whether the prompt joined the active turn ("injected"), started a + * new one ("startedNewTurn"), or could not be applied ("failed"); see + * {@link performSteeringRequest}. + */ + async executeOrQueueSteeringRequest(params: SessionSteerRequest): Promise { + const queue = this.getSteeringQueue(params.sessionId); + try { + return await queue.enqueue(params); + } catch (error) { + if (error instanceof RequestError) { + throw error; + } + logger.error(`Steering request for session ${params.sessionId} failed`, error); + return {outcome: "failed"}; + } finally { + if (queue.isIdle && this.steeringQueues.get(params.sessionId) === queue) { + this.steeringQueues.delete(params.sessionId); + } + } + } + + /** + * Returns the steering queue for a session, creating and registering it on + * first use. + * + * @param sessionId The session whose steering queue is required. + * @returns The session's existing queue, or a freshly created one. + */ + private getSteeringQueue(sessionId: string): SteeringQueue { + let queue = this.steeringQueues.get(sessionId); + if (!queue) { + queue = new SteeringQueue((params) => this.performSteeringRequest(params)); + this.steeringQueues.set(sessionId, queue); + } + return queue; + } + + /** + * Delivers a steering prompt to the session: injects it into the live turn + * when there is one, otherwise starts a new turn. + * + * @param params The target session id and the prompt to steer with. + * @returns "injected" when the prompt joined an existing turn, otherwise the + * outcome of starting a new turn. + */ + private async performSteeringRequest(params: SessionSteerRequest): Promise { + logger.log("Steering session requested", { + sessionId: params.sessionId, + prompt: params.prompt, + }); + const sessionState = this.getSessionState(params.sessionId); + this.assertSteerInputSupported(params, sessionState); + + const turnId = await this.getSteerableTurnId(sessionState); + if (turnId) { + const injected = await this.injectSteerIntoActiveTurn(params, turnId, sessionState); + if (injected) { + logger.log("Steering session injected", {sessionId: params.sessionId, turnId}); + return {outcome: "injected"}; + } + } + return await this.startNewTurnFromSteering(params); + } + + /** + * Rejects a steering prompt whose content the active model cannot accept + * (currently: image blocks on a text-only model). + */ + private assertSteerInputSupported(params: SessionSteerRequest, sessionState: SessionState): void { + const hasImage = params.prompt.some(block => block.type === "image"); + if (hasImage && !sessionState.supportedInputModalities.includes("image")) { + throw RequestError.invalidRequest("The current model does not support image input"); + } + } + + /** + * Attempts to inject the prompt into the given running turn. + * + * A failed injection is fatal only when the turn is still the session's + * current turn and Codex reported something other than "no active turn to + * steer". Otherwise the turn has already ended underneath us and the caller + * should start a new turn instead. + * + * @returns true when the prompt was injected; false when the caller should + * fall back to starting a new turn. + */ + private async injectSteerIntoActiveTurn( + params: SessionSteerRequest, + turnId: string, + sessionState: SessionState, + ): Promise { + try { + await this.runWithProcessCheck(() => this.codexAcpClient.steerTurn({ + threadId: params.sessionId, + turnId, + prompt: params.prompt, + })); + return true; + } catch (err) { + await this.codexAcpClient.waitForSessionNotifications(params.sessionId); + const turnStillActive = sessionState.currentTurnId === turnId; + if (turnStillActive && !this.isNoActiveTurnToSteerError(err)) { + throw err; + } + return false; + } + } + + /** + * Starts a new turn from a steering prompt when there is no live turn to + * inject into, and returns as soon as that turn is running. + * + * Waits for any previous prompt to drain first, then re-checks that the + * session is not closing — the await above is a window during which a close + * request can arrive. + * + * @param params The target session id and the prompt to steer with. + * @returns "startedNewTurn" once the turn is running; throws if the prompt + * fails or is cancelled before the turn starts. + */ + private async startNewTurnFromSteering(params: SessionSteerRequest): Promise { + // A prompt can outlive its turn (post-turn cleanup runs before it leaves + // activePrompts), so a steer can miss the turn while the prompt is still + // winding down. Starting a new turn now would run a second prompt on the + // same session, so wait for the current one to drain first (a no-op when idle). + const previousPrompt = this.activePrompts.get(params.sessionId); + await previousPrompt?.completion; + if (this.sessionIsClosing(params.sessionId)) { + throw RequestError.invalidRequest(`Session ${params.sessionId} is closing`); + } + + return await new Promise((resolve, reject) => { + let turnStarted = false; + const promptDone = this.prompt(params, undefined, () => { + turnStarted = true; + logger.log("Steering session started a new turn", {sessionId: params.sessionId}); + // The new turn is now running. This is the success path: answer the + // steer immediately ("a turn was started") and let prompt() finish the + // turn in the background. + resolve({outcome: "startedNewTurn"}); + }); + promptDone.then( + (response) => { + if (!turnStarted && response.stopReason === "cancelled") { + // The prompt ended without the turn ever starting, because it + // was cancelled. The steer never took, so fail the request. + reject(RequestError.invalidRequest(`Session ${params.sessionId} was cancelled before the steering turn started`)); + } else { + // Either the turn already started (this is a no-op after the + // resolve in the callback above), or the prompt finished + // without ever starting a turn and was not cancelled (e.g. a + // command-only turn). Both count as a successfully accepted steer. + resolve({outcome: "startedNewTurn"}); + } + }, + (error: unknown) => { + if (turnStarted) { + // The turn had already started, so the steer was already + // answered "startedNewTurn". This is a failure of a turn running + // in the background — nothing to return, just log it. + logger.error(`Steering-started prompt for session ${params.sessionId} failed`, error); + } else { + // The prompt failed before the turn started. The steer never + // took, so surface the failure to the caller. + reject(error); + } + }, + ); + }); + } + + private isNoActiveTurnToSteerError(error: unknown): boolean { + const messages = error instanceof Error ? [error.message] : []; + if (typeof error === "object" && error !== null && "data" in error) { + const data = (error as {data?: unknown}).data; + if (typeof data === "string") { + messages.push(data); + } else if (typeof data === "object" && data !== null && "details" in data) { + const details = (data as {details?: unknown}).details; + if (typeof details === "string") { + messages.push(details); + } + } + } + return messages.some(message => message.toLowerCase().includes("no active turn to steer")); + } + + private async getSteerableTurnId(sessionState: SessionState): Promise { + if (this.sessionIsClosing(sessionState.sessionId)) { + return null; + } + if (sessionState.currentTurnId) { + return sessionState.currentTurnId; + } + + const pendingTurnStart = this.pendingTurnStarts.get(sessionState.sessionId); + if (!pendingTurnStart) { + return null; + } + return await pendingTurnStart.promise; + } + + private parseSessionSteerParams(params: Record): SessionSteerRequest { + const sessionId = params["sessionId"]; + const prompt = params["prompt"]; + if (typeof sessionId !== "string" || !Array.isArray(prompt)) { + throw RequestError.invalidParams(); + } + return { + sessionId: sessionId, + prompt: prompt as acp.ContentBlock[], + }; + } + private createSessionConfigOptions(sessionState: SessionState): Array { const currentModelId = ModelId.fromString(sessionState.currentModelId); const configOptions = [ @@ -1609,7 +1850,11 @@ export class CodexAcpServer { return turnId; } - async prompt(params: acp.PromptRequest, signal?: AbortSignal): Promise { + async prompt( + params: acp.PromptRequest, + signal?: AbortSignal, + onTurnStarted?: () => void, + ): Promise { logger.log("Prompt received", { sessionId: params.sessionId, prompt: params.prompt, @@ -1662,6 +1907,7 @@ export class CodexAcpServer { } sessionState.currentTurnId = turnId; pendingTurnStart?.resolve(turnId); + onTurnStarted?.(); }, setConfigOption: async (configId, value) => { await this.applySessionConfigOption(sessionState, { @@ -1751,6 +1997,7 @@ export class CodexAcpServer { } sessionState.currentTurnId = turnId; pendingTurnStart?.resolve(turnId); + onTurnStarted?.(); }, () => this.promptShouldStop(params.sessionId, activePrompt), )); diff --git a/src/CodexAppServerClient.ts b/src/CodexAppServerClient.ts index aa88155d..eb26c83f 100644 --- a/src/CodexAppServerClient.ts +++ b/src/CodexAppServerClient.ts @@ -61,6 +61,8 @@ import type { TurnInterruptResponse, TurnStartParams, TurnStartResponse, + TurnSteerParams, + TurnSteerResponse, CommandExecutionRequestApprovalParams, CommandExecutionRequestApprovalResponse, FileChangeRequestApprovalParams, @@ -504,6 +506,10 @@ export class CodexAppServerClient { return await this.sendRequest({ method: "turn/interrupt", params: params }); } + async turnSteer(params: TurnSteerParams): Promise { + return await this.sendRequest({ method: "turn/steer", params: params }); + } + async reviewStart(params: ReviewStartParams): Promise { return await this.sendRequest({ method: "review/start", params: params }); } diff --git a/src/SteeringQueue.ts b/src/SteeringQueue.ts new file mode 100644 index 00000000..c1f553b1 --- /dev/null +++ b/src/SteeringQueue.ts @@ -0,0 +1,56 @@ +import type {SessionSteerRequest, SessionSteeringResponse} from "./AcpExtensions"; + +interface QueuedSteering { + params: SessionSteerRequest; + resolve: (response: SessionSteeringResponse) => void; + reject: (error: unknown) => void; +} + +/** + * Serialises steering requests for a single session. Callers add a request via + * enqueue(); a single consumer loop runs them one at a time, in arrival order, + * so two concurrent steers can never race to start rival turns. + */ +export class SteeringQueue { + private readonly pending: QueuedSteering[] = []; + private processing = false; + + constructor( + private readonly handle: (params: SessionSteerRequest) => Promise, + ) {} + + enqueue(params: SessionSteerRequest): Promise { + return new Promise((resolve, reject) => { + this.pending.push({params, resolve, reject}); + this.startConsumer(); + }); + } + + /** No request is queued and the consumer is not running. */ + get isIdle(): boolean { + return !this.processing && this.pending.length === 0; + } + + private startConsumer(): void { + if (this.processing) { + return; // consumer already draining the queue + } + this.processing = true; + void this.consume(); + } + + private async consume(): Promise { + try { + while (this.pending.length > 0) { + const next = this.pending.shift()!; + try { + next.resolve(await this.handle(next.params)); + } catch (error) { + next.reject(error); // one failed steer must not stall the rest + } + } + } finally { + this.processing = false; + } + } +} diff --git a/src/__tests__/CodexACPAgent/initialize.test.ts b/src/__tests__/CodexACPAgent/initialize.test.ts index 3180835b..9d6dc2b8 100644 --- a/src/__tests__/CodexACPAgent/initialize.test.ts +++ b/src/__tests__/CodexACPAgent/initialize.test.ts @@ -61,6 +61,11 @@ describe('CodexACPAgent - initialize', () => { }, }, authMethods: getCodexAuthMethods(), + _meta: { + steering: { + supported: true, + }, + }, }); }); diff --git a/src/__tests__/CodexACPAgent/steer-events.test.ts b/src/__tests__/CodexACPAgent/steer-events.test.ts new file mode 100644 index 00000000..c13a719b --- /dev/null +++ b/src/__tests__/CodexACPAgent/steer-events.test.ts @@ -0,0 +1,234 @@ +import {beforeEach, describe, expect, it, vi} from 'vitest'; +import type * as acp from "@agentclientprotocol/sdk"; +import {RequestError} from "@agentclientprotocol/sdk"; +import {createCodexMockTestFixture, createTestSessionState} from "../acp-test-utils"; +import type {SessionState} from "../../CodexAcpServer"; +import type {TurnCompletedNotification} from "../../app-server/v2"; +import {SESSION_STEERING_METHOD} from "../../AcpExtensions"; + +function createTurn(id: string, status: "inProgress" | "completed" | "interrupted") { + return { + id, + items: [], + itemsView: "notLoaded" as const, + status, + error: null, + startedAt: null, + completedAt: null, + durationMs: null, + }; +} + +function deferred(): {promise: Promise, resolve: (value: T) => void} { + let resolve: (value: T) => void = () => {}; + const promise = new Promise((innerResolve) => { + resolve = innerResolve; + }); + return {promise, resolve}; +} + +/** + * Drives a prompt to the point where a turn is active (in progress) and paused + * on turn completion, so a steer can be injected mid-turn. + */ +function startActiveTurn(sessionOverrides?: Partial) { + const mockFixture = createCodexMockTestFixture(); + const sessionState = createTestSessionState(sessionOverrides); + vi.spyOn(mockFixture.getCodexAppServerClient(), "turnStart").mockResolvedValue({ + turn: createTurn("turn-id", "inProgress"), + }); + const turnCompleted = deferred(); + vi.spyOn(mockFixture.getCodexAppServerClient(), "awaitTurnCompleted") + .mockReturnValue(turnCompleted.promise); + vi.spyOn(mockFixture.getCodexAcpAgent(), "getSessionState").mockReturnValue(sessionState); + return {mockFixture, sessionState, turnCompleted}; +} + +describe('_session/steering', () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + it('reports injected when the input joins the active turn', async () => { + const {mockFixture, sessionState, turnCompleted} = startActiveTurn(); + const turnSteerSpy = vi.spyOn(mockFixture.getCodexAppServerClient(), "turnSteer") + .mockResolvedValue({turnId: "turn-id"}); + + const promptPromise = mockFixture.getCodexAcpAgent().prompt({ + sessionId: "session-id", + prompt: [{type: "text", text: "long running prompt"}], + }); + await vi.waitFor(() => { + expect(sessionState.currentTurnId).toBe("turn-id"); + }); + + await expect(mockFixture.getCodexAcpAgent().extMethod(SESSION_STEERING_METHOD, { + sessionId: "session-id", + prompt: [{type: "text", text: "also keep backward compatibility"}], + })).resolves.toEqual({outcome: "injected"}); + + expect(turnSteerSpy).toHaveBeenCalledWith({ + threadId: "session-id", + expectedTurnId: "turn-id", + input: [{type: "text", text: "also keep backward compatibility", text_elements: []}], + }); + + turnCompleted.resolve({ + threadId: "session-id", + turn: createTurn("turn-id", "completed"), + }); + await expect(promptPromise).resolves.toMatchObject({stopReason: "end_turn"}); + }); + + it('starts a new turn when no turn is active', async () => { + const mockFixture = createCodexMockTestFixture(); + const sessionState = createTestSessionState(); + vi.spyOn(mockFixture.getCodexAcpAgent(), "getSessionState").mockReturnValue(sessionState); + const turnStartSpy = vi.spyOn(mockFixture.getCodexAppServerClient(), "turnStart") + .mockResolvedValue({turn: createTurn("new-turn-id", "inProgress")}); + const turnCompleted = deferred(); + vi.spyOn(mockFixture.getCodexAppServerClient(), "awaitTurnCompleted") + .mockReturnValue(turnCompleted.promise); + + await expect(mockFixture.getCodexAcpAgent().extMethod(SESSION_STEERING_METHOD, { + sessionId: "session-id", + prompt: [{type: "text", text: "too late for the previous turn"}], + })).resolves.toEqual({outcome: "startedNewTurn"}); + + expect(turnStartSpy).toHaveBeenCalledWith(expect.objectContaining({ + threadId: "session-id", + input: [{type: "text", text: "too late for the previous turn", text_elements: []}], + })); + + turnCompleted.resolve({ + threadId: "session-id", + turn: createTurn("new-turn-id", "completed"), + }); + await vi.waitFor(() => { + expect(sessionState.currentTurnId).toBeNull(); + }); + }); + + it('starts a new turn when Codex reports that the tracked turn is no longer active', async () => { + const {mockFixture, sessionState, turnCompleted} = startActiveTurn(); + const nextTurnCompleted = deferred(); + vi.spyOn(mockFixture.getCodexAppServerClient(), "turnStart") + .mockResolvedValueOnce({turn: createTurn("turn-id", "inProgress")}) + .mockResolvedValueOnce({turn: createTurn("new-turn-id", "inProgress")}); + vi.spyOn(mockFixture.getCodexAppServerClient(), "awaitTurnCompleted") + .mockReturnValueOnce(turnCompleted.promise) + .mockReturnValueOnce(nextTurnCompleted.promise); + vi.spyOn(mockFixture.getCodexAppServerClient(), "turnSteer").mockImplementation(async () => { + turnCompleted.resolve({ + threadId: "session-id", + turn: createTurn("turn-id", "completed"), + }); + throw Object.assign(new Error("Internal error"), { + data: {details: "no active turn to steer"}, + }); + }); + + const promptPromise = mockFixture.getCodexAcpAgent().prompt({ + sessionId: "session-id", + prompt: [{type: "text", text: "long running prompt"}], + }); + await vi.waitFor(() => { + expect(sessionState.currentTurnId).toBe("turn-id"); + }); + + await expect(mockFixture.getCodexAcpAgent().extMethod(SESSION_STEERING_METHOD, { + sessionId: "session-id", + prompt: [{type: "text", text: "racing follow-up"}], + })).resolves.toEqual({outcome: "startedNewTurn"}); + await expect(promptPromise).resolves.toMatchObject({stopReason: "end_turn"}); + expect(sessionState.currentTurnId).toBe("new-turn-id"); + + nextTurnCompleted.resolve({ + threadId: "session-id", + turn: createTurn("new-turn-id", "completed"), + }); + await vi.waitFor(() => { + expect(sessionState.currentTurnId).toBeNull(); + }); + }); + + it('serializes concurrent late steering requests without dropping either prompt', async () => { + const mockFixture = createCodexMockTestFixture(); + const sessionState = createTestSessionState(); + vi.spyOn(mockFixture.getCodexAcpAgent(), "getSessionState").mockReturnValue(sessionState); + vi.spyOn(mockFixture.getCodexAppServerClient(), "turnStart") + .mockResolvedValue({turn: createTurn("new-turn-id", "inProgress")}); + const turnCompleted = deferred(); + vi.spyOn(mockFixture.getCodexAppServerClient(), "awaitTurnCompleted") + .mockReturnValue(turnCompleted.promise); + const turnSteerSpy = vi.spyOn(mockFixture.getCodexAppServerClient(), "turnSteer") + .mockResolvedValue({turnId: "new-turn-id"}); + + const firstRequest = mockFixture.getCodexAcpAgent().extMethod(SESSION_STEERING_METHOD, { + sessionId: "session-id", + prompt: [{type: "text", text: "first late follow-up"}], + }); + const secondRequest = mockFixture.getCodexAcpAgent().extMethod(SESSION_STEERING_METHOD, { + sessionId: "session-id", + prompt: [{type: "text", text: "second late follow-up"}], + }); + + await expect(Promise.all([firstRequest, secondRequest])).resolves.toEqual([ + {outcome: "startedNewTurn"}, + {outcome: "injected"}, + ]); + expect(turnSteerSpy).toHaveBeenCalledWith({ + threadId: "session-id", + expectedTurnId: "new-turn-id", + input: [{type: "text", text: "second late follow-up", text_elements: []}], + }); + + turnCompleted.resolve({ + threadId: "session-id", + turn: createTurn("new-turn-id", "completed"), + }); + await vi.waitFor(() => { + expect(sessionState.currentTurnId).toBeNull(); + }); + }); + + it('reports failed instead of throwing when steering hits an unexpected error', async () => { + const mockFixture = createCodexMockTestFixture(); + vi.spyOn(mockFixture.getCodexAcpAgent(), "getSessionState").mockImplementation(() => { + throw new Error("unexpected boom"); + }); + + await expect(mockFixture.getCodexAcpAgent().extMethod(SESSION_STEERING_METHOD, { + sessionId: "session-id", + prompt: [{type: "text", text: "keep the agent alive"}], + })).resolves.toEqual({outcome: "failed"}); + }); + + it('rejects malformed steer params', async () => { + const mockFixture = createCodexMockTestFixture(); + + await expect(mockFixture.getCodexAcpAgent().extMethod(SESSION_STEERING_METHOD, { + sessionId: "session-id", + })).rejects.toThrow(RequestError); + }); + + it('rejects image input when the model does not support it', async () => { + const {mockFixture} = startActiveTurn({supportedInputModalities: ["text"]}); + const turnSteerSpy = vi.spyOn(mockFixture.getCodexAppServerClient(), "turnSteer"); + + const image: acp.ContentBlock = { + type: "image", + mimeType: "image/png", + data: "abc123", + }; + + const error = await mockFixture.getCodexAcpAgent().extMethod(SESSION_STEERING_METHOD, { + sessionId: "session-id", + prompt: [image], + }).catch((err: unknown) => err); + + expect(error).toBeInstanceOf(RequestError); + expect((error as RequestError).data).toContain("does not support image input"); + expect(turnSteerSpy).not.toHaveBeenCalled(); + }); +}); diff --git a/src/__tests__/SteeringQueue.test.ts b/src/__tests__/SteeringQueue.test.ts new file mode 100644 index 00000000..54b5e63b --- /dev/null +++ b/src/__tests__/SteeringQueue.test.ts @@ -0,0 +1,115 @@ +import {describe, expect, it} from "vitest"; +import type {SessionSteerRequest, SessionSteeringResponse} from "../AcpExtensions"; +import {SteeringQueue} from "../SteeringQueue"; + +function request(text: string): SessionSteerRequest { + return {sessionId: "session-id", prompt: [{type: "text", text}]}; +} + +function deferred(): {promise: Promise, resolve: (value: T) => void, reject: (error: unknown) => void} { + let resolve: (value: T) => void = () => {}; + let reject: (error: unknown) => void = () => {}; + const promise = new Promise((innerResolve, innerReject) => { + resolve = innerResolve; + reject = innerReject; + }); + return {promise, resolve, reject}; +} + +describe("SteeringQueue", () => { + it("runs enqueued requests one at a time in arrival order", async () => { + const order: string[] = []; + const queue = new SteeringQueue(async (params) => { + const text = (params.prompt[0] as {text: string}).text; + order.push(`start:${text}`); + await Promise.resolve(); + order.push(`end:${text}`); + return {outcome: "injected"}; + }); + + await Promise.all([ + queue.enqueue(request("a")), + queue.enqueue(request("b")), + queue.enqueue(request("c")), + ]); + + // Each request fully completes before the next one starts. + expect(order).toEqual([ + "start:a", "end:a", + "start:b", "end:b", + "start:c", "end:c", + ]); + }); + + it("never overlaps two handlers", async () => { + let active = 0; + let maxActive = 0; + const queue = new SteeringQueue(async () => { + active++; + maxActive = Math.max(maxActive, active); + await Promise.resolve(); + active--; + return {outcome: "injected"}; + }); + + await Promise.all(Array.from({length: 5}, (_, i) => queue.enqueue(request(`${i}`)))); + + expect(maxActive).toBe(1); + }); + + it("delivers each handler result to its own caller", async () => { + const outcomes: SessionSteeringResponse["outcome"][] = ["injected", "startedNewTurn", "injected"]; + let call = 0; + const queue = new SteeringQueue(async () => ({outcome: outcomes[call++]!})); + + const results = await Promise.all([ + queue.enqueue(request("a")), + queue.enqueue(request("b")), + queue.enqueue(request("c")), + ]); + + expect(results).toEqual([ + {outcome: "injected"}, + {outcome: "startedNewTurn"}, + {outcome: "injected"}, + ]); + }); + + it("rejects only the failing caller and keeps draining the rest", async () => { + const seen: string[] = []; + const queue = new SteeringQueue(async (params) => { + const text = (params.prompt[0] as {text: string}).text; + seen.push(text); + if (text === "boom") { + throw new Error("steer failed"); + } + return {outcome: "injected"}; + }); + + const first = queue.enqueue(request("ok")); + const failing = queue.enqueue(request("boom")); + const third = queue.enqueue(request("after")); + + await expect(first).resolves.toEqual({outcome: "injected"}); + await expect(failing).rejects.toThrow("steer failed"); + await expect(third).resolves.toEqual({outcome: "injected"}); + expect(seen).toEqual(["ok", "boom", "after"]); + }); + + it("reports isIdle before, during, and after processing", async () => { + const gate = deferred(); + const queue = new SteeringQueue(async () => { + await gate.promise; + return {outcome: "injected"}; + }); + + expect(queue.isIdle).toBe(true); + + const inFlight = queue.enqueue(request("a")); + expect(queue.isIdle).toBe(false); + + gate.resolve(); + await inFlight; + expect(queue.isIdle).toBe(true); + }); +}); diff --git a/src/index.ts b/src/index.ts index b2edd2c9..014801ff 100644 --- a/src/index.ts +++ b/src/index.ts @@ -12,7 +12,10 @@ import packageJson from "../package.json"; import {logger} from "./Logger"; import {runLoginCommand} from "./login"; import {runCodexCli} from "./CodexCli"; -import {GOAL_CONTROL_METHOD, LEGACY_SET_SESSION_MODEL_METHOD} from "./AcpExtensions"; +import { + GOAL_CONTROL_METHOD, LEGACY_SET_SESSION_MODEL_METHOD, + SESSION_STEERING_METHOD, +} from "./AcpExtensions"; const emptyExtensionParamsParser = z.preprocess( (params) => params ?? {}, @@ -24,6 +27,11 @@ const legacySetSessionModelParamsParser = z.object({ modelId: z.string(), }).passthrough(); +const sessionSteerParamsParser = z.object({ + sessionId: z.string(), + prompt: z.array(z.any()), +}).passthrough(); + const goalControlParamsParser = z.object({ sessionId: z.string(), action: z.enum(["pause", "clear"]), @@ -137,6 +145,7 @@ function startAcpServer() { .onRequest("authentication/status", emptyExtensionParamsParser, (ctx) => getAgent().extMethod("authentication/status", ctx.params)) .onRequest("authentication/logout", emptyExtensionParamsParser, (ctx) => getAgent().extMethod("authentication/logout", ctx.params)) .onRequest(LEGACY_SET_SESSION_MODEL_METHOD, legacySetSessionModelParamsParser, (ctx) => getAgent().extMethod(LEGACY_SET_SESSION_MODEL_METHOD, ctx.params)) + .onRequest(SESSION_STEERING_METHOD, sessionSteerParamsParser, (ctx) => getAgent().extMethod(SESSION_STEERING_METHOD, ctx.params)) .onRequest(GOAL_CONTROL_METHOD, goalControlParamsParser, (ctx) => getAgent().extMethod(GOAL_CONTROL_METHOD, ctx.params)) .connect(acpJsonStream); } diff --git a/tsconfig.json b/tsconfig.json index 39a4fd81..423a1d8f 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -27,5 +27,5 @@ "noUncheckedSideEffectImports": true, "skipLibCheck": true, }, - "exclude": [".claude"] + "exclude": [".claude", "examples"] }