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
17 changes: 15 additions & 2 deletions dist/index.cjs

Large diffs are not rendered by default.

23 changes: 22 additions & 1 deletion src/agents/claude.ts
Original file line number Diff line number Diff line change
Expand Up @@ -179,9 +179,17 @@ export class ClaudeAgentAdapter implements AgentAdapter {

async runStage<T>(args: RunStageArgs<T>): Promise<T> {
const controller = args.abortController ?? new AbortController();
// Track whether *our* timer fired the abort. The SDK throws a generic
// Error("Claude Code process aborted by user") for any abort and gives us
// no way to tell a wall-clock timeout from an external/user abort, so we
// record the cause here and reclassify the thrown error below.
let timedOut = false;
const timer =
args.timeoutMs != null
? setTimeout(() => controller.abort(), args.timeoutMs)
? setTimeout(() => {
timedOut = true;
controller.abort();
}, args.timeoutMs)
: null;

try {
Expand Down Expand Up @@ -269,6 +277,19 @@ export class ClaudeAgentAdapter implements AgentAdapter {
"agent_execution",
"claude session ended without a result message",
);
} catch (err) {
// A timeout-triggered abort reaches us as a raw SDK Error. Reclassify
// it as agent_timeout so the failure surfaces with the right kind
// instead of falling through to "internal" in the orchestrator.
// Externally-aborted runs and intentional AgentErrors pass through.
if (timedOut && !(err instanceof AgentError)) {
throw new AgentError(
"agent_timeout",
`claude session aborted after exceeding the ${args.timeoutMs}ms stage timeout`,
"timeout",
);
}
throw err;
} finally {
core.endGroup();
}
Expand Down
63 changes: 63 additions & 0 deletions test/agents/claude.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -166,6 +166,69 @@ describe("ClaudeAgentAdapter", () => {
expect(err.message).toMatch(/I'll classify this issue/);
});

it("maps a timeout-triggered abort to AgentError(agent_timeout)", async () => {
// The SDK throws a raw Error("...aborted by user") when its AbortController
// fires, with no way to distinguish a wall-clock timeout from a deliberate
// abort. When *our* timeout timer triggered the abort, the adapter must
// reclassify it as agent_timeout rather than letting it surface as internal.
(sdk as any).query.mockImplementation((opts: any) => {
const signal = opts.options.abortController.signal as AbortSignal;
return (async function* () {
yield { type: "system" };
await new Promise<void>((_resolve, reject) => {
const fail = () =>
reject(new Error("Claude Code process aborted by user"));
if (signal.aborted) fail();
else signal.addEventListener("abort", fail);
});
})();
});
const agent = new ClaudeAgentAdapter();
const err = await agent
.runStage({
systemPrompt: "S",
userPrompt: "U",
tools: [],
decisionSchema: Decision,
model: "claude-haiku",
timeoutMs: 5,
})
.catch((e) => e);
expect(err).toBeInstanceOf(AgentError);
expect(err.kind).toBe("agent_timeout");
});

it("does not reclassify an externally-triggered abort as a timeout", async () => {
const ctrl = new AbortController();
(sdk as any).query.mockImplementation((opts: any) => {
const signal = opts.options.abortController.signal as AbortSignal;
return (async function* () {
yield { type: "system" };
await new Promise<void>((_resolve, reject) => {
const fail = () =>
reject(new Error("Claude Code process aborted by user"));
if (signal.aborted) fail();
else signal.addEventListener("abort", fail);
});
})();
});
const agent = new ClaudeAgentAdapter();
const promise = agent
.runStage({
systemPrompt: "S",
userPrompt: "U",
tools: [],
decisionSchema: Decision,
model: "claude-haiku",
abortController: ctrl,
})
.catch((e) => e);
ctrl.abort();
const err = await promise;
expect(err).not.toBeInstanceOf(AgentError);
expect(err.message).toMatch(/aborted by user/);
});

it("throws agent_execution when no result message arrives", async () => {
(sdk as any).query.mockImplementation(() =>
(async function* () {
Expand Down
Loading