Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
101 changes: 95 additions & 6 deletions electron/providers/claude-sdk-runtime.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3292,6 +3292,13 @@ export function resolveClaudeTurnStopReason(args: {
return args.currentStopReason;
}

export function resolveClaudeStreamTerminalStopReason(args: {
abortRequested: boolean;
currentStopReason?: string;
}): string | undefined {
return args.abortRequested ? "user_abort" : args.currentStopReason;
}

export function buildClaudeReadOnlyPromptOptions(args: {
cwd: string;
model: string;
Expand Down Expand Up @@ -4145,6 +4152,42 @@ export async function waitForClaudeMcpReadiness(args: {
: null;
}

function isClaudeInitialStartupMessage(message: SDKMessage) {
return (
message.type === "system" &&
(message as SDKSystemMessage).subtype === "init"
);
}

/**
* A streaming-input query can finish after SDK initialization but before it
* consumes the first queued user message. That startup-only close is safe to
* retry because the model has not produced output or invoked a tool yet.
*/
export async function* recoverClaudeStreamBeforeInitialTurnWork(args: {
initialStream: AsyncIterable<SDKMessage>;
createRecoveryStream: () => AsyncIterable<SDKMessage>;
isAbortRequested: () => boolean;
onRecovery?: () => void;
}): AsyncGenerator<SDKMessage> {
let startupOnly = true;
for await (const message of args.initialStream) {
if (!isClaudeInitialStartupMessage(message)) {
startupOnly = false;
}
yield message;
}

if (!startupOnly || args.isAbortRequested()) {
return;
}

args.onRecovery?.();
for await (const message of args.createRecoveryStream()) {
yield message;
}
}

type ClaudeMcpAuthenticateResult = {
authUrl?: unknown;
authorizationUrl?: unknown;
Expand Down Expand Up @@ -4689,9 +4732,10 @@ export async function streamClaudeWithSdk(
// responder is registered after that push for the same reason — a steer
// arriving during the gate must not jump ahead of the primary message.
inputQueue = new SteerableUserMessageQueue();
let queryOptions: Options | null = null;
const queryResult = queryFn({
prompt: inputQueue,
options: buildClaudeQueryOptions({
options: (queryOptions = buildClaudeQueryOptions({
cwd: runtimeCwd,
claudeExecutablePath,
runtimeOptions: args.runtimeOptions,
Expand Down Expand Up @@ -5086,13 +5130,15 @@ export async function streamClaudeWithSdk(
throw error;
}
},
}),
})),
}) as Query;
stream = queryResult;

// Register abort handler using the official Query.close() method
const gateAbort = new AbortController();
let abortRequested = false;
args.registerAbort?.(() => {
abortRequested = true;
gateAbort.abort();
inputQueue?.close();
stream?.close();
Expand Down Expand Up @@ -5126,8 +5172,16 @@ export async function streamClaudeWithSdk(
}
}

const initialPromptMessage = buildClaudeSDKUserMessage({
text: providerPrompt,
});
if (!gateAbort.signal.aborted) {
inputQueue.push(buildClaudeSDKUserMessage({ text: providerPrompt }));
const accepted = inputQueue.push(initialPromptMessage);
if (!accepted && !abortRequested) {
throw new Error(
"Claude input queue closed before the initial prompt was accepted.",
);
}
}
args.registerSteerResponder?.(async ({ text }) => {
if (
Expand Down Expand Up @@ -5164,8 +5218,39 @@ export async function streamClaudeWithSdk(
const claudeDebugStream =
args.runtimeOptions?.debug ?? process.env.STAVE_CLAUDE_DEBUG === "1";
const subagentTracker = new SubagentProgressTracker();
const recoverableStream = recoverClaudeStreamBeforeInitialTurnWork({
initialStream: queryResult,
isAbortRequested: () => abortRequested,
onRecovery: () => {
console.warn(
"[claude-sdk-runtime] Claude query closed before initial turn work; retrying with the prompt preloaded",
{ taskId: args.taskId },
);
},
createRecoveryStream: () => {
inputQueue?.close();
queryResult.close();

for await (const message of stream) {
const recoveryInputQueue = new SteerableUserMessageQueue();
if (!recoveryInputQueue.push(initialPromptMessage)) {
throw new Error(
"Claude recovery input queue closed before the initial prompt was accepted.",
);
}
if (!queryOptions) {
throw new Error("Claude query options were unavailable for recovery.");
}
inputQueue = recoveryInputQueue;
const recoveryQuery = queryFn({
prompt: recoveryInputQueue,
options: queryOptions,
}) as Query;
stream = recoveryQuery;
return recoveryQuery;
},
});

for await (const message of recoverableStream) {
if (
message.type === "system" &&
(message as SDKSystemMessage).subtype === "init"
Expand Down Expand Up @@ -5310,8 +5395,12 @@ export async function streamClaudeWithSdk(
}
}

const done: BridgeEvent = finalStopReason
? { type: "done", stop_reason: finalStopReason }
const terminalStopReason = resolveClaudeStreamTerminalStopReason({
abortRequested,
currentStopReason: finalStopReason,
});
const done: BridgeEvent = terminalStopReason
? { type: "done", stop_reason: terminalStopReason }
: { type: "done" };
if (eventCollector.overflowed) {
for (const overflowEvent of CLAUDE_OVERFLOW_TAIL_EVENTS) {
Expand Down
7 changes: 7 additions & 0 deletions electron/providers/runtime.ts
Original file line number Diff line number Diff line change
Expand Up @@ -979,6 +979,13 @@ async function runProviderTurn(
emittedCounts.set(key, (emittedCounts.get(key) ?? 0) + 1);
}
for (const event of events) {
// The shared lifecycle owns the final abort classification. A timed-out
// adapter can return its locally collected user-abort terminal after the
// live callback was correctly suppressed; replaying it here would hide
// the outer runtime_failure terminal.
if (abortRequested && event.type === "done") {
continue;
}
const key = JSON.stringify(event);
const remaining = emittedCounts.get(key) ?? 0;
if (remaining > 0) {
Expand Down
95 changes: 95 additions & 0 deletions tests/claude-sdk-runtime.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,8 @@ import {
mapClaudeMessageToEvents,
parseClaudeQuestionList,
parseClaudeRouteClassificationJson,
recoverClaudeStreamBeforeInitialTurnWork,
resolveClaudeStreamTerminalStopReason,
resolveClaudeTurnStopReason,
resolveClaudeDisallowedTools,
resolveClaudePlanModeApprovalScope,
Expand Down Expand Up @@ -587,6 +589,99 @@ describe("resolveClaudeTurnStopReason", () => {
});
});

describe("resolveClaudeStreamTerminalStopReason", () => {
test("preserves an abort when the SDK iterator closes without a result", () => {
expect(
resolveClaudeStreamTerminalStopReason({
abortRequested: true,
currentStopReason: undefined,
}),
).toBe("user_abort");
});

test("keeps the SDK stop reason when the turn was not aborted", () => {
expect(
resolveClaudeStreamTerminalStopReason({
abortRequested: false,
currentStopReason: "max_tokens",
}),
).toBe("max_tokens");
});
});

describe("recoverClaudeStreamBeforeInitialTurnWork", () => {
test("retries when the readiness query ends after init but before turn work", async () => {
async function* initialStream() {
yield { type: "system", subtype: "init", session_id: "cold-start" };
}
async function* recoveryStream() {
yield { type: "assistant", message: { content: [] } };
yield { type: "result", subtype: "success" };
}

let recoveryCount = 0;
const messages: Array<{ type: string }> = [];
for await (const message of recoverClaudeStreamBeforeInitialTurnWork({
initialStream: initialStream() as AsyncIterable<SDKMessage>,
createRecoveryStream: () => {
recoveryCount += 1;
return recoveryStream() as AsyncIterable<SDKMessage>;
},
isAbortRequested: () => false,
})) {
messages.push(message);
}

expect(recoveryCount).toBe(1);
expect(messages.map((message) => message.type)).toEqual([
"system",
"assistant",
"result",
]);
});

test("does not retry after the provider begins turn work", async () => {
async function* initialStream() {
yield { type: "system", subtype: "init", session_id: "started" };
yield { type: "assistant", message: { content: [] } };
}

let recoveryCount = 0;
for await (const _message of recoverClaudeStreamBeforeInitialTurnWork({
initialStream: initialStream() as AsyncIterable<SDKMessage>,
createRecoveryStream: () => {
recoveryCount += 1;
return initialStream() as AsyncIterable<SDKMessage>;
},
isAbortRequested: () => false,
})) {
// Consume the public stream boundary.
}

expect(recoveryCount).toBe(0);
});

test("does not retry a user-aborted startup", async () => {
async function* initialStream() {
yield { type: "system", subtype: "init", session_id: "aborted" };
}

let recoveryCount = 0;
for await (const _message of recoverClaudeStreamBeforeInitialTurnWork({
initialStream: initialStream() as AsyncIterable<SDKMessage>,
createRecoveryStream: () => {
recoveryCount += 1;
return initialStream() as AsyncIterable<SDKMessage>;
},
isAbortRequested: () => true,
})) {
// Consume the public stream boundary.
}

expect(recoveryCount).toBe(0);
});
});

describe("buildClaudeApprovalPermissionResult", () => {
test("returns an allow payload with updated input for approved tools", () => {
expect(
Expand Down
12 changes: 11 additions & 1 deletion tests/provider-lifecycle-contract.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -50,7 +50,9 @@ async function runMockAdapter(args: AdapterArgs) {
args.registerAbort?.(resolve);
});
adapterState.pendingDecisionCount = 0;
return [];
const doneEvent = { type: "done", stop_reason: "user_abort" } as const;
args.onEvent?.(doneEvent);
return [doneEvent];
}

const textEvent = { type: "text", text: "working" } as const;
Expand Down Expand Up @@ -175,6 +177,10 @@ for (const providerId of ["claude-code", "codex"] as const) {
expect(turn.events.filter((event) => event.type === "done")).toHaveLength(
1,
);
expect(turn.events.at(-1)).toEqual({
type: "done",
stop_reason: "runtime_failure",
});
expect(getProviderRuntimeLifecycleSnapshot()).toMatchObject({
activeSessionCount: 0,
activeStreamCount: 0,
Expand Down Expand Up @@ -211,6 +217,10 @@ for (const providerId of ["claude-code", "codex"] as const) {
expect(turn.events.filter((event) => event.type === "done")).toHaveLength(
1,
);
expect(turn.events.at(-1)).toEqual({
type: "done",
stop_reason: "runtime_failure",
});
expect(getProviderRuntimeLifecycleSnapshot()).toMatchObject({
activeSessionCount: 0,
activeStreamCount: 0,
Expand Down
Loading