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

it.effect("stops the session after an authentication failure so the next turn respawns", () => {
const harness = makeHarness();
return Effect.gen(function* () {
const adapter = yield* ClaudeAdapter;

const runtimeEventsFiber = yield* Stream.takeUntil(
adapter.streamEvents,
(event) => event.type === "session.exited",
).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: [],
});

// The CLI reports expired or missing credentials as a synthetic
// assistant message stamped with error: "authentication_failed".
harness.query.emit({
type: "assistant",
session_id: "sdk-session-auth",
uuid: "assistant-auth-failed",
parent_tool_use_id: null,
error: "authentication_failed",
message: {
id: "assistant-message-auth",
content: [{ type: "text", text: "Not logged in \u00b7 Please run /login" }],
},
} as unknown as SDKMessage);

// The CLI closes the turn as a success result flagged is_error.
harness.query.emit({
type: "result",
subtype: "success",
is_error: true,
errors: [],
session_id: "sdk-session-auth",
uuid: "result-auth-failed",
} as unknown as SDKMessage);

const runtimeEvents = Array.from(yield* Fiber.join(runtimeEventsFiber));
const eventTypes = runtimeEvents.map((event) => event.type);
assert.equal(eventTypes[eventTypes.length - 1], "session.exited");
assert.ok(eventTypes.indexOf("turn.completed") < eventTypes.indexOf("session.exited"));
assert.equal(eventTypes.filter((type) => type === "turn.completed").length, 1);
assert.equal(eventTypes.includes("runtime.error"), false);

const turnCompleted = runtimeEvents.find((event) => event.type === "turn.completed");
assert.equal(turnCompleted?.type, "turn.completed");
if (turnCompleted?.type === "turn.completed") {
assert.equal(String(turnCompleted.turnId), String(turn.turnId));
assert.equal(turnCompleted.payload.state, "failed");
assert.equal(turnCompleted.payload.errorMessage, "Not logged in \u00b7 Please run /login");
}

assert.equal(yield* adapter.hasSession(THREAD_ID), false);
assert.equal(harness.query.closeCalls, 1);
}).pipe(
Effect.provideService(Random.Random, makeDeterministicRandomService()),
Effect.provide(harness.layer),
);
});

it.effect("reports an authentication failure without text as a runtime error", () => {
const harness = makeHarness();
return Effect.gen(function* () {
const adapter = yield* ClaudeAdapter;

const runtimeEventsFiber = yield* Stream.takeUntil(
adapter.streamEvents,
(event) => event.type === "session.exited",
).pipe(Stream.runCollect, Effect.forkChild);

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

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

harness.query.emit({
type: "assistant",
session_id: "sdk-session-auth",
uuid: "assistant-auth-failed-1",
parent_tool_use_id: null,
error: "authentication_failed",
message: { id: "assistant-message-auth-1", content: [] },
} as unknown as SDKMessage);

// A repeated failure in the same turn keeps the first message.
harness.query.emit({
type: "assistant",
session_id: "sdk-session-auth",
uuid: "assistant-auth-failed-2",
parent_tool_use_id: null,
error: "authentication_failed",
message: { id: "assistant-message-auth-2", content: [] },
} as unknown as SDKMessage);

harness.query.emit({
type: "result",
subtype: "success",
is_error: true,
errors: [],
session_id: "sdk-session-auth",
uuid: "result-auth-failed",
} as unknown as SDKMessage);

const runtimeEvents = Array.from(yield* Fiber.join(runtimeEventsFiber));
const runtimeErrors = runtimeEvents.filter((event) => event.type === "runtime.error");
assert.equal(runtimeErrors.length, 1);
if (runtimeErrors[0]?.type === "runtime.error") {
assert.equal(runtimeErrors[0].payload.message, "Claude authentication failed.");
}

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, "failed");
assert.equal(turnCompleted.payload.errorMessage, "Claude authentication failed.");
}

assert.equal(runtimeEvents[runtimeEvents.length - 1]?.type, "session.exited");
assert.equal(yield* adapter.hasSession(THREAD_ID), false);
}).pipe(
Effect.provideService(Random.Random, makeDeterministicRandomService()),
Effect.provide(harness.layer),
);
});

it.effect("closes the session when the Claude stream aborts after a turn starts", () => {
const harness = makeHarness();
return Effect.gen(function* () {
Expand Down
40 changes: 36 additions & 4 deletions apps/server/src/provider/Layers/ClaudeAdapter.ts
Original file line number Diff line number Diff line change
Expand Up @@ -328,6 +328,14 @@ interface ClaudeSessionContext {
lastKnownTotalProcessedTokens: number | undefined;
lastAssistantUuid: string | undefined;
lastThreadStartedId: string | undefined;
/**
* Message of the CLI's authentication failure, when it reported one. The CLI
* loads credentials once per process and never re-reads them, so a session
* whose credentials expired stays broken even after the user logs in again.
* The turn fails with this message and the session is stopped, so the next
* turn spawns a fresh CLI with the current credentials.
*/
authenticationError: string | undefined;
/** Limits already announced for the running turn, keyed `window:resetsAt`. */
announcedUsageLimits: { turnId: string; keys: Set<string> } | undefined;
stopped: boolean;
Expand Down Expand Up @@ -3137,6 +3145,16 @@ export const makeClaudeAdapter = Effect.fn("makeClaudeAdapter")(function* (
return;
}

if (message.error === "authentication_failed" && context.authenticationError === undefined) {
const authenticationText = trimmedString(extractAssistantTextBlocks(message).join("\n"));
context.authenticationError = authenticationText ?? "Claude authentication failed.";
// The CLI's own text reaches the transcript below; a message without
// text needs a runtime error so the failure is still visible.
if (authenticationText === undefined) {
yield* emitRuntimeError(context, context.authenticationError);
}
}

// Subagent-owned assistant snapshots (parent_tool_use_id set) are the
// subagent's own conversation, not the parent's. Emitting them created
// interleaved "Agent N done"-adjacent leak messages and spawned synthetic
Expand Down Expand Up @@ -3268,14 +3286,24 @@ export const makeClaudeAdapter = Effect.fn("makeClaudeAdapter")(function* (
return;
}

const status = turnStatusFromResult(message);
const errorMessage = resultUserFacingError(message);
const authenticationError = context.authenticationError;
const status = authenticationError === undefined ? turnStatusFromResult(message) : "failed";
const errorMessage = authenticationError ?? resultUserFacingError(message);

if (status === "failed") {
// The authentication failure already reached the transcript as the CLI's
// assistant message, so it is not repeated as a runtime error.
if (status === "failed" && authenticationError === undefined) {
yield* emitRuntimeError(context, errorMessage ?? "Claude turn failed.");
}

yield* completeTurn(context, status, errorMessage, message);

if (authenticationError !== undefined) {
yield* Effect.logInfo("claude.session.stopped.authentication-failed", {
threadId: context.session.threadId,
});
yield* stopSessionInternal(context, { emitExitEvent: true });
}
});

/**
Expand Down Expand Up @@ -4095,9 +4123,12 @@ export const makeClaudeAdapter = Effect.fn("makeClaudeAdapter")(function* (

yield* Queue.shutdown(context.promptQueue);

// The stream fiber ends on its own once `stopped` is set, so a stop issued
// from a message handler running on that fiber must not wait on itself.
const streamFiber = context.streamFiber;
context.streamFiber = undefined;
if (streamFiber && streamFiber.pollUnsafe() === undefined) {
const currentFiber = yield* Effect.withFiber((fiber) => Effect.succeed(fiber));
if (streamFiber && streamFiber !== currentFiber && streamFiber.pollUnsafe() === undefined) {
yield* Fiber.interrupt(streamFiber);
}

Expand Down Expand Up @@ -4783,6 +4814,7 @@ export const makeClaudeAdapter = Effect.fn("makeClaudeAdapter")(function* (
lastKnownTotalProcessedTokens: undefined,
lastAssistantUuid: resumeState?.resumeSessionAt,
lastThreadStartedId: undefined,
authenticationError: undefined,
announcedUsageLimits: undefined,
stopped: false,
};
Expand Down
Loading