diff --git a/src/lib/__tests__/claude-cli-transport.test.ts b/src/lib/__tests__/claude-cli-transport.test.ts index 15cac9140..e1b38f3df 100644 --- a/src/lib/__tests__/claude-cli-transport.test.ts +++ b/src/lib/__tests__/claude-cli-transport.test.ts @@ -133,6 +133,46 @@ describe("createClaudeCodeStreamParser", () => { expect(parse(JSON.stringify({ type: "future_type_we_dont_know" }))).toBeNull() }) + it("signals a fatal error via onFatalError when a result event has is_error: true", () => { + // Real-user scenario (#708): an expired OAuth session makes claude + // report the fatal error inside a `result` event on stdout — not on + // stderr and not as a separate `error` event. The parser must hand + // the `result` text to onFatalError instead of discarding the line. + const parse = createClaudeCodeStreamParser() + const onFatalError = vi.fn() + parse.onFatalError = onFatalError + const line = JSON.stringify({ + type: "result", + subtype: "success", + is_error: true, + result: "Failed to authenticate: OAuth session expired and could not be refreshed", + }) + expect(parse(line)).toBeNull() + expect(onFatalError).toHaveBeenCalledTimes(1) + expect(onFatalError).toHaveBeenCalledWith( + "Failed to authenticate: OAuth session expired and could not be refreshed", + ) + }) + + it("does not fire onFatalError for an is_error result without a usable result string", () => { + const parse = createClaudeCodeStreamParser() + const onFatalError = vi.fn() + parse.onFatalError = onFatalError + expect(parse(JSON.stringify({ type: "result", is_error: true }))).toBeNull() + expect(parse(JSON.stringify({ type: "result", is_error: true, result: 42 }))).toBeNull() + expect(parse(JSON.stringify({ type: "result", is_error: true, result: " " }))).toBeNull() + expect(onFatalError).not.toHaveBeenCalled() + }) + + it("does not fire onFatalError for successful result events", () => { + const parse = createClaudeCodeStreamParser() + const onFatalError = vi.fn() + parse.onFatalError = onFatalError + expect(parse(JSON.stringify({ type: "result", subtype: "success", result: "done" }))).toBeNull() + expect(parse(JSON.stringify({ type: "result", is_error: false, result: "done" }))).toBeNull() + expect(onFatalError).not.toHaveBeenCalled() + }) + it("returns null for malformed JSON or blank lines", () => { const parse = createClaudeCodeStreamParser() expect(parse("")).toBeNull() @@ -386,6 +426,59 @@ describe("streamClaudeCodeCli", () => { ) }) + it("shows the auth hint when an is_error result reports an expired OAuth session", async () => { + // #708: claude exits 1 with empty stderr once the local OAuth + // session expires; the real error travels in a stream-json `result` + // event on stdout. The user must see the login hint, not the raw + // JSON blob that line used to be dumped as. + const callbacks = { + onToken: vi.fn(), + onDone: vi.fn(), + onError: vi.fn(), + } + + const stream = streamClaudeCodeCli( + { + provider: "claude-code", + apiKey: "", + model: "claude-sonnet-4-6", + ollamaUrl: "", + customEndpoint: "", + maxContextSize: 200000, + }, + [{ role: "user", content: "ping" }], + callbacks, + ) + + await vi.waitFor(() => { + expect(tauriMocks.invoke).toHaveBeenCalledTimes(1) + }) + + const payload = tauriMocks.invoke.mock.calls[0]?.[1] as { streamId: string } + tauriMocks.emit( + `claude-cli:${payload.streamId}`, + JSON.stringify({ + type: "result", + subtype: "success", + is_error: true, + result: "Failed to authenticate: OAuth session expired and could not be refreshed", + }), + ) + tauriMocks.emit(`claude-cli:${payload.streamId}:done`, { code: 1, stderr: "" }) + + await stream + + expect(callbacks.onError).toHaveBeenCalledTimes(1) + const message = (callbacks.onError.mock.calls[0]?.[0] as Error).message + expect(message).toMatch(/not authenticated/i) + expect(message).toContain("`claude`") + expect(message).toMatch(/terminal/i) + expect(message).not.toContain("is_error") + expect(message).not.toMatch(/couldn't parse/) + expect(callbacks.onToken).not.toHaveBeenCalled() + expect(callbacks.onDone).not.toHaveBeenCalled() + }) + it("does not spawn when the signal is already aborted", async () => { const controller = new AbortController() controller.abort() @@ -448,6 +541,52 @@ describe("buildExitError", () => { expect(msg).toMatch(/not authenticated/i) }) + it("recognizes an auth failure carried in the result text (the #708 case)", () => { + // claude reports an expired OAuth session via a stream-json result + // event on stdout, with stderr empty — the message must still land + // on the friendly login hint. + const msg = buildExitError( + 1, + "", + "", + "Failed to authenticate: OAuth session expired and could not be refreshed", + ) + expect(msg).toMatch(/not authenticated/i) + expect(msg).toMatch(/`claude`/) + expect(msg).toMatch(/terminal/i) + expect(msg).not.toMatch(/couldn't parse/) + }) + + it("prefers the auth hint over the raw unparsed stdout dump", () => { + // Before #708 the user saw the JSON wall below instead of the + // login hint, because the auth check only ever looked at stderr. + const dump = + '{"type":"system","subtype":"init","session_id":"abc"}\n' + + '{"type":"result","is_error":true,"result":"Failed to authenticate: OAuth session expired and could not be refreshed"}' + const msg = buildExitError( + 1, + "", + dump, + "Failed to authenticate: OAuth session expired and could not be refreshed", + ) + expect(msg).toMatch(/not authenticated/i) + expect(msg).toContain("`claude`") + expect(msg).not.toContain("is_error") + expect(msg).not.toMatch(/couldn't parse/) + }) + + it("surfaces a non-auth result error instead of the JSON dump", () => { + const msg = buildExitError(1, "", "", "Invalid API key provided") + expect(msg).toContain("Invalid API key provided") + expect(msg).toContain("code 1") + expect(msg).not.toMatch(/couldn't parse/) + }) + + it("matches the OAuth-session-expired phrasing without the authenticate prefix", () => { + const msg = buildExitError(1, "", "", "OAuth session expired and could not be refreshed") + expect(msg).toMatch(/not authenticated/i) + }) + it("falls back to unparsed stdout when stderr is empty (the real-user case)", () => { // Real-user scenario: claude exit 1, stderr empty, but stdout // had a structured error event our parser didn't recognize. diff --git a/src/lib/claude-cli-transport.ts b/src/lib/claude-cli-transport.ts index a3bdc0772..f5079aad8 100644 --- a/src/lib/claude-cli-transport.ts +++ b/src/lib/claude-cli-transport.ts @@ -17,6 +17,17 @@ import { useWikiStore } from "@/stores/wiki-store" import type { ChatMessage, RequestOverrides } from "./llm-providers" import type { StreamCallbacks } from "./llm-client" +/** + * A parser is a callable that maps one stream-json line to the + * assistant text it contains. `onFatalError` is an optional side + * channel: a `result` event with `is_error: true` carries the CLI's + * authoritative failure message (e.g. an expired OAuth session), and + * the parser reports it there instead of silently dropping the event. + */ +type ClaudeCodeStreamParser = ((rawLine: string) => string | null) & { + onFatalError?: (message: string) => void +} + /** * Public parse entry point. Given one stream-json line from claude's * stdout, returns any assistant text it contains (or null for events @@ -28,14 +39,14 @@ import type { StreamCallbacks } from "./llm-client" * real token-level deltas. To avoid double-counting, we prefer deltas * when they arrive and skip the fat `assistant` events after seeing one. */ -export function createClaudeCodeStreamParser() { +export function createClaudeCodeStreamParser(): ClaudeCodeStreamParser { let sawDelta = false // Track the running text we have emitted for the current assistant // turn via `assistant` events so we can diff new content off the end // and only emit what wasn't already streamed. let emittedFromAssistant = "" - return function parseLine(rawLine: string): string | null { + const parseLine: ClaudeCodeStreamParser = (rawLine: string): string | null => { const line = rawLine.trim() if (!line) return null @@ -94,9 +105,25 @@ export function createClaudeCodeStreamParser() { return text } + // A `result` event normally carries only the turn summary, but when + // `is_error` is set it holds the CLI's authoritative fatal error — + // e.g. "Failed to authenticate: OAuth session expired..." (note the + // misleading `"subtype":"success"`). claude reports auth failures on + // stdout this way — there is no separate `error` event and stderr + // stays empty — so surface the `result` text via onFatalError rather + // than discarding the event into the unparsed-stdout dump. (#708) + if (type === "result" && obj.is_error === true) { + if (typeof obj.result === "string" && obj.result.trim()) { + parseLine.onFatalError?.(obj.result) + } + return null + } + // Ignore session init, tool_use, result summary, unknown types. return null } + + return parseLine } // Tauri's `invoke` typing requires the payload object to satisfy @@ -142,6 +169,13 @@ export async function streamClaudeCodeCli( const streamId = crypto.randomUUID() const parse = createClaudeCodeStreamParser() + // Fatal errors reported inside a stream-json `result` event (an + // expired OAuth session, for instance) land here instead of being + // buried in the unparsed-stdout dump below. + let fatalResultError = "" + parse.onFatalError = (message) => { + fatalResultError = message + } let unlistenData: UnlistenFn | undefined let unlistenDone: UnlistenFn | undefined @@ -238,14 +272,16 @@ export async function streamClaudeCodeCli( if (code !== null && code !== undefined && code !== 0) { finishWith(() => onError( - new Error(buildExitError(code, stderr, unparsedLines.join("\n"))), + new Error( + buildExitError(code, stderr, unparsedLines.join("\n"), fatalResultError), + ), ), ) } else if (!emittedToken) { // CLI exited successfully but produced no assistant text. // Surface this as an explicit error so the ingest pipeline // retries rather than silently writing an empty stub page. - const details = stderr || unparsedLines.join("\n").trim() + const details = fatalResultError || stderr || unparsedLines.join("\n").trim() finishWith(() => onError(new Error( details @@ -308,20 +344,26 @@ export async function streamClaudeCodeCli( * we used to throw was correct but unactionable — users had to * read JSON-shaped stderr text to figure out what to do. * - * Three diagnostic sources, used in priority order: - * 1. stderr — the canonical place. The most common content is - * `Unauthenticated:` from Claude Code itself, meaning the - * user's ~/.claude OAuth token expired / was revoked / they - * logged out. We surface that case explicitly because users - * otherwise mis-diagnose it as an LLM Wiki bug. - * 2. unparsedStdout — stdout lines the parser didn't recognize - * (non-JSON, unknown event types, the stream-json `error` - * event shape). Used as a fallback when stderr is empty — - * claude sometimes writes its real diagnostic to stdout via - * the stream-json channel, and our parser silently drops - * anything it doesn't classify, leaving users with no info - * at all. - * 3. Neither — silent exit. We can't help much here other than + * Four diagnostic sources, used in priority order: + * 1. auth failure — the most common fatal case. Detected by + * pattern-matching stderr AND resultError together, because + * claude reports an expired/revoked OAuth session in different + * places depending on version: `Unauthenticated:` on stderr, or + * a `{"type":"result","is_error":true,"result":"Failed to + * authenticate: ..."}` event on stdout with empty stderr (#708). + * Surfaced explicitly because users otherwise mis-diagnose it + * as an LLM Wiki bug. + * 2. resultError — the `result` text extracted from an is_error + * result event. It's the CLI's own one-line failure + * description, so it beats dumping raw stdout JSON at the user. + * 3. stderr — other stderr diagnostics. + * 4. unparsedStdout — stdout lines the parser didn't recognize + * (non-JSON, unknown event shapes). Used as a fallback when + * stderr is empty — claude sometimes writes its real diagnostic + * to stdout via the stream-json channel, and our parser + * silently drops anything it doesn't classify, leaving users + * with no info at all. + * 5. Neither — silent exit. We can't help much here other than * telling the user to reproduce in a terminal where they can * see whatever output the CLI does produce. */ @@ -329,16 +371,28 @@ export function buildExitError( code: number, stderr: string, unparsedStdout: string = "", + resultError: string = "", ): string { - if (/unauthenticated|please.*log\s*in|authentication.*failed/i.test(stderr)) { + // Match stderr and the extracted result text together: the auth + // failure travels on either channel depending on CLI version. + const diagnostics = `${stderr}\n${resultError}` + if ( + /unauthenticated|please.*log\s*in|authentication.*failed|failed to authenticate|oauth session expired/i.test( + diagnostics, + ) + ) { return [ "Claude Code CLI is not authenticated.", "Please open a terminal and run `claude` to complete the OAuth login,", "then retry. (LLM Wiki only spawns the binary — it can't run the", "login flow on your behalf.)", stderr ? `\n\n— stderr —\n${stderr}` : "", + resultError ? `\n\n— result —\n${resultError}` : "", ].join(" ").trim() } + if (resultError.trim()) { + return `claude CLI exited with code ${code}: ${resultError}` + } if (stderr) { return `claude CLI exited with code ${code}: ${stderr}` }