Skip to content
Open
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
139 changes: 139 additions & 0 deletions src/lib/__tests__/claude-cli-transport.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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()
Expand Down Expand Up @@ -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()
Expand Down Expand Up @@ -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.
Expand Down
92 changes: 73 additions & 19 deletions src/lib/claude-cli-transport.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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

Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -308,37 +344,55 @@ 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.
*/
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}`
}
Expand Down