diff --git a/main.ts b/main.ts index 3915efd..082f690 100644 --- a/main.ts +++ b/main.ts @@ -18,6 +18,7 @@ import { import { shouldConfirmRegenerate } from './src/cache'; import { CacheManager } from './src/cache-manager'; import { resolveCardAnchors } from './src/cards'; +import { showGenerationError } from './src/error-ui'; import { cancellationNoticeKey, summarizeDocument } from './src/generation'; import { classifyGenerationError, @@ -603,7 +604,30 @@ class ParallelReaderPlugin extends Plugin { const msg = e instanceof Error ? e.message : String(e); console.error(e); if (view && this.viewIsShowingFile(view, file)) view.renderError(file, msg); - new Notice(this.t('generationFailed', { kind: kind === 'unknown' ? '' : ` (${kind})`, error: msg })); + showGenerationError( + { + app: this.app, + settings: this.settings, + openSettings: () => this.openPluginSettings(), + }, + kind, + e, + msg, + ); + } + + private openPluginSettings(): void { + // Obsidian doesn't expose a typed API for opening a specific plugin tab; use the documented + // (but technically internal) setting/openTabById path with safe fallbacks. + const setting = (this.app as unknown as { setting?: { open: () => void; openTabById: (id: string) => void } }) + .setting; + if (!setting) return; + try { + setting.open(); + setting.openTabById(this.manifest.id); + } catch (err: unknown) { + console.warn('[parallel-reader] failed to open settings tab', err); + } } async runBatchForFolder() { diff --git a/src/cli.ts b/src/cli.ts index ceaf2ca..95781dc 100644 --- a/src/cli.ts +++ b/src/cli.ts @@ -59,6 +59,42 @@ export interface RunCliOptions { debug?: boolean; } +export type CliFailureReason = + | 'wall-timeout' + | 'idle-timeout' + | 'exit-nonzero' + | 'streams-unavailable' + | 'spawn-failure' + | 'startup-error'; + +export interface CliErrorDetails { + reason: CliFailureReason; + cmd: string; + pid: number | null; + elapsedMs: number; + idleMs: number | null; + stdoutBytes: number; + stderrBytes: number; + /** Already secret-redacted, capped to DIAG_STDERR_TAIL_CHARS. */ + stderrTail: string; + /** Already secret-redacted, capped to DIAG_STDOUT_TAIL_CHARS. */ + stdoutTail: string; + exitCode: number | null; + signal: NodeJS.Signals | null; + timeoutMs: number; + idleTimeoutMs: number; +} + +export class CliProcessError extends Error { + readonly name = 'CliProcessError'; + readonly details: CliErrorDetails; + + constructor(message: string, details: CliErrorDetails) { + super(message); + this.details = details; + } +} + const DIAG_STDERR_TAIL_CHARS = 800; const DIAG_STDOUT_TAIL_CHARS = 200; @@ -73,7 +109,7 @@ function redactSecrets(text: string): string { return text .replace(/\b(Bearer)\s+[\w.\-+/=]{6,}/gi, `$1 ${REDACT}`) .replace(/\b(x-api-key|x-goog-api-key|api[_-]?key|authorization)\s*[:=]\s*[\w.\-+/=]{6,}/gi, `$1: ${REDACT}`) - .replace(/\b(sk-[A-Za-z0-9_\-]{12,})/g, REDACT); + .replace(/\b(sk-[A-Za-z0-9_-]{12,})/g, REDACT); } function utf8ByteLength(text: string): number { @@ -156,7 +192,23 @@ export function runCli( } catch (e: unknown) { const message = `Failed to start ${cmd}: ${e instanceof Error ? e.message : String(e)}`; if (debug) console.warn('[parallel-reader] cli spawn failed', { cmd, error: message }); - return reject(new Error(message)); + return reject( + new CliProcessError(message, { + reason: 'spawn-failure', + cmd, + pid: null, + elapsedMs: Date.now() - startedAt, + idleMs: null, + stdoutBytes: 0, + stderrBytes: 0, + stdoutTail: '', + stderrTail: '', + exitCode: null, + signal: null, + timeoutMs, + idleTimeoutMs, + }), + ); } if (debug) { @@ -172,17 +224,32 @@ export function runCli( let stdout = ''; let stderr = ''; - const buildDiagSuffix = (idleMs: number): string => { - const elapsed = Date.now() - startedAt; - const stderrBytes = utf8ByteLength(stderr); - const stdoutBytes = utf8ByteLength(stdout); - const stderrTail = redactSecrets(tail(stderr, DIAG_STDERR_TAIL_CHARS)); - const stdoutTail = redactSecrets(tail(stdout, DIAG_STDOUT_TAIL_CHARS)); + const collectDetails = ( + reason: CliFailureReason, + idleMs: number, + extras: { exitCode?: number | null; signal?: NodeJS.Signals | null } = {}, + ): CliErrorDetails => ({ + reason, + cmd, + pid: child?.pid ?? null, + elapsedMs: Date.now() - startedAt, + idleMs, + stdoutBytes: utf8ByteLength(stdout), + stderrBytes: utf8ByteLength(stderr), + stdoutTail: redactSecrets(tail(stdout, DIAG_STDOUT_TAIL_CHARS)), + stderrTail: redactSecrets(tail(stderr, DIAG_STDERR_TAIL_CHARS)), + exitCode: extras.exitCode ?? null, + signal: extras.signal ?? null, + timeoutMs, + idleTimeoutMs, + }); + + const buildDiagSuffix = (details: CliErrorDetails): string => { let suffix = - ` [pid=${child?.pid ?? 'unknown'} elapsed=${elapsed}ms idle=${idleMs}ms ` + - `stdout=${stdoutBytes}B stderr=${stderrBytes}B]`; - if (stderrTail) suffix += `\n--- stderr tail ---\n${stderrTail}`; - if (stdoutTail) suffix += `\n--- stdout tail ---\n${stdoutTail}`; + ` [pid=${details.pid ?? 'unknown'} elapsed=${details.elapsedMs}ms idle=${details.idleMs ?? 0}ms ` + + `stdout=${details.stdoutBytes}B stderr=${details.stderrBytes}B]`; + if (details.stderrTail) suffix += `\n--- stderr tail ---\n${details.stderrTail}`; + if (details.stdoutTail) suffix += `\n--- stdout tail ---\n${details.stdoutTail}`; return suffix; }; @@ -193,7 +260,8 @@ export function runCli( console.warn('[parallel-reader] failed to kill timed-out CLI process', e); } const idleMs = Date.now() - lastActivityAt; - fail(new Error(`CLI timed out (${timeoutMs}ms)${buildDiagSuffix(idleMs)}`)); + const details = collectDetails('wall-timeout', idleMs, { signal: 'SIGKILL' }); + fail(new CliProcessError(`CLI timed out (${timeoutMs}ms)${buildDiagSuffix(details)}`, details)); }, timeoutMs); const armIdleTimer = () => { @@ -206,7 +274,8 @@ export function runCli( } catch (e: unknown) { console.warn('[parallel-reader] failed to kill idle-timed-out CLI process', e); } - fail(new Error(`CLI idle timeout (${idleTimeoutMs}ms)${buildDiagSuffix(idleMs)}`)); + const details = collectDetails('idle-timeout', idleMs, { signal: 'SIGKILL' }); + fail(new CliProcessError(`CLI idle timeout (${idleTimeoutMs}ms)${buildDiagSuffix(details)}`, details)); }, idleTimeoutMs); }; armIdleTimer(); @@ -223,7 +292,7 @@ export function runCli( } if (!child.stdout || !child.stderr || !child.stdin) { - fail(new Error('CLI process streams are unavailable')); + fail(new CliProcessError('CLI process streams are unavailable', collectDetails('streams-unavailable', 0))); return; } @@ -246,12 +315,20 @@ export function runCli( noteActivity(); }); child.on('error', (e) => { - fail(new Error(`CLI startup error: ${e.message}. Try setting an absolute CLI path.`)); + const idleMs = Date.now() - lastActivityAt; + fail( + new CliProcessError( + `CLI startup error: ${e.message}. Try setting an absolute CLI path.`, + collectDetails('startup-error', idleMs), + ), + ); }); - child.on('close', (code) => { + child.on('close', (code, signal) => { if (settled) return; if (code !== 0) { - return fail(new Error(`CLI exited with code ${code}\nstderr:\n${stderr.slice(0, 1000)}`)); + const idleMs = Date.now() - lastActivityAt; + const details = collectDetails('exit-nonzero', idleMs, { exitCode: code, signal }); + return fail(new CliProcessError(`CLI exited with code ${code}\nstderr:\n${stderr.slice(0, 1000)}`, details)); } succeed({ stdout, stderr }); }); diff --git a/src/error-ui.ts b/src/error-ui.ts new file mode 100644 index 0000000..16f51fe --- /dev/null +++ b/src/error-ui.ts @@ -0,0 +1,198 @@ +'use strict'; + +import { type App, Modal, Notice, Setting } from 'obsidian'; +import type { CliErrorDetails } from './cli'; +import { translate } from './i18n'; +import type { ErrorKind, PluginSettings } from './types'; +import { copyToClipboard } from './ui-helpers'; + +export interface GenerationErrorContext { + app: App; + settings: PluginSettings; + /** Open the plugin settings tab (best-effort; falls back to no-op if unavailable). */ + openSettings: () => void; +} + +interface ActionableNoticeAction { + label: string; + primary?: boolean; + onClick: () => void; +} + +function showActionableNotice(message: string, actions: ActionableNoticeAction[], durationMs = 12000): Notice { + const notice = new Notice('', durationMs); + const root = notice.noticeEl; + root.addClass('parallel-reader-error-notice'); + root.createDiv({ cls: 'parallel-reader-error-notice-message', text: message }); + if (actions.length > 0) { + const buttonRow = root.createDiv({ cls: 'parallel-reader-error-notice-actions' }); + for (const action of actions) { + const btn = buttonRow.createEl('button', { + cls: action.primary ? 'parallel-reader-error-notice-button mod-cta' : 'parallel-reader-error-notice-button', + text: action.label, + attr: { type: 'button' }, + }); + btn.addEventListener('click', (e) => { + e.preventDefault(); + e.stopPropagation(); + try { + action.onClick(); + } catch (err: unknown) { + console.error('[parallel-reader] error notice action failed', err); + } finally { + notice.hide(); + } + }); + } + } + return notice; +} + +class TimeoutDiagnosticsModal extends Modal { + private readonly settings: PluginSettings; + private readonly details: CliErrorDetails; + private readonly fullMessage: string; + private readonly onOpenSettings: () => void; + + constructor( + app: App, + settings: PluginSettings, + details: CliErrorDetails, + fullMessage: string, + onOpenSettings: () => void, + ) { + super(app); + this.settings = settings; + this.details = details; + this.fullMessage = fullMessage; + this.onOpenSettings = onOpenSettings; + } + + onOpen(): void { + const { contentEl } = this; + contentEl.empty(); + contentEl.addClass('parallel-reader-error-modal'); + contentEl.createEl('h2', { text: translate(this.settings, 'errorModalTimeoutTitle') }); + + const reasonKey = this.details.reason === 'idle-timeout' ? 'errorModalReasonIdle' : 'errorModalReasonWall'; + contentEl.createEl('p', { text: translate(this.settings, reasonKey) }); + + const grid = contentEl.createEl('div', { cls: 'parallel-reader-error-modal-grid' }); + const addRow = (label: string, value: string) => { + const row = grid.createDiv({ cls: 'parallel-reader-error-modal-row' }); + row.createSpan({ cls: 'parallel-reader-error-modal-label', text: label }); + row.createSpan({ cls: 'parallel-reader-error-modal-value', text: value }); + }; + addRow(translate(this.settings, 'errorModalFieldCmd'), this.details.cmd); + addRow(translate(this.settings, 'errorModalFieldPid'), String(this.details.pid ?? '—')); + addRow(translate(this.settings, 'errorModalFieldElapsed'), `${this.details.elapsedMs}ms`); + addRow( + translate(this.settings, 'errorModalFieldIdle'), + this.details.idleMs == null ? '—' : `${this.details.idleMs}ms`, + ); + addRow( + translate(this.settings, 'errorModalFieldBytes'), + `stdout ${this.details.stdoutBytes}B / stderr ${this.details.stderrBytes}B`, + ); + + if (this.details.stderrTail) { + contentEl.createEl('h3', { text: translate(this.settings, 'errorModalStderrTail') }); + contentEl.createEl('pre', { cls: 'parallel-reader-error-modal-tail', text: this.details.stderrTail }); + } else if (this.details.stdoutTail) { + contentEl.createEl('h3', { text: translate(this.settings, 'errorModalStdoutTail') }); + contentEl.createEl('pre', { cls: 'parallel-reader-error-modal-tail', text: this.details.stdoutTail }); + } else { + contentEl.createEl('p', { + cls: 'parallel-reader-error-modal-empty', + text: translate(this.settings, 'errorModalNoOutput'), + }); + } + + new Setting(contentEl) + .addButton((b) => + b.setButtonText(translate(this.settings, 'errorModalActionCopy')).onClick(() => { + void copyToClipboard(this.fullMessage, translate(this.settings, 'errorModalCopySuccess')); + }), + ) + .addButton((b) => + b + .setButtonText(translate(this.settings, 'errorModalActionOpenSettings')) + .setCta() + .onClick(() => { + this.onOpenSettings(); + this.close(); + }), + ) + .addButton((b) => b.setButtonText(translate(this.settings, 'errorModalActionClose')).onClick(() => this.close())); + } + + onClose(): void { + this.contentEl.empty(); + } +} + +export function showGenerationError( + ctx: GenerationErrorContext, + kind: ErrorKind, + error: unknown, + message: string, +): void { + const tr = (k: string, vars?: Record) => translate(ctx.settings, k, vars); + + if (kind === 'timeout') { + const details = (error as { details?: CliErrorDetails }).details; + if (details) { + // Open a modal so users can read the stderr tail at leisure and copy it. + new TimeoutDiagnosticsModal(ctx.app, ctx.settings, details, message, ctx.openSettings).open(); + return; + } + // API streaming timeout: no structured details, show actionable notice with raw message tail. + showActionableNotice(tr('errorNoticeTimeout'), [ + { + label: tr('errorActionCopyDetails'), + onClick: () => void copyToClipboard(message, tr('errorModalCopySuccess')), + }, + { label: tr('errorActionOpenSettings'), primary: true, onClick: ctx.openSettings }, + ]); + return; + } + + if (kind === 'auth') { + showActionableNotice(tr('errorNoticeAuth'), [ + { label: tr('errorActionOpenSettings'), primary: true, onClick: ctx.openSettings }, + ]); + return; + } + + if (kind === 'rate-limit') { + showActionableNotice(tr('errorNoticeRateLimit', { error: message }), [ + { + label: tr('errorActionCopyDetails'), + onClick: () => void copyToClipboard(message, tr('errorModalCopySuccess')), + }, + ]); + return; + } + + if (kind === 'schema') { + showActionableNotice(tr('errorNoticeSchema'), [ + { + label: tr('errorActionCopyRaw'), + primary: true, + onClick: () => void copyToClipboard(message, tr('errorModalCopySuccess')), + }, + { label: tr('errorActionOpenSettings'), onClick: ctx.openSettings }, + ]); + return; + } + + if (kind === 'config') { + showActionableNotice(tr('errorNoticeConfig', { error: message }), [ + { label: tr('errorActionOpenSettings'), primary: true, onClick: ctx.openSettings }, + ]); + return; + } + + // Unknown / fallback: keep the legacy short Notice with kind tag, no buttons. + new Notice(tr('generationFailed', { kind: kind === 'unknown' ? '' : ` (${kind})`, error: message })); +} diff --git a/src/generation-job-manager.ts b/src/generation-job-manager.ts index 49fd54d..65d0891 100644 --- a/src/generation-job-manager.ts +++ b/src/generation-job-manager.ts @@ -195,6 +195,24 @@ export function classifyGenerationError(error: unknown): ErrorKind { if (error instanceof GenerationJobCancelledError) return 'cancelled'; const errObj = error as { code?: string; message?: string } | null; if (errObj?.code === 'cancelled') return 'cancelled'; + + // Structured CLI error short-circuits message-regex matching for the deterministic cases. + // Duck-typed to avoid circular import with ./cli. + const details = (error as { details?: { reason?: string } } | null)?.details; + if (details && typeof details.reason === 'string') { + switch (details.reason) { + case 'wall-timeout': + case 'idle-timeout': + return 'timeout'; + case 'spawn-failure': + case 'startup-error': + return 'config'; + case 'streams-unavailable': + return 'unknown'; + // exit-nonzero falls through: stderr might carry auth/rate-limit/schema info. + } + } + const message = String(errObj?.message || error); if (/api key|unauthorized|401|403|认证|权限/i.test(message)) return 'auth'; if (/timeout|超时|timed out/i.test(message)) return 'timeout'; diff --git a/src/i18n-strings.ts b/src/i18n-strings.ts index e8986ba..1c5027e 100644 --- a/src/i18n-strings.ts +++ b/src/i18n-strings.ts @@ -84,6 +84,31 @@ export const STRINGS: Record> = { emptyNote: '笔记为空', longNoteTruncated: '笔记较长:仅发送前 {count} 个字符给模型', generatingNotice: '对照阅读:让 LLM 读全文并自适应切段…', + errorNoticeTimeout: '生成超时。可能是网络挂死、CLI 启动失败或模型响应慢。', + errorNoticeAuth: 'API key 缺失或无效。请到设置中检查后重试。', + errorNoticeRateLimit: '触发限流:{error}\n请稍候片刻再重试。', + errorNoticeSchema: '模型返回的内容不符合 JSON 格式。可复制原始输出排查或重试。', + errorNoticeConfig: '配置错误:{error}', + errorActionOpenSettings: '打开设置', + errorActionCopyDetails: '复制详情', + errorActionCopyRaw: '复制原始输出', + errorModalTimeoutTitle: 'CLI 超时诊断', + errorModalReasonWall: + 'CLI 在墙钟超时之前没有完成。建议先看 stderr 是否有线索,再考虑增大 CLI 超时或换用 API 后端。', + errorModalReasonIdle: + 'CLI 在静默超时窗口内没有任何输出,已被终止。常见原因:网络挂死、模型在思考但 CLI 不流式输出。', + errorModalFieldCmd: '命令', + errorModalFieldPid: '进程 PID', + errorModalFieldElapsed: '已耗时', + errorModalFieldIdle: '最后活动后静默', + errorModalFieldBytes: '已收字节', + errorModalStderrTail: 'stderr 末尾(已脱敏)', + errorModalStdoutTail: 'stdout 末尾(已脱敏)', + errorModalNoOutput: 'CLI 在被终止前没有产生任何输出 —— 通常意味着启动后立即挂死(网络/认证)。', + errorModalActionCopy: '复制完整诊断', + errorModalActionOpenSettings: '打开设置', + errorModalActionClose: '关闭', + errorModalCopySuccess: '已复制到剪贴板', noCardsReturned: 'LLM 未返回任何 card', generationDone: '对照笔记生成完成:{count} 段{suffix}', unanchoredSuffix: '(⚠ {count} 段 anchor 未匹配)', @@ -272,6 +297,32 @@ export const STRINGS: Record> = { emptyNote: 'The note is empty', longNoteTruncated: 'Long note: only the first {count} characters will be sent to the model', generatingNotice: 'Parallel Reader: asking the LLM to read and segment the full note...', + errorNoticeTimeout: 'Generation timed out. Possible network hang, CLI startup failure, or slow model.', + errorNoticeAuth: 'API key missing or invalid. Check Settings and retry.', + errorNoticeRateLimit: 'Rate-limited: {error}\nWait a moment and retry.', + errorNoticeSchema: 'Model returned non-JSON output. Copy the raw output to inspect or retry.', + errorNoticeConfig: 'Config error: {error}', + errorActionOpenSettings: 'Open Settings', + errorActionCopyDetails: 'Copy details', + errorActionCopyRaw: 'Copy raw output', + errorModalTimeoutTitle: 'CLI timeout diagnostics', + errorModalReasonWall: + 'The CLI did not finish before the wall-clock timeout. Inspect stderr below, then consider raising the CLI timeout or switching to the API backend.', + errorModalReasonIdle: + 'The CLI produced no output during the idle-timeout window and was terminated. Common causes: network hang, or model thinking with a non-streaming CLI.', + errorModalFieldCmd: 'Command', + errorModalFieldPid: 'PID', + errorModalFieldElapsed: 'Elapsed', + errorModalFieldIdle: 'Idle since last activity', + errorModalFieldBytes: 'Bytes received', + errorModalStderrTail: 'stderr tail (redacted)', + errorModalStdoutTail: 'stdout tail (redacted)', + errorModalNoOutput: + 'The CLI produced no output before being killed — typically a startup-time hang (network/auth).', + errorModalActionCopy: 'Copy full diagnostics', + errorModalActionOpenSettings: 'Open Settings', + errorModalActionClose: 'Close', + errorModalCopySuccess: 'Copied to clipboard', noCardsReturned: 'LLM returned no cards', generationDone: 'Generated {count} sections{suffix}', unanchoredSuffix: ' (⚠ {count} anchors unmatched)', diff --git a/src/test-exports.ts b/src/test-exports.ts index d542188..9c40e45 100644 --- a/src/test-exports.ts +++ b/src/test-exports.ts @@ -20,7 +20,7 @@ export { export { serializeCacheFile, shouldConfirmRegenerate, touchCacheEntry } from './cache'; export { CacheManager } from './cache-manager'; export { activeIndexAfterCardDelete, removeCardAt, updateCardAt } from './cards'; -export { resolveCliPath, runCli, summarizeViaClaudeCode, summarizeViaCodex } from './cli'; +export { CliProcessError, resolveCliPath, runCli, summarizeViaClaudeCode, summarizeViaCodex } from './cli'; export { cancellationNoticeKey, summarizeDocument } from './generation'; export { classifyGenerationError, diff --git a/styles.css b/styles.css index 18bdf09..49f6f26 100644 --- a/styles.css +++ b/styles.css @@ -473,3 +473,60 @@ color: var(--text-faint); font-weight: normal; } + +/* ---- Generation error UI: actionable notice + diagnostics modal ---- */ + +.parallel-reader-error-notice .parallel-reader-error-notice-message { + white-space: pre-wrap; + margin-bottom: 0.6em; +} + +.parallel-reader-error-notice .parallel-reader-error-notice-actions { + display: flex; + gap: 0.4em; + flex-wrap: wrap; +} + +.parallel-reader-error-notice .parallel-reader-error-notice-button { + padding: 0.25em 0.7em; + font-size: 0.85em; + cursor: pointer; +} + +.parallel-reader-error-modal .parallel-reader-error-modal-grid { + display: grid; + grid-template-columns: max-content 1fr; + column-gap: 0.8em; + row-gap: 0.2em; + font-size: 0.85em; + margin: 0.6em 0; +} + +.parallel-reader-error-modal .parallel-reader-error-modal-row { + display: contents; +} + +.parallel-reader-error-modal .parallel-reader-error-modal-label { + color: var(--text-muted); +} + +.parallel-reader-error-modal .parallel-reader-error-modal-value { + font-family: var(--font-monospace); + word-break: break-all; +} + +.parallel-reader-error-modal .parallel-reader-error-modal-tail { + background: var(--background-secondary); + padding: 0.6em 0.8em; + border-radius: 4px; + font-size: 0.8em; + max-height: 240px; + overflow: auto; + white-space: pre-wrap; + word-break: break-all; +} + +.parallel-reader-error-modal .parallel-reader-error-modal-empty { + color: var(--text-muted); + font-style: italic; +} diff --git a/tests/cli.test.js b/tests/cli.test.js index 822af74..eb09228 100644 --- a/tests/cli.test.js +++ b/tests/cli.test.js @@ -66,6 +66,13 @@ async function testRunCliEdgeCases() { assert.match(err.message, /stderr=\d+B/, 'timeout error carries stderr byte count'); assert.match(err.message, /stderr tail/, 'timeout error includes stderr tail'); assert.match(err.message, /boom: connection reset/, 'timeout error preserves last stderr line'); + // Structured CliProcessError details (P0 #2: error UI dispatch needs typed access). + assert.ok(err instanceof t.CliProcessError, 'wall-timeout throws CliProcessError'); + assert.strictEqual(err.details.reason, 'wall-timeout', 'wall-timeout reason set'); + assert.strictEqual(err.details.pid, 4242, 'details.pid populated'); + assert.ok(err.details.stderrBytes >= 'boom: connection reset'.length, 'details.stderrBytes utf-8 byte count'); + assert.match(err.details.stderrTail, /boom: connection reset/, 'details.stderrTail preserved'); + assert.strictEqual(err.details.timeoutMs, 20, 'details.timeoutMs reflects wall timeout'); return true; }, 'runCli rejects on timeout with rich diagnostics', @@ -80,6 +87,9 @@ async function testRunCliEdgeCases() { (err) => { assert.match(err.message, /CLI idle timeout \(15ms\)/, 'idle timeout error message'); assert.match(err.message, /pid=5252/, 'idle timeout carries pid'); + assert.ok(err instanceof t.CliProcessError, 'idle-timeout throws CliProcessError'); + assert.strictEqual(err.details.reason, 'idle-timeout', 'idle-timeout reason set'); + assert.strictEqual(err.details.idleTimeoutMs, 15, 'details.idleTimeoutMs reflects idle window'); return true; }, 'runCli idle timeout fires when no output', @@ -132,10 +142,35 @@ async function testRunCliEdgeCases() { await assert.rejects( () => t.runCli('fake', [], '', 100, undefined, () => ({ stdout: null, stderr: null, stdin: null })), - /CLI process streams are unavailable/, + (err) => { + assert.match(err.message, /CLI process streams are unavailable/, 'streams-unavailable message preserved'); + assert.ok(err instanceof t.CliProcessError, 'streams-unavailable throws CliProcessError'); + assert.strictEqual(err.details.reason, 'streams-unavailable', 'streams-unavailable reason set'); + return true; + }, 'runCli reports unavailable stdio streams', ); + // Non-zero exit still throws a typed CliProcessError so dispatcher can inspect stderr. + const exitChild = createFakeChild(); + exitChild.pid = 7373; + const exitPromise = t.runCli('fake', [], '', 1000, undefined, () => exitChild); + setImmediate(() => { + exitChild.stderr.emit('data', Buffer.from('error: invalid api key')); + exitChild.emit('close', 2, null); + }); + await assert.rejects( + () => exitPromise, + (err) => { + assert.ok(err instanceof t.CliProcessError, 'exit-nonzero throws CliProcessError'); + assert.strictEqual(err.details.reason, 'exit-nonzero', 'exit-nonzero reason set'); + assert.strictEqual(err.details.exitCode, 2, 'details.exitCode populated'); + assert.match(err.details.stderrTail, /invalid api key/, 'details.stderrTail captures auth hint'); + return true; + }, + 'runCli exposes non-zero exit code in structured details', + ); + // Diagnostic suffix must redact secrets that show up in stderr (Bearer tokens, sk- keys, etc.) const secretChild = createFakeChild(); secretChild.pid = 6363; @@ -433,6 +468,62 @@ assert.strictEqual(t.classifyGenerationError(new Error('API key 未设置')), 'a assert.strictEqual(t.classifyGenerationError(new Error('API key is not set')), 'auth'); assert.strictEqual(t.classifyGenerationError(new Error('CLI timed out (120000ms)')), 'timeout'); assert.strictEqual(t.classifyGenerationError(new Error('CLI 超时 (120000ms)')), 'timeout'); + +// Structured CliProcessError short-circuits message-regex matching for deterministic reasons. +const wallTimeout = new t.CliProcessError('whatever message text', { + reason: 'wall-timeout', + cmd: 'claude', + pid: 1, + elapsedMs: 0, + idleMs: 0, + stdoutBytes: 0, + stderrBytes: 0, + stdoutTail: '', + stderrTail: '', + exitCode: null, + signal: null, + timeoutMs: 0, + idleTimeoutMs: 0, +}); +assert.strictEqual(t.classifyGenerationError(wallTimeout), 'timeout', 'wall-timeout details → timeout kind'); +const spawnFailure = new t.CliProcessError('Failed to start claude', { + reason: 'spawn-failure', + cmd: 'claude', + pid: null, + elapsedMs: 0, + idleMs: null, + stdoutBytes: 0, + stderrBytes: 0, + stdoutTail: '', + stderrTail: '', + exitCode: null, + signal: null, + timeoutMs: 0, + idleTimeoutMs: 0, +}); +assert.strictEqual(t.classifyGenerationError(spawnFailure), 'config', 'spawn-failure details → config kind'); +// exit-nonzero falls through so stderr-derived auth/rate-limit messages can still classify. +const exitWithAuthStderr = new t.CliProcessError('CLI exited with code 1\nstderr:\nerror: API key invalid', { + reason: 'exit-nonzero', + cmd: 'claude', + pid: 1, + elapsedMs: 0, + idleMs: 0, + stdoutBytes: 0, + stderrBytes: 0, + stdoutTail: '', + stderrTail: '', + exitCode: 1, + signal: null, + timeoutMs: 0, + idleTimeoutMs: 0, +}); +assert.strictEqual( + t.classifyGenerationError(exitWithAuthStderr), + 'auth', + 'exit-nonzero falls through so message-regex still detects auth', +); + assert.strictEqual(t.classifyGenerationError(new Error('API returned HTTP 429')), 'rate-limit'); assert.strictEqual(t.classifyGenerationError(new Error('json_schema validation failed')), 'schema'); assert.strictEqual(t.classifyGenerationError(new Error('LLM 返回非 JSON')), 'schema');