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
68 changes: 68 additions & 0 deletions apps/desktop/src/main/services/chat/agentChatService.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1878,6 +1878,74 @@ describe("createAgentChatService", () => {
expect((doneEvent!.event as any).modelId).toBe("anthropic/claude-opus-4-8");
});

it("fast-fails a logged-out Claude turn into the inline re-login card", async () => {
const events: AgentChatEventEnvelope[] = [];
let streamCall = 0;
vi.mocked(claudeSdkCreateSessionCompat).mockReturnValue({
send: vi.fn().mockResolvedValue(undefined),
stream: vi.fn(() => (async function* () {
streamCall += 1;
if (streamCall === 1) {
// Startup warmup stream — healthy.
yield { type: "system", subtype: "init", session_id: "sdk-auth", model: "claude-opus-4-8", slash_commands: [] };
yield { type: "result", subtype: "success", is_error: false, session_id: "sdk-auth" };
return;
}
// The SDK reports a logged-out session as an assistant message carrying
// error="authentication_failed", with the 401 surfaced as plain text.
yield { type: "system", subtype: "init", session_id: "sdk-auth", model: "claude-opus-4-8", slash_commands: [] };
yield {
type: "assistant",
error: "authentication_failed",
message: {
model: "claude-opus-4-8",
content: [{ type: "text", text: "Failed to authenticate. API Error: 401 Invalid authentication credentials" }],
},
};
})()),
close: vi.fn(),
sessionId: "sdk-auth",
setPermissionMode: vi.fn().mockResolvedValue(undefined),
} as any);

const { service } = createService({
onEvent: (event: AgentChatEventEnvelope) => events.push(event),
});
const session = await service.createSession({
laneId: "lane-1",
provider: "claude",
model: "claude-opus-4-8",
modelId: "anthropic/claude-opus-4-8",
});

await service.runSessionTurn({ sessionId: session.id, text: "use context skill" });

// The raw 401 is not surfaced as a plain assistant bubble.
const authText = events.find(
(event) => event.event.type === "text"
&& /invalid authentication credentials/i.test((event.event as any).text ?? ""),
);
expect(authText).toBeUndefined();

// A single "logged out" notice replaces the "retry 1/10 … 10/10" storm.
const notice = events.find(
(event) => event.event.type === "system_notice"
&& /logged out/i.test((event.event as any).message ?? ""),
);
expect(notice).toBeTruthy();

// The error carries the agentCli signal that renders the inline re-login card.
const errorEvent = events.find(
(event) => event.event.type === "error"
&& (event.event as any).errorInfo?.agentCli?.category === "unauthenticated",
);
expect(errorEvent).toBeTruthy();
expect((errorEvent!.event as any).errorInfo.agentCli.agent).toBe("claude");

const failedDone = events.filter((event) => event.event.type === "done").at(-1);
expect((failedDone!.event as any).status).toBe("failed");
});

it("honors an explicit initial chat title", async () => {
const { service, sessionService } = createService();
const session = await service.createSession({
Expand Down
58 changes: 51 additions & 7 deletions apps/desktop/src/main/services/chat/agentChatService.ts
Original file line number Diff line number Diff line change
Expand Up @@ -802,6 +802,9 @@ const CLAUDE_BUILT_IN_SLASH_COMMANDS: AgentChatSlashCommand[] = [
const CODEX_BUILT_IN_SLASH_COMMAND_NAMES = new Set(CODEX_BUILT_IN_SLASH_COMMANDS.map((command) => slashCommandKey(command.name)));
const CLAUDE_BUILT_IN_SLASH_COMMAND_NAMES = new Set(CLAUDE_BUILT_IN_SLASH_COMMANDS.map((command) => slashCommandKey(command.name)));
const CLAUDE_LOGIN_NOT_SDK_COMMAND = "ADE Claude chat is hosted through the Claude Agent SDK, and /login is not an SDK-dispatchable command. Run `claude auth login` in a terminal or configure ANTHROPIC_API_KEY, then refresh AI settings.";
// Terse one-liner shown when a turn fast-fails on a logout — paired with the
// fuller CLAUDE_RUNTIME_AUTH_ERROR card body emitted by the catch path.
const CLAUDE_AUTH_STOPPED_NOTICE = "Claude is logged out — stopped retrying.";
const CLAUDE_SESSION_DISABLED_PLUGINS: Record<string, boolean> = {
"learning-output-style@claude-code-plugins": false,
"learning-output-style@claude-plugins-official": false,
Expand Down Expand Up @@ -12123,6 +12126,28 @@ export function createAgentChatService(args: {
}
};

// Fast-fail on a definitive Claude auth failure (401 / logged out). A 401
// is not transient, so rather than letting the SDK grind through its retry
// budget ("retry 1/10 … 10/10"), we stop on the first auth signal: emit a
// single "logged out" notice and throw an auth error. The catch block below
// recognises it via isClaudeRuntimeAuthError, closes the query (halting
// further retries), reports the runtime auth failure, and emits a decorated
// error event that renders the inline re-login card on every chat surface.
let claudeAuthStopNoticeEmitted = false;
const failClaudeTurnUnauthenticated = (): never => {
if (!claudeAuthStopNoticeEmitted) {
claudeAuthStopNoticeEmitted = true;
emitChatEvent(managed, {
type: "system_notice",
noticeKind: "auth",
severity: "warning",
message: CLAUDE_AUTH_STOPPED_NOTICE,
turnId,
});
}
throw new Error(CLAUDE_RUNTIME_AUTH_ERROR);
};

while (true) {
const nextMessage = await readNextClaudeTurnMessage();
if (!nextMessage) {
Expand Down Expand Up @@ -12313,13 +12338,8 @@ export function createAgentChatService(args: {
if (msg.type === "auth_status") {
const authMsg = msg as any;
if (authMsg.error) {
reportProviderRuntimeAuthFailure("claude", CLAUDE_RUNTIME_AUTH_ERROR);
emitChatEvent(managed, {
type: "system_notice",
noticeKind: "auth",
message: CLAUDE_RUNTIME_AUTH_ERROR,
turnId,
});
// Definitive logged-out signal — fast-fail into the re-login card.
failClaudeTurnUnauthenticated();
} else if (authMsg.isAuthenticating) {
emitChatEvent(managed, {
type: "system_notice",
Expand Down Expand Up @@ -12386,6 +12406,18 @@ export function createAgentChatService(args: {
if (msg.type === "system" && (msg as any).subtype === "api_retry") {
const retryMsg = msg as any;
const error = typeof retryMsg.error === "string" ? retryMsg.error : "transient_error";
// A logged-out/auth retry will never recover on its own. Stop the retry
// storm on the first auth attempt instead of surfacing
// "retry 1/10 … 10/10" — rate-limit/overloaded retries still proceed.
// Do not treat every bare 403 as logout: Anthropic can use 403 for
// org/model access restrictions, which should not render a login card.
if (
retryMsg.error_status === 401
|| error === "authentication_failed"
|| isClaudeRuntimeAuthError(error)
) {
failClaudeTurnUnauthenticated();
}
Comment thread
arul28 marked this conversation as resolved.
const retryDelayMs = typeof retryMsg.retry_delay_ms === "number" ? retryMsg.retry_delay_ms : null;
const retryDelay = retryDelayMs != null ? Math.max(0, Math.round(retryDelayMs / 1000)) : null;
emitChatEvent(managed, {
Expand Down Expand Up @@ -12743,6 +12775,12 @@ export function createAgentChatService(args: {
// assistant message — process content blocks
if (msg.type === "assistant") {
const assistantMsg = msg as any;
// The SDK reports a logged-out turn as an assistant message carrying
// error="authentication_failed" (its text otherwise renders as a plain
// bubble with no recovery affordance). Fast-fail into the re-login card.
if (assistantMsg.error === "authentication_failed") {
failClaudeTurnUnauthenticated();
}
const betaMessage = assistantMsg.message;
const assistantMessageId = typeof betaMessage?.id === "string" ? betaMessage.id : null;
// If the snapshot has no id, advance the id-less boundary once the
Expand Down Expand Up @@ -13097,6 +13135,12 @@ export function createAgentChatService(args: {
costUsd = resultMsg.total_cost_usd;
}
if (resultMsg.is_error && resultMsg.errors?.length) {
// A logged-out result surfaces here as 401 / "invalid authentication
// credentials" errors — route them into the re-login card rather than
// dumping raw API error text.
if (isClaudeRuntimeAuthError(resultMsg.errors.map(String).join(" "))) {
failClaudeTurnUnauthenticated();
}
for (const err of resultMsg.errors) {
emitChatEvent(managed, {
type: "error",
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -661,6 +661,36 @@ describe("AgentChatMessageList transcript rendering", () => {
expect(screen.getAllByRole("button")).toHaveLength(2);
});

it("renders unauthenticated agent CLI errors as a re-login card", () => {
renderMessageList([
{
sessionId: "session-1",
timestamp: "2026-03-17T10:00:00.000Z",
event: {
type: "error",
message: "Authentication failed for Claude Sonnet 4.6.",
detail: "API Error: 401 Invalid authentication credentials",
errorInfo: {
category: "agent_cli_auth",
provider: "Claude Code",
agentCli: {
agent: "claude",
displayName: "Claude Code",
category: "unauthenticated",
installCommand: "npm install -g @anthropic-ai/claude-code",
authCommand: "claude auth login",
},
},
},
},
], { sessionId: "session-1" });

expect(screen.getByText("Claude Code is logged out")).toBeTruthy();
expect(screen.getByRole("button", { name: /retry turn/i })).toBeTruthy();
expect(screen.getByText("Details")).toBeTruthy();
expect(screen.queryByText("Error")).toBeNull();
});

it("renders Claude plan usage warning as a compact non-error notice", () => {
renderMessageList([
{
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -3167,6 +3167,45 @@ function renderEvent(
const errorCopyValue = event.detail?.trim().length
? `${event.message}\n\n${event.detail}`
: event.message;
const renderAgentCliAuthCard = () => agentCliInfo ? (
<AgentCliAuthCard
agentCli={agentCliInfo}
laneId={options?.laneId}
chatSessionId={options?.sessionId}
runtimeName={options?.runtimeName}
onRevealTerminal={options?.onRevealChatTerminal}
/>
) : null;
// A logged-out runtime is recoverable, not a crash — lead with the calm
// re-login card and tuck the raw 401 behind a Details disclosure instead of
// the loud red error chrome. (The "missing CLI" card keeps the red frame.)
if (agentCliInfo?.category === "unauthenticated") {
return (
<div
className={cn(
GLASS_CARD_CLASS,
"group p-0",
agentCliInfo.agent === "claude" ? "border-[#d97757]/12" : "border-amber-400/12",
)}
style={SURFACE_INLINE_CARD_STYLE}
>
<div className="p-4 pt-3">
{renderAgentCliAuthCard()}
<details className="mt-2">
<summary className="cursor-pointer list-none font-mono text-[length:calc(var(--chat-font-size)*9/14)] font-bold uppercase tracking-[0.16em] text-muted-fg/40 transition-colors hover:text-muted-fg/65">
Details
</summary>
<div className="mt-1.5 flex items-start gap-2 rounded-[calc(var(--chat-radius-card)-8px)] border border-white/[0.06] bg-black/15 px-3 py-2">
<div className="min-w-0 flex-1 whitespace-pre-wrap break-words font-mono text-[length:calc(var(--chat-font-size)*10/14)] leading-relaxed text-fg/55">
{errorCopyValue}
</div>
<MessageCopyButton value={errorCopyValue} className="shrink-0" />
</div>
</details>
</div>
</div>
);
}
Comment thread
arul28 marked this conversation as resolved.
return (
<div className={cn(GLASS_CARD_CLASS, "group border-red-500/12 p-0")} style={SURFACE_INLINE_CARD_STYLE}>
<div className="h-px w-full bg-gradient-to-r from-transparent via-red-500/40 to-transparent" />
Expand All @@ -3191,15 +3230,7 @@ function renderEvent(
{event.detail}
</div>
) : null}
{agentCliInfo ? (
<AgentCliAuthCard
agentCli={agentCliInfo}
laneId={options?.laneId}
chatSessionId={options?.sessionId}
runtimeName={options?.runtimeName}
onRevealTerminal={options?.onRevealChatTerminal}
/>
) : null}
{renderAgentCliAuthCard()}
{event.errorInfo && !agentCliInfo ? (
<div className="mt-2 font-mono text-[length:calc(var(--chat-font-size)*10/14)] text-muted-fg/40">
{typeof event.errorInfo === "string" ? event.errorInfo : `${event.errorInfo.provider ? `${event.errorInfo.provider}` : ""}${event.errorInfo.model ? ` / ${event.errorInfo.model}` : ""}`}
Expand Down
Loading
Loading