Skip to content
Closed
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
57 changes: 57 additions & 0 deletions apps/server/src/provider/Layers/ClaudeAdapter.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3959,4 +3959,61 @@ describe("ClaudeAdapterLive", () => {
Effect.provide(harness.layer),
);
});

it.effect(
"treats a diagnostic-only Claude result after an explicit interrupt as interrupted",
() => {
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: [],
});

yield* adapter.interruptTurn(session.threadId, turn.turnId);

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",
session_id: "sdk-session-diagnostic-interrupt",
uuid: "result-diagnostic-interrupt",
} as unknown as SDKMessage);

const runtimeEvents = Array.from(yield* Fiber.join(runtimeEventsFiber));
assert.equal(harness.query.interruptCalls.length, 1);
assert.notInclude(
runtimeEvents.map((event) => event.type),
"runtime.error",
);

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.stopReason, "tool_use");
}
}).pipe(
Effect.provideService(Random.Random, makeDeterministicRandomService()),
Effect.provide(harness.layer),
);
},
);
});
52 changes: 42 additions & 10 deletions apps/server/src/provider/Layers/ClaudeAdapter.ts
Original file line number Diff line number Diff line change
Expand Up @@ -201,6 +201,7 @@ interface ClaudeSessionContext {
lastKnownTotalProcessedTokens: number | undefined;
lastAssistantUuid: string | undefined;
lastThreadStartedId: string | undefined;
interruptRequestedTurnId: TurnId | undefined;
stopped: boolean;
}

Expand Down Expand Up @@ -303,18 +304,31 @@ function resultErrorsText(result: SDKResultMessage): string {
: "";
}

function isInterruptedResult(result: SDKResultMessage): boolean {
function isClaudeExecutionDiagnosticOnlyResult(result: SDKResultMessage): boolean {
return (
result.subtype === "error_during_execution" &&
"errors" in result &&
result.errors.length === 1 &&
result.errors[0]?.startsWith("[ede_diagnostic]") === true
);
}

function isInterruptedResult(
result: SDKResultMessage,
explicitInterruptRequested = false,
): boolean {
const errors = resultErrorsText(result);
if (errors.includes("interrupt")) {
return true;
}

return (
result.subtype === "error_during_execution" &&
result.is_error === false &&
(errors.includes("request was aborted") ||
errors.includes("interrupted by user") ||
errors.includes("aborted"))
(result.subtype === "error_during_execution" &&
result.is_error === false &&
(errors.includes("request was aborted") ||
errors.includes("interrupted by user") ||
errors.includes("aborted"))) ||
(explicitInterruptRequested && isClaudeExecutionDiagnosticOnlyResult(result))
);
}

Expand Down Expand Up @@ -994,13 +1008,16 @@ const buildUserMessageEffect = Effect.fn("buildUserMessageEffect")(function* (
return buildUserMessage({ sdkContent });
});

function turnStatusFromResult(result: SDKResultMessage): ProviderRuntimeTurnStatus {
function turnStatusFromResult(
result: SDKResultMessage,
explicitInterruptRequested = false,
): ProviderRuntimeTurnStatus {
if (result.subtype === "success") {
return "completed";
}

const errors = resultErrorsText(result);
if (isInterruptedResult(result)) {
if (isInterruptedResult(result, explicitInterruptRequested)) {
return "interrupted";
}
if (errors.includes("cancel")) {
Expand Down Expand Up @@ -2553,7 +2570,11 @@ export const makeClaudeAdapter = Effect.fn("makeClaudeAdapter")(function* (
return;
}

const status = turnStatusFromResult(message);
const explicitInterruptRequested =
context.interruptRequestedTurnId !== undefined &&
context.interruptRequestedTurnId === context.turnState?.turnId;
context.interruptRequestedTurnId = undefined;
const status = turnStatusFromResult(message, explicitInterruptRequested);
const errorMessage = message.subtype === "success" ? undefined : message.errors[0];

if (status === "failed") {
Expand Down Expand Up @@ -3640,6 +3661,7 @@ export const makeClaudeAdapter = Effect.fn("makeClaudeAdapter")(function* (
lastKnownTotalProcessedTokens: undefined,
lastAssistantUuid: resumeState?.resumeSessionAt,
lastThreadStartedId: undefined,
interruptRequestedTurnId: undefined,
stopped: false,
};
yield* Ref.set(contextRef, context);
Expand Down Expand Up @@ -3825,10 +3847,20 @@ export const makeClaudeAdapter = Effect.fn("makeClaudeAdapter")(function* (
const interruptTurn: ClaudeAdapterShape["interruptTurn"] = Effect.fn("interruptTurn")(
function* (threadId, _turnId) {
const context = yield* requireSession(threadId);
const interruptRequestedTurnId = context.turnState?.turnId;
context.interruptRequestedTurnId = interruptRequestedTurnId;
yield* Effect.tryPromise({
try: () => context.query.interrupt(),
catch: (cause) => toRequestError(threadId, "turn/interrupt", cause),
});
}).pipe(
Effect.tapError(() =>
Effect.sync(() => {
if (context.interruptRequestedTurnId === interruptRequestedTurnId) {
context.interruptRequestedTurnId = undefined;
}
}),
),
);
},
);

Expand Down
Loading