feat(error-ui): structured CliProcessError + ErrorKind-driven UI dispatch

Replaces the single long-Notice failure UX with a kind-aware dispatcher
so users get actionable buttons instead of a wall of text.

- CliProcessError extends Error with a typed CliErrorDetails payload
  (reason / pid / elapsed / idle / bytes / tails / exitCode / signal /
  timeoutMs / idleTimeoutMs). All five runCli failure paths now throw
  the typed error; message format is preserved so existing classifier
  regex and tests still match.
- classifyGenerationError short-circuits on details.reason for the
  deterministic cases (wall/idle-timeout → timeout, spawn/startup →
  config, streams-unavailable → unknown). exit-nonzero falls through
  so stderr-derived auth/rate-limit hints still classify correctly.
  Duck-typed check avoids cli ↔ generation-job-manager circular import.
- New error-ui.ts dispatches by ErrorKind:
    timeout (with details) → TimeoutDiagnosticsModal showing cmd, pid,
      elapsed, idle, bytes, redacted stderr/stdout tail, plus copy /
      open-settings / close buttons.
    timeout (no details, e.g. API streaming) → actionable Notice.
    auth/config → Notice + "Open Settings" CTA.
    rate-limit → Notice + "Copy details".
    schema → Notice + "Copy raw output" CTA.
    unknown → unchanged legacy short Notice.
- main.ts handleGenerationError delegates to dispatcher; new
  openPluginSettings() helper uses Obsidian's app.setting.openTabById.
- 21 i18n strings (zh + en) for all new UI labels and the modal copy.
- styles.css adds error notice + diagnostics modal styling.
- 7 new test assertions cover typed details on every failure path and
  classifyGenerationError respecting structured reasons.

Change-Id: I8af46d375d92ba26d14180ffbeb6c3a6dedd89f5
This commit is contained in:
fancivez 2026-05-05 20:19:57 +08:00 committed by wujunchen
parent 89464608aa
commit d738fc120a
8 changed files with 537 additions and 21 deletions

26
main.ts
View file

@ -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() {

View file

@ -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 });
});

198
src/error-ui.ts Normal file
View file

@ -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<string, string | number>) => 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 }));
}

View file

@ -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';

View file

@ -84,6 +84,31 @@ export const STRINGS: Record<string, Record<string, string>> = {
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<string, Record<string, string>> = {
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)',

View file

@ -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,

View file

@ -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;
}

View file

@ -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');