Skip to content
Open
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
59 changes: 59 additions & 0 deletions apps/server/src/provider/Layers/ClaudeAdapter.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1508,6 +1508,65 @@ describe("ClaudeAdapterLive", () => {
);
});

it.effect(
"treats an externally signal-killed subprocess as interrupted, not a runtime error",
() => {
const harness = makeHarness();
return Effect.gen(function* () {
const adapter = yield* ClaudeAdapter;
const runtimeEvents: Array<ProviderRuntimeEvent> = [];

const runtimeEventsFiber = yield* Stream.runForEach(adapter.streamEvents, (event) =>
Effect.sync(() => {
runtimeEvents.push(event);
}),
).pipe(Effect.forkChild);

yield* adapter.startSession({
threadId: THREAD_ID,
provider: ProviderDriverKind.make("claudeAgent"),
runtimeMode: "full-access",
});

yield* adapter.sendTurn({
threadId: THREAD_ID,
input: "hello",
attachments: [],
});

// Model the desktop app's quit sequence delivering SIGTERM directly
// to the Claude CLI subprocess (e.g. via OS-level process-tree
// teardown), independently of and *before* our own graceful
// stopSession() flow ever runs. The real
// `@anthropic-ai/claude-agent-sdk` produces exactly this message
// (ProcessTransport#getProcessExitError) when its child process
// exits via a signal instead of a clean stopSession()-driven close.
harness.query.fail(new Error("Claude Code process terminated by signal SIGTERM"));

yield* Effect.yieldNow;
yield* Effect.yieldNow;
yield* Effect.yieldNow;
runtimeEventsFiber.interruptUnsafe();

const runtimeError = runtimeEvents.find((event) => event.type === "runtime.error");
assert.equal(
runtimeError,
undefined,
"a SIGTERM/SIGINT-terminated subprocess should never surface as a runtime.error",
);

const turnCompleted = runtimeEvents.find((event) => event.type === "turn.completed");
assert.equal(turnCompleted?.type, "turn.completed");
if (turnCompleted?.type === "turn.completed") {
assert.equal(turnCompleted.payload.state, "interrupted");
}
}).pipe(
Effect.provideService(Random.Random, makeDeterministicRandomService()),
Effect.provide(harness.layer),
);
},
);

it.effect("keeps Claude stream failure events structural", () => {
const harness = makeHarness();
return Effect.gen(function* () {
Expand Down
10 changes: 9 additions & 1 deletion apps/server/src/provider/Layers/ClaudeAdapter.ts
Original file line number Diff line number Diff line change
Expand Up @@ -282,7 +282,15 @@ function isClaudeInterruptedMessage(message: string): boolean {
return (
normalized.includes("all fibers interrupted without error") ||
normalized.includes("request was aborted") ||
normalized.includes("interrupted by user")
normalized.includes("interrupted by user") ||
// The Claude Agent SDK's ProcessTransport reports its child exiting this
// way whenever the subprocess is killed by SIGTERM/SIGINT — the signals
// used to request a graceful shutdown (e.g. desktop app quit). The OS can
// deliver that signal to the subprocess directly, ahead of our own
// stopSession()-driven close, so this case has to be recognized here too
// instead of relying solely on stopSession() winning the race.
normalized.includes("terminated by signal sigterm") ||
normalized.includes("terminated by signal sigint")
);
}

Expand Down
Loading