Skip to content

Commit eba3248

Browse files
committed
feat(cloud): add native steering to agent adapters
1 parent 46f300a commit eba3248

8 files changed

Lines changed: 273 additions & 28 deletions

File tree

packages/agent/src/adapters/claude/claude-agent.streamed-text.test.ts

Lines changed: 58 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -243,6 +243,64 @@ describe("ClaudeAcpAgent.prompt — streamed assistant text wiring", () => {
243243
]);
244244
});
245245

246+
it("keeps the original turn open until a pending steer is consumed", async () => {
247+
const { agent, client } = makeAgent();
248+
const sessionId = "s-steer-ordering";
249+
const { query, input } = installFakeSession(agent, sessionId);
250+
251+
const promptPromise = agent.prompt({
252+
sessionId,
253+
prompt: [{ type: "text", text: "use orange" }],
254+
});
255+
let promptSettled = false;
256+
void promptPromise.then(() => {
257+
promptSettled = true;
258+
});
259+
await tick();
260+
await echoUserMessage(query, input);
261+
262+
const steerResult = await agent.prompt({
263+
sessionId,
264+
prompt: [{ type: "text", text: "use green instead" }],
265+
_meta: { steer: true },
266+
});
267+
expect(steerResult._meta).toEqual({ steer: true });
268+
269+
await send(query, assistantMessage(sessionId, "msg_orange", "ORANGE"));
270+
await send(query, resultSuccess(sessionId));
271+
expect(promptSettled).toBe(false);
272+
273+
await echoUserMessage(query, input);
274+
await send(query, assistantMessage(sessionId, "msg_green", "GREEN"));
275+
await send(query, resultSuccess(sessionId));
276+
277+
await expect(promptPromise).resolves.toMatchObject({
278+
stopReason: "end_turn",
279+
});
280+
expect(messageChunkTexts(client.sessionUpdate.mock.calls)).toEqual([
281+
"ORANGE",
282+
"GREEN",
283+
]);
284+
});
285+
286+
it("declines an explicit steer after the active turn has ended", async () => {
287+
const { agent } = makeAgent();
288+
const sessionId = "s-expired-steer";
289+
installFakeSession(agent, sessionId);
290+
291+
await expect(
292+
agent.prompt({
293+
sessionId,
294+
prompt: [{ type: "text", text: "too late" }],
295+
_meta: { steer: true },
296+
}),
297+
).resolves.toMatchObject({ _meta: { steer: false } });
298+
299+
const session = (agent as unknown as { session: { turnQueue: unknown[] } })
300+
.session;
301+
expect(session.turnQueue).toHaveLength(0);
302+
});
303+
246304
it("reconnects a disconnected signed-commit server before the turn", async () => {
247305
const { agent } = makeAgent();
248306
const sessionId = "s-heal";

packages/agent/src/adapters/claude/claude-agent.ts

Lines changed: 29 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -482,12 +482,20 @@ export class ClaudeAcpAgent extends BaseAcpAgent {
482482
const hasInFlightTurns =
483483
this.session.activeTurn !== null || this.session.turnQueue.length > 0;
484484

485-
if (hasInFlightTurns && isSteerMeta(params._meta)) {
485+
const isSteer = isSteerMeta(params._meta);
486+
if (hasInFlightTurns && isSteer) {
486487
// Fold into the running turn (promptToClaude tagged it priority:"next");
487488
// the benign end_turn is ignored by clients, which key off _meta.steer.
489+
const owner =
490+
this.session.activeTurn ??
491+
this.session.turnQueue.find((turn) => !turn.settled);
492+
owner?.pendingSteerUuids.add(promptUuid);
488493
this.session.input.push(userMessage);
489494
await this.broadcastUserMessage(params);
490-
return { stopReason: "end_turn" };
495+
return { stopReason: "end_turn", _meta: { steer: true } };
496+
}
497+
if (isSteer) {
498+
return { stopReason: "end_turn", _meta: { steer: false } };
491499
}
492500

493501
if (!hasInFlightTurns && !isLocalOnlyCommand) {
@@ -507,6 +515,7 @@ export class ClaudeAcpAgent extends BaseAcpAgent {
507515

508516
const turn: Turn = {
509517
promptUuid,
518+
pendingSteerUuids: new Set(),
510519
isLocalOnlyCommand,
511520
commandName: commandMatch?.[1],
512521
broadcast: () => this.broadcastUserMessage(params),
@@ -1059,6 +1068,21 @@ export class ClaudeAcpAgent extends BaseAcpAgent {
10591068
},
10601069
);
10611070

1071+
if (
1072+
!isTaskNotification &&
1073+
session.activeTurn &&
1074+
session.activeTurn.pendingSteerUuids.size > 0
1075+
) {
1076+
this.logger.debug(
1077+
"Deferring turn completion until pending steers are consumed",
1078+
{
1079+
sessionId,
1080+
pendingSteers: session.activeTurn.pendingSteerUuids.size,
1081+
},
1082+
);
1083+
break;
1084+
}
1085+
10621086
if (
10631087
(message as { stop_reason?: string }).stop_reason === "refusal"
10641088
) {
@@ -1198,6 +1222,9 @@ export class ClaudeAcpAgent extends BaseAcpAgent {
11981222
// active one first), then drops from the feed. Runs before the
11991223
// cancelled guard so a turn enqueued after a cancel still starts.
12001224
if (message.type === "user" && "uuid" in message && message.uuid) {
1225+
if (session.activeTurn?.pendingSteerUuids.delete(message.uuid)) {
1226+
break;
1227+
}
12011228
const queued = session.turnQueue.find(
12021229
(t) => t.promptUuid === message.uuid && !t.settled,
12031230
);

packages/agent/src/adapters/claude/types.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -42,6 +42,7 @@ export type BackgroundTerminal =
4242
/** One in-flight `prompt()` call, settled by the session's consumer. */
4343
export type Turn = {
4444
promptUuid: string;
45+
pendingSteerUuids: Set<string>;
4546
isLocalOnlyCommand: boolean;
4647
commandName?: string;
4748
/** Invoked once at activation, matching the pre-consumer broadcast timing. */

packages/agent/src/adapters/codex-app-server/app-server-client.test.ts

Lines changed: 7 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,7 @@ import {
44
createBidirectionalStreams,
55
type StreamPair,
66
} from "../../utils/streams";
7-
import { AppServerClient } from "./app-server-client";
7+
import { AppServerClient, AppServerRequestError } from "./app-server-client";
88

99
interface RpcMessage {
1010
id?: number | string;
@@ -86,7 +86,12 @@ describe("AppServerClient", () => {
8686
error: { code: -32001, message: "Server overloaded; retry later." },
8787
});
8888

89-
await expect(pending).rejects.toThrow("Server overloaded; retry later.");
89+
const error = await pending.catch((requestError: unknown) => requestError);
90+
expect(error).toBeInstanceOf(AppServerRequestError);
91+
expect(error).toMatchObject({
92+
code: -32001,
93+
message: "Server overloaded; retry later.",
94+
});
9095
await client.close();
9196
});
9297

packages/agent/src/adapters/codex-app-server/app-server-client.ts

Lines changed: 18 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -24,6 +24,17 @@ export interface AppServerRpc {
2424
close(): Promise<void>;
2525
}
2626

27+
export class AppServerRequestError extends Error {
28+
constructor(
29+
readonly code: number,
30+
message: string,
31+
readonly data?: unknown,
32+
) {
33+
super(message);
34+
this.name = "AppServerRequestError";
35+
}
36+
}
37+
2738
/**
2839
* Bidirectional newline-delimited JSON-RPC client for the native Codex `app-server` subprocess.
2940
* Transport-agnostic via a {@link StreamPair} so tests can drive it over in-memory streams.
@@ -173,7 +184,13 @@ export class AppServerClient implements AppServerRpc {
173184
}
174185
this.pending.delete(message.id);
175186
if (message.error) {
176-
call.reject(new Error(message.error.message));
187+
call.reject(
188+
new AppServerRequestError(
189+
message.error.code,
190+
message.error.message,
191+
message.error.data,
192+
),
193+
);
177194
} else {
178195
call.resolve(message.result);
179196
}

packages/agent/src/adapters/codex-app-server/codex-app-server-agent.test.ts

Lines changed: 123 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,7 @@ import type {
1010
AppServerClientHandlers,
1111
AppServerRpc,
1212
} from "./app-server-client";
13+
import { AppServerRequestError } from "./app-server-client";
1314
import { CodexAppServerAgent } from "./codex-app-server-agent";
1415
import { sandboxPolicyFor } from "./session-config";
1516

@@ -48,6 +49,9 @@ function makeStubRpc(responses: Record<string, unknown>) {
4849
};
4950
}
5051
const response = responses[method];
52+
if (response instanceof Error) {
53+
throw response;
54+
}
5155
return (
5256
typeof response === "function"
5357
? await response(params)
@@ -2133,7 +2137,12 @@ describe("CodexAppServerAgent", () => {
21332137
prompt: [{ type: "text", text: "more context" }],
21342138
} as unknown as PromptRequest);
21352139

2136-
// The single turn/completed resolves both the original and the folded prompt.
2140+
await expect(second).resolves.toMatchObject({
2141+
stopReason: "end_turn",
2142+
_meta: { steer: true },
2143+
});
2144+
2145+
// The original prompt remains the sole owner of turn completion.
21372146
stub.emit("thread/tokenUsage/updated", {
21382147
tokenUsage: {
21392148
last: {
@@ -2145,12 +2154,11 @@ describe("CodexAppServerAgent", () => {
21452154
},
21462155
});
21472156
stub.emit("turn/completed", { turn: { status: "completed" } });
2148-
const [firstResult, secondResult] = await Promise.all([first, second]);
2157+
const firstResult = await first;
21492158
expect(firstResult).toMatchObject({
21502159
stopReason: "end_turn",
21512160
usage: { totalTokens: 45 },
21522161
});
2153-
expect(secondResult).toEqual({ stopReason: "end_turn" });
21542162

21552163
const steer = stub.requests.find((r) => r.method === "turn/steer");
21562164
expect(steer?.params).toMatchObject({
@@ -2164,6 +2172,118 @@ describe("CodexAppServerAgent", () => {
21642172
);
21652173
});
21662174

2175+
it("rejects a failed turn/steer without echoing or acknowledging it", async () => {
2176+
const stub = makeStubRpc({
2177+
"thread/start": { thread: { id: "t" } },
2178+
"turn/start": { turn: { id: "turn_1" } },
2179+
"turn/steer": new Error("steer transport failed"),
2180+
});
2181+
const { client, sessionUpdates } = makeFakeClient();
2182+
const agent = new CodexAppServerAgent(client, {
2183+
processOptions: { binaryPath: "/x/codex" },
2184+
rpcFactory: stub.factory,
2185+
});
2186+
2187+
await agent.newSession({ cwd: "/r" } as unknown as NewSessionRequest);
2188+
const first = agent.prompt({
2189+
sessionId: "t",
2190+
prompt: [{ type: "text", text: "one" }],
2191+
} as unknown as PromptRequest);
2192+
stub.emit("turn/started", { threadId: "t", turn: { id: "turn_1" } });
2193+
2194+
await expect(
2195+
agent.prompt({
2196+
sessionId: "t",
2197+
prompt: [{ type: "text", text: "lost steer" }],
2198+
_meta: { steer: true },
2199+
} as unknown as PromptRequest),
2200+
).rejects.toThrow("steer transport failed");
2201+
expect(sessionUpdates).not.toContainEqual(
2202+
expect.objectContaining({
2203+
update: expect.objectContaining({
2204+
sessionUpdate: "user_message_chunk",
2205+
content: { type: "text", text: "lost steer" },
2206+
}),
2207+
}),
2208+
);
2209+
2210+
stub.emit("turn/completed", { turn: { status: "completed" } });
2211+
await first;
2212+
});
2213+
2214+
it("declines a stale turn/steer so the caller can queue it normally", async () => {
2215+
const stub = makeStubRpc({
2216+
"thread/start": { thread: { id: "t" } },
2217+
"turn/start": { turn: { id: "turn_1" } },
2218+
"turn/steer": new AppServerRequestError(
2219+
-32600,
2220+
"expected active turn id `turn_1` but found `turn_2`",
2221+
),
2222+
});
2223+
const { client, sessionUpdates } = makeFakeClient();
2224+
const agent = new CodexAppServerAgent(client, {
2225+
processOptions: { binaryPath: "/x/codex" },
2226+
rpcFactory: stub.factory,
2227+
});
2228+
2229+
await agent.newSession({ cwd: "/r" } as unknown as NewSessionRequest);
2230+
const first = agent.prompt({
2231+
sessionId: "t",
2232+
prompt: [{ type: "text", text: "one" }],
2233+
} as unknown as PromptRequest);
2234+
stub.emit("turn/started", { threadId: "t", turn: { id: "turn_1" } });
2235+
2236+
await expect(
2237+
agent.prompt({
2238+
sessionId: "t",
2239+
prompt: [{ type: "text", text: "queue me" }],
2240+
_meta: { steer: true },
2241+
} as unknown as PromptRequest),
2242+
).resolves.toMatchObject({ _meta: { steer: false } });
2243+
expect(sessionUpdates).not.toContainEqual(
2244+
expect.objectContaining({
2245+
update: expect.objectContaining({
2246+
sessionUpdate: "user_message_chunk",
2247+
content: { type: "text", text: "queue me" },
2248+
}),
2249+
}),
2250+
);
2251+
2252+
stub.emit("turn/completed", { turn: { status: "completed" } });
2253+
await first;
2254+
});
2255+
2256+
it("declines an explicit steer after the active turn has ended", async () => {
2257+
const stub = makeStubRpc({
2258+
"thread/start": { thread: { id: "t" } },
2259+
});
2260+
const { client, sessionUpdates } = makeFakeClient();
2261+
const agent = new CodexAppServerAgent(client, {
2262+
processOptions: { binaryPath: "/x/codex" },
2263+
rpcFactory: stub.factory,
2264+
});
2265+
2266+
await agent.newSession({ cwd: "/r" } as unknown as NewSessionRequest);
2267+
await expect(
2268+
agent.prompt({
2269+
sessionId: "t",
2270+
prompt: [{ type: "text", text: "too late" }],
2271+
_meta: { steer: true },
2272+
} as unknown as PromptRequest),
2273+
).resolves.toMatchObject({ _meta: { steer: false } });
2274+
expect(
2275+
stub.requests.filter((request) => request.method === "turn/start"),
2276+
).toHaveLength(0);
2277+
expect(sessionUpdates).not.toContainEqual(
2278+
expect.objectContaining({
2279+
update: expect.objectContaining({
2280+
sessionUpdate: "user_message_chunk",
2281+
content: { type: "text", text: "too late" },
2282+
}),
2283+
}),
2284+
);
2285+
});
2286+
21672287
it("refreshes the live turnId from each turn/steer response", async () => {
21682288
const stub = makeStubRpc({
21692289
"thread/start": { thread: { id: "t" } },

0 commit comments

Comments
 (0)