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

it.effect("treats aborted_tools results as interrupted and hides ede_diagnostic errors", () => {
const harness = makeHarness();
return Effect.gen(function* () {
const adapter = yield* ClaudeAdapter;

const runtimeEventsFiber = yield* Stream.take(adapter.streamEvents, 6).pipe(
Stream.runCollect,
Effect.forkChild,
);

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

const turn = yield* adapter.sendTurn({
threadId: session.threadId,
input: "hello",
attachments: [],
});

// Exact shape the CLI emits when Stop lands mid-tool-call: is_error
// is true and the only error is internal diagnostic telemetry.
harness.query.emit({
type: "result",
subtype: "error_during_execution",
is_error: true,
errors: ["[ede_diagnostic] result_type=user last_content_type=n/a stop_reason=tool_use"],
stop_reason: "tool_use",
terminal_reason: "aborted_tools",
session_id: "sdk-session-abort-tools",
uuid: "result-abort-tools",
} as unknown as SDKMessage);

const runtimeEvents = Array.from(yield* Fiber.join(runtimeEventsFiber));
assert.deepEqual(
runtimeEvents.map((event) => event.type),
[
"session.started",
"session.configured",
"session.state.changed",
"turn.started",
"thread.started",
"turn.completed",
],
);

const turnCompleted = runtimeEvents[runtimeEvents.length - 1];
assert.equal(turnCompleted?.type, "turn.completed");
if (turnCompleted?.type === "turn.completed") {
assert.equal(String(turnCompleted.turnId), String(turn.turnId));
assert.equal(turnCompleted.payload.state, "interrupted");
assert.equal(turnCompleted.payload.errorMessage, undefined);
}
}).pipe(
Effect.provideService(Random.Random, makeDeterministicRandomService()),
Effect.provide(harness.layer),
);
});

it.effect("interruptTurn stops every live task before interrupting the turn", () => {
const harness = makeHarness();
return Effect.gen(function* () {
Expand Down
24 changes: 23 additions & 1 deletion apps/server/src/provider/Layers/ClaudeAdapter.ts
Original file line number Diff line number Diff line change
Expand Up @@ -348,7 +348,29 @@ function resultErrorsText(result: SDKResultMessage): string {
: "";
}

/**
* First user-facing error from a non-success result. "[ede_diagnostic] ..."
* entries are CLI-internal telemetry (the CLI hides them from its own UI too),
* so they must never become the error banner.
*/
function resultUserFacingError(result: SDKResultMessage): string | undefined {
if (result.subtype === "success" || !Array.isArray(result.errors)) {
return undefined;
}
return result.errors.find((error) => !error.startsWith("[ede_diagnostic]"));
}

function isInterruptedResult(result: SDKResultMessage): boolean {
// The CLI stamps user aborts explicitly: interrupting mid-tool-call yields
// "aborted_tools" (with an internal "[ede_diagnostic] ..." error and
// is_error: true), interrupting mid-stream yields "aborted_streaming".
if (
result.terminal_reason === "aborted_tools" ||
result.terminal_reason === "aborted_streaming"
) {
return true;
}

const errors = resultErrorsText(result);
if (errors.includes("interrupt")) {
return true;
Expand Down Expand Up @@ -2919,7 +2941,7 @@ export const makeClaudeAdapter = Effect.fn("makeClaudeAdapter")(function* (
}

const status = turnStatusFromResult(message);
const errorMessage = message.subtype === "success" ? undefined : message.errors[0];
const errorMessage = resultUserFacingError(message);

if (status === "failed") {
yield* emitRuntimeError(context, errorMessage ?? "Claude turn failed.");
Expand Down
Loading