diff --git a/apps/server/src/provider/Layers/ClaudeAdapter.test.ts b/apps/server/src/provider/Layers/ClaudeAdapter.test.ts index 84cc03b75a6d..fc9fccb23cd9 100644 --- a/apps/server/src/provider/Layers/ClaudeAdapter.test.ts +++ b/apps/server/src/provider/Layers/ClaudeAdapter.test.ts @@ -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* () { diff --git a/apps/server/src/provider/Layers/ClaudeAdapter.ts b/apps/server/src/provider/Layers/ClaudeAdapter.ts index a005f583066f..9100de0d7a63 100644 --- a/apps/server/src/provider/Layers/ClaudeAdapter.ts +++ b/apps/server/src/provider/Layers/ClaudeAdapter.ts @@ -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 } | undefined; stopped: boolean; @@ -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 @@ -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 }); + } }); /** @@ -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); } @@ -4783,6 +4814,7 @@ export const makeClaudeAdapter = Effect.fn("makeClaudeAdapter")(function* ( lastKnownTotalProcessedTokens: undefined, lastAssistantUuid: resumeState?.resumeSessionAt, lastThreadStartedId: undefined, + authenticationError: undefined, announcedUsageLimits: undefined, stopped: false, };