Compare commits

...

1 Commits

Author SHA1 Message Date
Ben Brandt
a6475c3e96 fix: report model cycles that Claude Code starts on its own as running and idle on the experimental ACP v2 surface
Claude Code runs model cycles that no prompt started: the follow-up after
a background command finishes, or its answer to a message from another
session. While the session had no turn, a v2 client got that output while
the session said idle. It now gets `running` before the cycle's first
output, `requires_action` while the cycle waits for the user, and `idle`
with the cycle's own stop reason and usage at its result.

The agent opens such a cycle (CycleEvents, through the new
`autonomousCycle` option) at the first output of the main conversation
while no turn is active or queued, and ends it at a result that no turn
took (not a placeholder result), at an idle that no earlier result owes,
or when the stream ends. A prompt that Claude Code takes in meanwhile takes
the cycle over. v1 and AIR clients are unchanged.
2026-10-12 12:54:25 +02:00
5 changed files with 548 additions and 27 deletions

View File

@@ -56,7 +56,7 @@ import {
WriteTextFileResponse,
ToolCallContent,
} from "@agentclientprotocol/sdk";
import type { StopReason, TurnEvents, TurnOutcome } from "./turn-events.js";
import type { CycleEvents, StopReason, TurnEvents, TurnOutcome } from "./turn-events.js";
import {
AccountInfo,
AgentInfo,
@@ -868,6 +868,11 @@ export type Session = {
/** The turn whose messages the consumer is currently attributing output to
* (the head of `turnQueue` once its user message has been echoed). */
activeTurn?: Turn | null;
/** The model cycle that Claude Code started on its own while the session had
* no turn, and that has not ended yet (see {@link CycleEvents}). Only set
* when the agent reports such cycles (`ClaudeAcpAgent` option
* `autonomousCycle`). */
autonomousCycle?: { events: CycleEvents; awaitingUser?: boolean };
/** Session-owned native checkpoint reporter. Turn state stays on each Turn. */
fileChangeReporter?: NativeFileChangeReporter;
/** Optimistic goal state published for a submitted `/goal` command whose
@@ -2540,6 +2545,8 @@ export class ClaudeAcpAgent {
* express: see {@link ToolCallClientCapabilities.v2}.
*/
private readonly v2: boolean;
/** See the `autonomousCycle` constructor option. */
private readonly autonomousCycleEvents?: (sessionId: string) => CycleEvents;
/** The session index (docs/air-extensions.md, "Session index"). */
private readonly sessionIndex = new sessionIndex.SessionIndexConnection({
agent: this,
@@ -2548,8 +2555,23 @@ export class ClaudeAcpAgent {
teardownSession: (sessionId) => this.teardownSession(sessionId),
});
constructor(client: AcpClient, logger?: Logger, options: { v2?: boolean } = {}) {
constructor(
client: AcpClient,
logger?: Logger,
options: {
v2?: boolean;
/**
* Called when Claude Code starts a model cycle on its own while a session
* has no turn, before the cycle's first output goes out. It returns where
* the agent reports that cycle (see {@link CycleEvents}). Without it, the
* cycle's output still goes out, and nothing reports the cycle itself: a
* v1 client has no state to report it with.
*/
autonomousCycle?: (sessionId: string) => CycleEvents;
} = {},
) {
this.v2 = options.v2 ?? false;
this.autonomousCycleEvents = options.autonomousCycle;
this.sessions = {};
this.client = new ChangedMetaClient(client, () => this.toolCallCapabilities.air.client);
this.logger = logger ?? console;
@@ -4282,6 +4304,9 @@ export class ClaudeAcpAgent {
* result isn't wrongly skipped. */
const activateTurn = (turn: Turn) => {
session.activeTurn = turn;
// A prompt that Claude Code takes in while a cycle it started on its own
// runs takes that cycle over (see CycleEvents): the turn reports the rest.
session.autonomousCycle = undefined;
session.cancelled = false;
compaction.resume();
if (turn.localCommand?.startsAtActivation) ensureLocalCommandMarkdown(turn);
@@ -4836,6 +4861,7 @@ export class ClaudeAcpAgent {
/** Reject every in-flight turn — used when the stream dies. */
const failAllTurns = (error: unknown) => {
disarmForceCancel(session);
this.endAutonomousCycle(session, { error });
const turns = session.activeTurn
? [session.activeTurn, ...(session.turnQueue ?? []).filter((t) => t !== session.activeTurn)]
: [...(session.turnQueue ?? [])];
@@ -5010,6 +5036,12 @@ export class ClaudeAcpAgent {
session.cancelled ? "stopped" : "failed",
"at end of stream",
);
this.endAutonomousCycle(
session,
session.cancelled
? { outcome: { stopReason: "cancelled" } }
: { error: RequestError.internalError(undefined, SESSION_ENDED_MESSAGE) },
);
await settleActive(
session.cancelled
? cancelledOutcome(session, inFlight)
@@ -5161,6 +5193,8 @@ export class ClaudeAcpAgent {
continue;
}
this.openAutonomousCycle(params.sessionId, session, message);
switch (message.type) {
case "system":
switch (message.subtype) {
@@ -5310,6 +5344,17 @@ export class ClaudeAcpAgent {
session.owedTrailingIdles = 0;
}
if (message.state === "idle") {
// A model cycle that Claude Code started on its own (see
// CycleEvents) normally ends at its result. An idle that no
// earlier result owes means that result will not come (the
// stream dropped mid-cycle, as for a turn in issue #825), so
// the cycle ends here. An owed idle belongs to an earlier
// result: it can lag past the cycle's first output.
if (session.autonomousCycle && session.owedTrailingIdles === 0) {
this.endAutonomousCycle(session, {
outcome: { stopReason: session.cancelled ? "cancelled" : "end_turn" },
});
}
// A non-cancelled turn normally settled at its terminal
// `result` already (issue #773), and that result recorded an
// owed trailing idle — absorbed here via the decrement. We
@@ -6175,6 +6220,17 @@ export class ClaudeAcpAgent {
});
}
// A model cycle that Claude Code started on its own (see
// CycleEvents) ends at its result, after its usage, also after a
// cancel. A turn that activates takes the cycle over, so a cycle
// still open here owns this result, whatever its origin says. A
// placeholder result (`num_turns: 0`, see the autonomous lane
// below) comes before the shared followup runs, so it ends
// nothing.
if (session.autonomousCycle && !(isAutonomousResult && message.num_turns === 0)) {
this.endAutonomousCycle(session, autonomousCycleEnd(message, session.cancelled));
}
if (session.cancelled) {
session.pendingExitPlanModeInterruption = undefined;
session.pendingExitPlanContextReset = undefined;
@@ -8372,12 +8428,46 @@ export class ClaudeAcpAgent {
* when a turn activates. */
private syncAwaitingUser(session: Session): void {
const turn = session.activeTurn;
if (!turn || turn.settled || !turn.insertedReported) return;
// With no turn, a model cycle that Claude Code started on its own reports
// it (see CycleEvents).
const work =
turn && !turn.settled ? (turn.insertedReported ? turn : undefined) : session.autonomousCycle;
if (!work) return;
const waiting = (session.pendingUserInputCount ?? 0) > 0;
if (waiting === (turn.awaitingUser ?? false)) return;
turn.awaitingUser = waiting;
if (waiting) turn.events.awaitingUser();
else turn.events.resumed();
if (waiting === (work.awaitingUser ?? false)) return;
work.awaitingUser = waiting;
if (waiting) work.events.awaitingUser();
else work.events.resumed();
}
/** Open a model cycle that Claude Code started on its own (see
* {@link CycleEvents}) when `message` is its first output: model output of
* the main conversation (not a subagent's) while the session has no turn,
* active or queued. A queued turn's output can come before its echo
* activates it, so output while a turn is queued stays that turn's. Output
* after a cancel is dropped, so it opens nothing. */
private openAutonomousCycle(sessionId: string, session: Session, message: SDKMessage): void {
if (!this.autonomousCycleEvents || session.autonomousCycle || session.cancelled) return;
if (message.type !== "assistant" && message.type !== "stream_event") return;
if (message.parent_tool_use_id !== null) return;
if (session.activeTurn && !session.activeTurn.settled) return;
if ((session.turnQueue ?? []).some((turn) => !turn.settled)) return;
session.autonomousCycle = { events: this.autonomousCycleEvents(sessionId) };
// A request can already be open, for example a background subagent's.
this.syncAwaitingUser(session);
}
/** End the session's open model cycle (see {@link CycleEvents}) with
* `outcome`, or as failed with `error`. */
private endAutonomousCycle(
session: Session,
end: { outcome: TurnOutcome } | { error: unknown },
): void {
const cycle = session.autonomousCycle;
if (!cycle) return;
session.autonomousCycle = undefined;
if ("outcome" in end) cycle.events.ended(end.outcome);
else cycle.events.failed(end.error);
}
/** Forward a permission request to the client, wiring the tool call's
@@ -10368,6 +10458,40 @@ function turnOutcome(
};
}
/** How a model cycle that Claude Code started on its own ended (see
* CycleEvents), read from its result as a turn's is: a refusal, the token
* limit, and the turn and budget limits are their stop reasons, an error
* result fails the cycle, and a cancel ends it `cancelled`. The usage is the
* result's own, since autonomous results stay out of the turn tally. */
function autonomousCycleEnd(
result: Extract<SDKMessage, { type: "result" }>,
cancelled: boolean,
): { outcome: TurnOutcome } | { error: unknown } {
// Typed non-null, but third-party backends have been observed omitting
// usage token fields (see snapshotFromUsage).
const tally: AccumulatedUsage = {
inputTokens: result.usage.input_tokens ?? 0,
outputTokens: result.usage.output_tokens ?? 0,
cachedReadTokens: result.usage.cache_read_input_tokens ?? 0,
cachedWriteTokens: result.usage.cache_creation_input_tokens ?? 0,
};
const usage = { ...tally, totalTokens: tallyTotal(tally) };
const ended = (stopReason: StopReason) => ({ outcome: { stopReason, usage } });
if (cancelled) return ended("cancelled");
if (result.stop_reason === "refusal") return ended("refusal");
if (result.stop_reason === "max_tokens") return ended("max_tokens");
if (result.is_error) {
const text =
result.subtype === "success" ? result.result : result.errors.join(", ") || result.subtype;
return { error: RequestError.internalError(undefined, text) };
}
return ended(
result.subtype === "success" || result.subtype === "error_during_execution"
? "end_turn"
: "max_turn_requests",
);
}
/** The outcome of `turn` when a cancel ends it. A turn that already has its
* result, because it is held open for its background subagents (see
* Turn.deferredSettle) or is settling (see Turn.settlingOutcome), keeps the

View File

@@ -1551,6 +1551,360 @@ describe("ACP v2 prompts", () => {
});
});
// Claude Code runs model cycles that no prompt started, for example its
// follow-up after a background command finished. v2 reports them as
// foreground work.
describe("ACP v2 model cycles that Claude Code starts on its own", () => {
/** The result of a cycle that a background task's notification started. */
const followupResult = (options: Options, overrides: Record<string, unknown> = {}) =>
result(options, { origin: { kind: "task-notification" }, ...overrides });
/** What the client saw of the turn of the prompt "hi" that ended. */
const endedTurn = ["user_message", "running", "agent_message_chunk", "idle end_turn"];
/**
* Sends the prompt "hi", runs `during`, waits until the client saw `idles`
* idle states, and returns the client.
*/
async function promptAndWait(
idles: number,
during?: (
agent: v2.ClientContext,
sessionId: string,
client: ReturnType<typeof v2Client>,
) => Promise<void>,
capabilities: v2.ClientCapabilities = {},
) {
const client = v2Client();
await client.app.connectWith(connectRouter(), async (agent) => {
await initializeV2(agent, capabilities);
const { sessionId } = await agent.request(v2.methods.agent.session.new, { cwd });
await agent.request(v2.methods.agent.session.prompt, { sessionId, prompt: text("hi") });
await during?.(agent, sessionId, client);
await vi.waitFor(() =>
expect(
turnTrace(client.sessionUpdates).filter((state) => state.startsWith("idle")),
).toHaveLength(idles),
);
await client.authUpdate(1);
});
return client;
}
it("reports a cycle after the turn ended as running, then idle with the cycle's usage", async () => {
scriptTurns(async function* (options) {
yield assistantText(options, "Started the build in the background.");
yield result(options);
yield idleState(options);
// The build finishes, and Claude Code wakes the model to report it.
yield assistantText(options, "The build passed.", "msg_followup");
yield followupResult(options, {
usage: {
input_tokens: 7,
output_tokens: 4,
cache_read_input_tokens: 0,
cache_creation_input_tokens: 0,
},
});
yield idleState(options);
});
const client = await promptAndWait(2);
expect(turnTrace(client.sessionUpdates)).toEqual([
...endedTurn,
"running",
"agent_message_chunk",
"idle end_turn",
]);
expect(client.updates("state_update").at(-1)).toMatchObject({
state: "idle",
stopReason: "end_turn",
usage: { inputTokens: 7, outputTokens: 4, totalTokens: 11 },
});
});
it("ends a cycle that the client cancels with idle cancelled", async () => {
const interrupted = Promise.withResolvers<void>();
sdk.interrupt = async () => interrupted.resolve();
scriptTurns(async function* (options) {
yield assistantText(options, "Started the build in the background.");
yield result(options);
yield idleState(options);
yield stream(options, {
type: "message_start",
message: { id: "msg_followup", role: "assistant", content: [], usage: {} },
});
yield stream(options, {
type: "content_block_delta",
index: 0,
delta: { type: "text_delta", text: "The build" },
});
await interrupted.promise;
yield followupResult(options);
yield idleState(options);
});
const client = await promptAndWait(2, async (agent, sessionId, client) => {
await vi.waitFor(() =>
expect(turnTrace(client.sessionUpdates)).toEqual([
...endedTurn,
"running",
"agent_message_chunk",
]),
);
await agent.notify(v2.methods.agent.session.cancel, { sessionId });
});
expect(turnTrace(client.sessionUpdates)).toEqual([
...endedTurn,
"running",
"agent_message_chunk",
"idle cancelled",
]);
});
it("lets a prompt that Claude Code takes in during a cycle take the cycle over", async () => {
const echoed = scriptTurns(
async function* (options) {
yield assistantText(options, "Started the build in the background.");
yield result(options);
yield idleState(options);
// The follow-up still runs when the next prompt comes in.
yield assistantText(options, "The build passed.", "msg_followup");
},
async function* (options) {
yield assistantText(options, "Deployed.", "msg_deploy");
yield result(options);
yield idleState(options);
},
);
const client = await promptAndWait(2, async (agent, sessionId, client) => {
await vi.waitFor(() =>
expect(turnTrace(client.sessionUpdates)).toEqual([
...endedTurn,
"running",
"agent_message_chunk",
]),
);
expect(
await agent.request(v2.methods.agent.session.prompt, { sessionId, prompt: text("deploy") }),
).toEqual({ messageId: echoed[1] });
});
// No idle between the cycle and the turn that took it over.
expect(turnTrace(client.sessionUpdates)).toEqual([
...endedTurn,
"running",
"agent_message_chunk",
"user_message",
"running",
"agent_message_chunk",
"idle end_turn",
]);
});
it("ends a cycle at its own result, not at a placeholder result or an idle owed to the turn", async () => {
scriptTurns(async function* (options) {
yield assistantText(options, "Started two builds in the background.");
yield result(options);
// The turn's idle comes late, after the follow-up started.
yield assistantText(options, "The first build passed.", "msg_first");
yield idleState(options);
// Claude Code answers queued notifications with one model call, and
// gives each but the last a placeholder result.
yield followupResult(options, { num_turns: 0 });
yield assistantText(options, "The second build passed too.", "msg_second");
yield followupResult(options);
yield idleState(options);
});
const client = await promptAndWait(2);
expect(turnTrace(client.sessionUpdates)).toEqual([
...endedTurn,
"running",
"agent_message_chunk",
"agent_message_chunk",
"idle end_turn",
]);
});
it("ends a cycle at an idle when its result never comes", async () => {
scriptTurns(async function* (options) {
yield assistantText(options, "Started the build in the background.");
yield result(options);
yield idleState(options);
yield assistantText(options, "The build", "msg_followup");
// The stream lost the cycle's result.
yield idleState(options);
});
const client = await promptAndWait(2);
expect(turnTrace(client.sessionUpdates)).toEqual([
...endedTurn,
"running",
"agent_message_chunk",
"idle end_turn",
]);
expect(client.updates("state_update").at(-1)).not.toHaveProperty("usage");
});
it("reports a cycle that waits on a question as requires_action", async () => {
scriptTurns(async function* (options) {
yield assistantText(options, "Started the build in the background.");
yield result(options);
yield idleState(options);
yield assistantText(options, "The build failed. One question first.", "msg_followup");
await options.onElicitation!(
{
serverName: "linear",
message: "Which team?",
mode: "form",
requestedSchema: { type: "object", properties: { team: { type: "string" } } },
},
{ signal: new AbortController().signal } as Parameters<
NonNullable<Options["onElicitation"]>
>[1],
);
yield followupResult(options);
yield idleState(options);
});
const client = await promptAndWait(2, undefined, { elicitation: { form: {} } });
expect(turnTrace(client.sessionUpdates)).toEqual([
...endedTurn,
"running",
"agent_message_chunk",
"requires_action",
"running",
"idle end_turn",
]);
});
it("reports a cycle whose result is an error as idle with the error stop reason", async () => {
scriptTurns(async function* (options) {
yield assistantText(options, "Started the build in the background.");
yield result(options);
yield idleState(options);
yield assistantText(options, "Looking at the build.", "msg_followup");
yield followupResult(options, { is_error: true, result: "API Error: 529 Overloaded" });
yield idleState(options);
});
const client = await promptAndWait(2);
expect(turnTrace(client.sessionUpdates)).toEqual([
...endedTurn,
"running",
"agent_message_chunk",
"idle error",
]);
expect(client.updates("state_update").at(-1)).toMatchObject({
error: { code: -32603, message: expect.stringContaining("API Error: 529 Overloaded") },
});
});
it.each([
["ends", false],
["fails", true],
])("ends a cycle with an error when the stream %s during it", async (_, fails) => {
sdk.run = async function* (prompts, options) {
const { value } = await prompts[Symbol.asyncIterator]().next();
yield {
type: "user",
message: value!.message,
parent_tool_use_id: null,
uuid: value!.uuid,
session_id: options.sessionId,
isReplay: true,
};
yield assistantText(options, "Started the build in the background.");
yield result(options);
yield idleState(options);
yield assistantText(options, "The build", "msg_followup");
// Claude Code exits, or the stream breaks.
if (fails) throw new Error("stream broke");
};
const client = await promptAndWait(2);
expect(turnTrace(client.sessionUpdates)).toEqual([
...endedTurn,
"running",
"agent_message_chunk",
"idle error",
]);
});
it("leaves output that comes before a queued prompt's echo to that prompt", async () => {
sdk.run = async function* (prompts, options) {
const input = prompts[Symbol.asyncIterator]();
const echo = (value: SDKUserMessage) => ({
type: "user",
message: value.message,
parent_tool_use_id: null,
uuid: value.uuid,
session_id: options.sessionId,
isReplay: true,
});
yield echo((await input.next()).value);
yield assistantText(options, "Started the build in the background.");
yield result(options);
yield idleState(options);
// The next prompt's answer starts streaming before its echo.
const next = (await input.next()).value;
yield stream(options, {
type: "message_start",
message: { id: "msg_deploy", role: "assistant", content: [], usage: {} },
});
yield stream(options, {
type: "content_block_delta",
index: 0,
delta: { type: "text_delta", text: "Deploying" },
});
yield echo(next);
yield result(options);
yield idleState(options);
while (!(await input.next()).done);
};
const client = await promptAndWait(2, async (agent, sessionId, client) => {
await vi.waitFor(() => expect(turnTrace(client.sessionUpdates)).toEqual(endedTurn));
await agent.request(v2.methods.agent.session.prompt, { sessionId, prompt: text("deploy") });
});
expect(turnTrace(client.sessionUpdates)).toEqual([
...endedTurn,
"agent_message_chunk",
"user_message",
"running",
"idle end_turn",
]);
});
it("leaves a subagent's output after the turn to the session's background", async () => {
scriptTurns(async function* (options) {
yield assistantText(options, "Started a background review.");
yield result(options);
yield idleState(options);
yield {
...assistantText(options, "Reviewing.", "msg_subagent"),
parent_tool_use_id: "toolu_agent",
};
// Claude Code goes idle again while the subagent works. That idle would
// end a cycle that the subagent's output had opened.
yield idleState(options);
yield assistantText(options, "The review is done.", "msg_followup");
yield followupResult(options);
yield idleState(options);
});
const client = await promptAndWait(2);
// The cycle starts at the main conversation's output, not the subagent's.
expect(turnTrace(client.sessionUpdates)).toEqual([
...endedTurn,
"running",
"agent_message_chunk",
"idle end_turn",
]);
});
});
describe("ACP v2 session translation", () => {
it("renames the group id of grouped select options", () => {
expect(

View File

@@ -48,6 +48,16 @@ export interface TurnEvents {
failed(error: unknown): void;
}
/**
* A model cycle that Claude Code started on its own while the session had no
* turn: for example, the model's follow-up after a background command
* finished, or its answer to a message from another session. No prompt
* started it, so it reports like a turn after {@link TurnEvents.inserted}: it
* may wait for the user, and it ends or fails once. A prompt that Claude Code
* takes in while it runs takes it over, and the cycle reports nothing more.
*/
export type CycleEvents = Omit<TurnEvents, "inserted">;
/** Why a turn ended. Every ACP version has these reasons. */
export type StopReason = "end_turn" | "max_tokens" | "max_turn_requests" | "refusal" | "cancelled";

View File

@@ -19,7 +19,7 @@ import type { AcpSessionNotification } from "../acp-subagents.js";
import type { AcpPermissionRequest } from "../permissions/presentation.js";
import { v1InitializeRequest, v2InitializeResponse } from "./initialize.js";
import { v1PermissionResponse, v2PermissionRequest } from "./permission.js";
import { v2Prompt } from "./prompt.js";
import { v2AutonomousCycle, v2Prompt } from "./prompt.js";
import {
v1ForkSessionRequests,
v1NewSessionRequest,
@@ -52,7 +52,13 @@ export function v2AgentApp(
.agent({ name: "claude-code-acp" })
.onConnect((connection) => {
client = new V2ClientConnection(connection.client, logger ?? console);
agent = new ClaudeAcpAgent(client, logger, { v2: true });
agent = new ClaudeAcpAgent(client, logger, {
v2: true,
autonomousCycle: (sessionId) =>
v2AutonomousCycle((update) => {
void client.send({ sessionId, update });
}),
});
onAgent(agent);
})
.onRequest(v2.methods.agent.initialize, async ({ params }) =>

View File

@@ -13,7 +13,7 @@ import {
} from "@agentclientprotocol/sdk";
import * as v2 from "@agentclientprotocol/sdk/experimental/v2";
import type { ClaudeAcpAgent } from "../acp-agent.js";
import type { StopReason, TurnEvents, TurnOutcome } from "../turn-events.js";
import type { CycleEvents, StopReason, TurnEvents, TurnOutcome } from "../turn-events.js";
/**
* Starts a turn for `params`, and answers when Claude Code takes the prompt
@@ -45,6 +45,7 @@ export function v2Prompt(
send: (update: v2.SessionUpdate) => void,
): Promise<v2.PromptResponse> {
return new Promise((resolve, reject) => {
const work = foregroundWork(send);
let inserted = false;
const events: TurnEvents = {
inserted(messageId) {
@@ -53,15 +54,11 @@ export function v2Prompt(
send({ sessionUpdate: "state_update", state: "running" });
resolve({ messageId });
},
awaitingUser() {
send({ sessionUpdate: "state_update", state: "requires_action" });
},
resumed() {
send({ sessionUpdate: "state_update", state: "running" });
},
awaitingUser: work.awaitingUser,
resumed: work.resumed,
ended(outcome) {
if (inserted) {
send(idle(outcome));
work.ended(outcome);
} else if (outcome.stopReason === "cancelled") {
reject(RequestError.requestCancelled());
} else {
@@ -74,16 +71,8 @@ export function v2Prompt(
}
},
failed(error) {
if (!inserted) {
reject(error);
return;
}
send({
sessionUpdate: "state_update",
state: "idle",
stopReason: "error",
error: jsonRpcError(error),
});
if (inserted) work.failed(error);
else reject(error);
},
};
let request: PromptRequest;
@@ -97,6 +86,44 @@ export function v2Prompt(
});
}
/**
* Reports a model cycle that Claude Code started on its own while the session
* had no turn (see {@link CycleEvents}), such as its follow-up after a
* background command finished. v2 reports foreground work whether or not a
* prompt started it: `running` now, then the cycle's states as for a prompt's
* turn after it was taken in.
*/
export function v2AutonomousCycle(send: (update: v2.SessionUpdate) => void): CycleEvents {
send({ sessionUpdate: "state_update", state: "running" });
return foregroundWork(send);
}
/**
* The states of foreground work once it runs: `requires_action` while it
* waits for the user, `running` again after, and `idle` when it ends or fails.
*/
function foregroundWork(send: (update: v2.SessionUpdate) => void): CycleEvents {
return {
awaitingUser() {
send({ sessionUpdate: "state_update", state: "requires_action" });
},
resumed() {
send({ sessionUpdate: "state_update", state: "running" });
},
ended(outcome) {
send(idle(outcome));
},
failed(error) {
send({
sessionUpdate: "state_update",
state: "idle",
stopReason: "error",
error: jsonRpcError(error),
});
},
};
}
/** The JSON-RPC error that v1 answers a failed prompt with. */
function jsonRpcError(error: unknown): { code: number; message: string; data?: unknown } {
if (error instanceof RequestError) {