diff --git a/CHANGELOG.md b/CHANGELOG.md index bde5b2c..8b54636 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,7 @@ Repo: https://github.com/openclaw/acpx ### Changes - Conformance/ACP: add a data-driven ACP core v1 conformance suite with CI smoke coverage, nightly coverage, and a hardened runner that reports startup failures cleanly and scopes filesystem checks to the session cwd. (#130) Thanks @lynnzc. +- CLI/prompts: add `--prompt-retries` to retry transient prompt failures with exponential backoff while preserving strict JSON behavior and avoiding replay after prompt side effects. (#142) Thanks @lupuletic and @dutifulbob. - Output: add `--suppress-reads` to mask raw file-read bodies in text and JSON output while keeping normal tool activity visible. (#136) Thanks @hayatosc. - Agents/droid: add `factory-droid` and `factorydroid` aliases for the built-in Factory Droid adapter and sync the built-in docs. Thanks @vincentkoc. - Flows/workflows: add an initial `flow run` command, an `acpx/flows` runtime surface, and file-backed flow run state under `~/.acpx/flows/runs` for user-authored workflow modules. Thanks @osolmaz. diff --git a/src/cli-core.ts b/src/cli-core.ts index d483716..b3e6847 100644 --- a/src/cli-core.ts +++ b/src/cli-core.ts @@ -287,6 +287,7 @@ async function handlePrompt( timeoutMs: globalFlags.timeout, ttlMs: globalFlags.ttl, maxQueueDepth: config.queueMaxDepth, + promptRetries: globalFlags.promptRetries, verbose: globalFlags.verbose, waitForCompletion: flags.wait !== false, }); @@ -361,6 +362,7 @@ async function handleExec( suppressSdkConsoleErrors: outputPolicy.suppressSdkConsoleErrors, timeoutMs: globalFlags.timeout, verbose: globalFlags.verbose, + promptRetries: globalFlags.promptRetries, sessionOptions: { model: globalFlags.model, allowedTools: globalFlags.allowedTools, diff --git a/src/cli/flags.ts b/src/cli/flags.ts index 9940583..975629c 100644 --- a/src/cli/flags.ts +++ b/src/cli/flags.ts @@ -42,6 +42,7 @@ export type GlobalFlags = PermissionFlags & { model?: string; allowedTools?: string[]; maxTurns?: number; + promptRetries?: number; }; export type PromptFlags = { @@ -158,6 +159,14 @@ export function parseMaxTurns(value: string): number { return parsed; } +export function parsePromptRetries(value: string): number { + const parsed = Number(value); + if (!Number.isInteger(parsed) || parsed < 0) { + throw new InvalidArgumentError("Prompt retries must be a non-negative integer"); + } + return parsed; +} + export function resolvePermissionMode( flags: PermissionFlags, defaultMode: PermissionMode, @@ -209,6 +218,11 @@ export function addGlobalFlags(command: Command): Command { parseAllowedTools, ) .option("--max-turns ", "Maximum turns for the session", parseMaxTurns) + .option( + "--prompt-retries ", + "Retry failed prompt turns on transient errors (default: 0)", + parsePromptRetries, + ) .option( "--json-strict", "Strict JSON mode: requires --format json and suppresses non-JSON stderr output", @@ -295,6 +309,7 @@ export function resolveGlobalFlags(command: Command, config: ResolvedAcpxConfig) model: typeof opts.model === "string" ? parseNonEmptyValue("Model", opts.model) : undefined, allowedTools: Array.isArray(opts.allowedTools) ? opts.allowedTools : undefined, maxTurns: typeof opts.maxTurns === "number" ? opts.maxTurns : undefined, + promptRetries: typeof opts.promptRetries === "number" ? opts.promptRetries : undefined, approveAll: opts.approveAll ? true : undefined, approveReads: opts.approveReads ? true : undefined, denyAll: opts.denyAll ? true : undefined, diff --git a/src/error-normalization.ts b/src/error-normalization.ts index 035617b..02fb86b 100644 --- a/src/error-normalization.ts +++ b/src/error-normalization.ts @@ -226,6 +226,49 @@ export function normalizeOutputError( }; } +/** + * Returns true when an error from `client.prompt()` looks transient and + * can reasonably be retried (e.g. model-API 400/500, network hiccups that + * surface as ACP internal errors). + * + * Errors that are definitively non-recoverable (auth, missing session, + * invalid params, timeout, permission) return false. + */ +export function isRetryablePromptError(error: unknown): boolean { + if (error instanceof PermissionDeniedError || error instanceof PermissionPromptUnavailableError) { + return false; + } + if (isTimeoutLike(error) || isNoSessionLike(error) || isUsageLike(error)) { + return false; + } + + // Extract ACP payload once and reuse for all subsequent checks. + const acp = extractAcpError(error); + if (!acp) { + // Non-ACP errors (e.g. process crash) are not retried at the prompt level. + return false; + } + + // Resource-not-found (session gone) — check using the already-extracted payload. + if (acp.code === -32001 || acp.code === -32002) { + return false; + } + + // Auth-required errors are never retryable. Use the same thorough check as normalizeOutputError. + if (isAcpAuthRequiredPayload(acp)) { + return false; + } + + // Method-not-found or invalid-params are permanent protocol errors. + if (acp.code === -32601 || acp.code === -32602) { + return false; + } + + // ACP internal errors (-32603) typically wrap model-API failures → retryable. + // Parse errors (-32700) can also be transient. + return acp.code === -32603 || acp.code === -32700; +} + export function exitCodeForOutputErrorCode(code: OutputErrorCode): ExitCode { switch (code) { case "USAGE": diff --git a/src/queue-owner-env.ts b/src/queue-owner-env.ts index d0b532b..b013f8d 100644 --- a/src/queue-owner-env.ts +++ b/src/queue-owner-env.ts @@ -72,6 +72,10 @@ export function parseQueueOwnerPayload(raw: string): QueueOwnerRuntimeOptions { options.maxQueueDepth = Math.max(1, Math.round(record.maxQueueDepth)); } + if (typeof record.promptRetries === "number" && Number.isFinite(record.promptRetries)) { + options.promptRetries = Math.max(0, Math.round(record.promptRetries)); + } + return options; } diff --git a/src/session-runtime.ts b/src/session-runtime.ts index b19ad57..baed3d5 100644 --- a/src/session-runtime.ts +++ b/src/session-runtime.ts @@ -1,7 +1,11 @@ import fs from "node:fs/promises"; import path from "node:path"; import { AcpClient } from "./client.js"; -import { formatErrorMessage, normalizeOutputError } from "./error-normalization.js"; +import { + formatErrorMessage, + isRetryablePromptError, + normalizeOutputError, +} from "./error-normalization.js"; import { checkpointPerfMetricsCapture } from "./perf-metrics-capture.js"; import { formatPerfMetric, measurePerf, setPerfGauge, startPerfTimer } from "./perf-metrics.js"; import { refreshQueueOwnerLease } from "./queue-lease-store.js"; @@ -178,6 +182,7 @@ export type RunOnceOptions = { suppressSdkConsoleErrors?: boolean; verbose?: boolean; sessionOptions?: SessionAgentOptions; + promptRetries?: number; } & TimedRunOptions; export type SessionCreateOptions = { @@ -214,6 +219,7 @@ export type SessionSendOptions = { ttlMs?: number; maxQueueDepth?: number; client?: AcpClient; + promptRetries?: number; } & TimedRunOptions; export type SessionEnsureOptions = { @@ -290,6 +296,7 @@ type RunSessionPromptOptions = { timeoutMs?: number; suppressSdkConsoleErrors?: boolean; verbose?: boolean; + promptRetries?: number; onClientAvailable?: (controller: ActiveSessionController) => void; onClientClosed?: () => void; onPromptActive?: () => Promise | void; @@ -464,6 +471,23 @@ export function normalizeQueueOwnerTtlMs(ttlMs: number | undefined): number { return Math.round(ttlMs); } +function emitPromptRetryNotice(params: { + error: unknown; + delayMs: number; + attempt: number; + maxRetries: number; + suppressSdkConsoleErrors?: boolean; +}): void { + if (params.suppressSdkConsoleErrors) { + return; + } + + process.stderr.write( + `[acpx] prompt failed (${formatErrorMessage(params.error)}), retrying in ${params.delayMs}ms ` + + `(attempt ${params.attempt}/${params.maxRetries})\n`, + ); +} + async function runQueuedTask( sessionRecordId: string, task: QueueTask, @@ -475,6 +499,7 @@ async function runQueuedTask( authCredentials?: Record; authPolicy?: AuthPolicy; suppressSdkConsoleErrors?: boolean; + promptRetries?: number; onClientAvailable?: (controller: ActiveSessionController) => void; onClientClosed?: () => void; onPromptActive?: () => Promise | void; @@ -499,6 +524,7 @@ async function runQueuedTask( timeoutMs: task.timeoutMs, suppressSdkConsoleErrors: task.suppressSdkConsoleErrors ?? options.suppressSdkConsoleErrors, verbose: options.verbose, + promptRetries: options.promptRetries, onClientAvailable: options.onClientAvailable, onClientClosed: options.onClientClosed, onPromptActive: options.onPromptActive, @@ -561,6 +587,8 @@ async function runSessionPrompt(options: RunSessionPromptOptions): Promise { + if (promptTurnActive) { + promptTurnHadSideEffects = true; + } acpxState = recordConversationSessionUpdate(conversation, acpxState, notification); trimConversationForRuntime(conversation); options.onSessionUpdate?.(notification); }, onClientOperation: (operation) => { + if (promptTurnActive) { + promptTurnHadSideEffects = true; + } acpxState = recordConversationClientOperation(conversation, acpxState, operation); trimConversationForRuntime(conversation); options.onClientOperation?.(operation); @@ -698,64 +732,96 @@ async function runSessionPrompt(options: RunSessionPromptOptions): Promise { + return await withTimeout(promptPromise, options.timeoutMs); + }); + if (options.verbose) { + process.stderr.write( + `[acpx] ${formatPerfMetric("prompt.agent_turn", Date.now() - promptStartedAt)}\n`, + ); + } + break; + } catch (error) { + const snapshot = client.getAgentLifecycleSnapshot(); + const agentCrashed = snapshot.lastExit?.unexpectedDuringPrompt === true; + + // Retry if: retries remain, agent is still alive, error is transient. + if ( + attempt < maxRetries && + !agentCrashed && + !promptTurnHadSideEffects && + isRetryablePromptError(error) + ) { + const delayMs = Math.min(1_000 * 2 ** attempt, 10_000); + emitPromptRetryNotice({ + error, + delayMs, + attempt: attempt + 1, + maxRetries, + suppressSdkConsoleErrors: options.suppressSdkConsoleErrors, + }); + await waitMs(delayMs); + if (!promptTurnHadSideEffects) { + continue; } } - } - response = await measurePerf("runtime.prompt.agent_turn", async () => { - return await withTimeout(promptPromise, options.timeoutMs); - }); - if (options.verbose) { - process.stderr.write( - `[acpx] ${formatPerfMetric("prompt.agent_turn", Date.now() - promptStartedAt)}\n`, - ); - } - } catch (error) { - const snapshot = client.getAgentLifecycleSnapshot(); - applyLifecycleSnapshotToRecord(record, snapshot); - if (snapshot.lastExit?.unexpectedDuringPrompt && options.verbose) { - process.stderr.write( - "[acpx] agent disconnected during prompt (" + - snapshot.lastExit.reason + - ", exit=" + - snapshot.lastExit.exitCode + - ", signal=" + - (snapshot.lastExit.signal ?? "none") + - ")\n", - ); - } - const normalizedError = normalizeOutputError(error, { - origin: "runtime", - }); + promptTurnActive = false; + applyLifecycleSnapshotToRecord(record, snapshot); + const lastExit = snapshot.lastExit; + if (lastExit?.unexpectedDuringPrompt && options.verbose) { + process.stderr.write( + "[acpx] agent disconnected during prompt (" + + lastExit.reason + + ", exit=" + + lastExit.exitCode + + ", signal=" + + (lastExit.signal ?? "none") + + ")\n", + ); + } - await flushPendingMessages(false).catch(() => { - // best effort while bubbling prompt failure - }); + const normalizedError = normalizeOutputError(error, { + origin: "runtime", + }); + + await flushPendingMessages(false).catch(() => { + // best effort while bubbling prompt failure + }); - output.flush(); + output.flush(); - record.lastUsedAt = isoNow(); - applyConversation(record, conversation); - record.acpx = acpxState; + record.lastUsedAt = isoNow(); + applyConversation(record, conversation); + record.acpx = acpxState; - const propagated = error instanceof Error ? error : new Error(formatErrorMessage(error)); - (propagated as { outputAlreadyEmitted?: boolean }).outputAlreadyEmitted = sawAcpMessage; - (propagated as { normalizedOutputError?: unknown }).normalizedOutputError = - normalizedError; - throw propagated; + const propagated = + error instanceof Error ? error : new Error(formatErrorMessage(error)); + (propagated as { outputAlreadyEmitted?: boolean }).outputAlreadyEmitted = sawAcpMessage; + (propagated as { normalizedOutputError?: unknown }).normalizedOutputError = + normalizedError; + throw propagated; + } } + promptTurnActive = false; await flushPendingMessages(false); output.flush(); @@ -819,6 +885,8 @@ async function runSessionPrompt(options: RunSessionPromptOptions): Promise { const output = options.outputFormatter; + let promptTurnActive = false; + let promptTurnHadSideEffects = false; const client = new AcpClient({ agentCommand: options.agentCommand, cwd: absolutePath(options.cwd), @@ -831,8 +899,18 @@ export async function runOnce(options: RunOnceOptions): Promise verbose: options.verbose, onAcpMessage: options.onAcpMessage, onAcpOutputMessage: (_direction, message) => output.onAcpMessage(message), - onSessionUpdate: options.onSessionUpdate, - onClientOperation: options.onClientOperation, + onSessionUpdate: (notification) => { + if (promptTurnActive) { + promptTurnHadSideEffects = true; + } + options.onSessionUpdate?.(notification); + }, + onClientOperation: (operation) => { + if (promptTurnActive) { + promptTurnHadSideEffects = true; + } + options.onClientOperation?.(operation); + }, sessionOptions: options.sessionOptions, }); @@ -854,9 +932,39 @@ export async function runOnce(options: RunOnceOptions): Promise sessionId, }); - const response = await measurePerf("runtime.exec.prompt", async () => { - return await withTimeout(client.prompt(sessionId, options.prompt), options.timeoutMs); - }); + const maxRetries = options.promptRetries ?? 0; + let response; + promptTurnActive = true; + for (let attempt = 0; ; attempt++) { + try { + response = await measurePerf("runtime.exec.prompt", async () => { + return await withTimeout(client.prompt(sessionId, options.prompt), options.timeoutMs); + }); + break; + } catch (error) { + if ( + attempt < maxRetries && + !promptTurnHadSideEffects && + isRetryablePromptError(error) + ) { + const delayMs = Math.min(1_000 * 2 ** attempt, 10_000); + emitPromptRetryNotice({ + error, + delayMs, + attempt: attempt + 1, + maxRetries, + suppressSdkConsoleErrors: options.suppressSdkConsoleErrors, + }); + await waitMs(delayMs); + if (!promptTurnHadSideEffects) { + continue; + } + } + promptTurnActive = false; + throw error; + } + } + promptTurnActive = false; output.flush(); return toPromptResult(response.stopReason, sessionId, client); }, @@ -1194,6 +1302,7 @@ export async function runSessionQueueOwner(options: QueueOwnerRuntimeOptions): P authCredentials: options.authCredentials, authPolicy: options.authPolicy, suppressSdkConsoleErrors: options.suppressSdkConsoleErrors, + promptRetries: options.promptRetries, onClientAvailable: setActiveController, onClientClosed: clearActiveController, onPromptActive: async () => { diff --git a/src/session-runtime/queue-owner-process.ts b/src/session-runtime/queue-owner-process.ts index 15f5852..43e5642 100644 --- a/src/session-runtime/queue-owner-process.ts +++ b/src/session-runtime/queue-owner-process.ts @@ -18,6 +18,7 @@ export type QueueOwnerRuntimeOptions = { verbose?: boolean; ttlMs?: number; maxQueueDepth?: number; + promptRetries?: number; }; type SessionSendLike = { @@ -31,6 +32,7 @@ type SessionSendLike = { verbose?: boolean; ttlMs?: number; maxQueueDepth?: number; + promptRetries?: number; }; export function sanitizeQueueOwnerExecArgv( @@ -128,6 +130,7 @@ export function queueOwnerRuntimeOptionsFromSend( verbose: options.verbose, ttlMs: options.ttlMs, maxQueueDepth: options.maxQueueDepth, + promptRetries: options.promptRetries, }; } diff --git a/test/integration.test.ts b/test/integration.test.ts index 7124354..c7dff62 100644 --- a/test/integration.test.ts +++ b/test/integration.test.ts @@ -760,6 +760,9 @@ test("integration: exec forwards model, allowed-tools, and max-turns in session/ const cwd = await fs.mkdtemp(path.join(os.tmpdir(), "acpx-integration-cwd-")); try { + const created = await runCli([...baseAgentArgs(cwd), "sessions", "new"], homeDir); + assert.equal(created.code, 0, created.stderr); + const result = await runCli( [ ...baseAgentArgs(cwd), @@ -1539,6 +1542,44 @@ test("integration: json-strict exec success emits JSON-RPC lines only", async () }); }); +test("integration: json-strict exec retries without emitting stderr notices", async () => { + await withTempHome(async (homeDir) => { + const cwd = await fs.mkdtemp(path.join(os.tmpdir(), "acpx-integration-cwd-")); + + try { + const result = await runCli( + [ + ...baseAgentArgs(cwd), + "--format", + "json", + "--json-strict", + "--prompt-retries", + "1", + "exec", + "retryable-error-once", + ], + homeDir, + ); + + assert.equal(result.code, 0, result.stderr); + assert.equal(result.stderr.trim(), ""); + + const payloads = parseJsonRpcOutputLines(result.stdout); + const promptRequests = payloads.filter((payload) => payload.method === "session/prompt"); + assert.equal(promptRequests.length, 2, result.stdout); + assert.equal( + payloads.some( + (payload) => extractAgentMessageChunkText(payload) === "recovered after retry", + ), + true, + result.stdout, + ); + } finally { + await fs.rm(cwd, { recursive: true, force: true }); + } + }); +}); + test("integration: fs/read_text_file through mock agent", async () => { await withTempHome(async (homeDir) => { const cwd = await fs.mkdtemp(path.join(os.tmpdir(), "acpx-integration-cwd-")); @@ -1884,6 +1925,71 @@ test("integration: prompt recovers when loadSession fails on empty session witho }); }); +test("integration: prompt retries stop after partial prompt output", async () => { + await withTempHome(async (homeDir) => { + const cwd = await fs.mkdtemp(path.join(os.tmpdir(), "acpx-integration-cwd-")); + + try { + const created = await runCli([...baseAgentArgs(cwd), "sessions", "new"], homeDir); + assert.equal(created.code, 0, created.stderr); + + const result = await runCli( + [ + ...baseAgentArgs(cwd), + "--format", + "json", + "--prompt-retries", + "1", + "prompt", + "partial-retryable-error", + ], + homeDir, + ); + assert.notEqual(result.code, 0, result.stderr); + assert.equal(/retrying in/.test(result.stderr), false, result.stderr); + + const payloads = parseJsonRpcOutputLines(result.stdout); + const partialUpdates = payloads.filter( + (payload) => extractAgentMessageChunkText(payload) === "partial update", + ); + assert.equal(partialUpdates.length, 1, result.stdout); + } finally { + await fs.rm(cwd, { recursive: true, force: true }); + } + }); +}); + +test("integration: exec retries stop after partial prompt output", async () => { + await withTempHome(async (homeDir) => { + const cwd = await fs.mkdtemp(path.join(os.tmpdir(), "acpx-integration-cwd-")); + + try { + const result = await runCli( + [ + ...baseAgentArgs(cwd), + "--format", + "json", + "--prompt-retries", + "1", + "exec", + "partial-retryable-error", + ], + homeDir, + ); + assert.equal(result.code, 1, result.stderr); + assert.equal(/retrying in/.test(result.stderr), false, result.stderr); + + const payloads = parseJsonRpcOutputLines(result.stdout); + const partialUpdates = payloads.filter( + (payload) => extractAgentMessageChunkText(payload) === "partial update", + ); + assert.equal(partialUpdates.length, 1, result.stdout); + } finally { + await fs.rm(cwd, { recursive: true, force: true }); + } + }); +}); + test("integration: prompt recovers when loadSession returns not found without emitting load error", async () => { await withTempHome(async (homeDir) => { const cwd = await fs.mkdtemp(path.join(os.tmpdir(), "acpx-integration-cwd-")); diff --git a/test/mock-agent.ts b/test/mock-agent.ts index e3248b7..f0f3c46 100644 --- a/test/mock-agent.ts +++ b/test/mock-agent.ts @@ -48,6 +48,7 @@ type SessionState = { hasCompletedPrompt: boolean; modeId: string; configValues: Record; + transientPromptAttempts: Record; }; class CancelledError extends Error { @@ -398,6 +399,7 @@ function createSessionState(hasCompletedPrompt = false): SessionState { configValues: { reasoning_effort: "medium", }, + transientPromptAttempts: {}, }; } @@ -540,9 +542,54 @@ class MockAgent implements Agent { session.pendingPrompt?.abort(); const promptAbort = new AbortController(); session.pendingPrompt = promptAbort; + const text = getPromptText(params.prompt); + + if (text === "partial-retryable-error") { + try { + await this.sendAssistantMessage(params.sessionId, "partial update"); + const error = new Error("Internal error") as Error & { + code: number; + data: { + details: string; + }; + }; + error.code = -32603; + error.data = { + details: "transient failure after partial output", + }; + throw error; + } finally { + if (session.pendingPrompt === promptAbort) { + session.pendingPrompt = undefined; + } + } + } + + if (text === "retryable-error-once") { + const attempts = session.transientPromptAttempts[text] ?? 0; + session.transientPromptAttempts[text] = attempts + 1; + if (attempts === 0) { + try { + const error = new Error("Internal error") as Error & { + code: number; + data: { + details: string; + }; + }; + error.code = -32603; + error.data = { + details: "transient failure before output", + }; + throw error; + } finally { + if (session.pendingPrompt === promptAbort) { + session.pendingPrompt = undefined; + } + } + } + } try { - const text = getPromptText(params.prompt); const response = text === "inspect-prompt" ? describePromptBlocks(params.prompt) @@ -659,6 +706,9 @@ class MockAgent implements Agent { if (text === "echo") { return ""; } + if (text === "retryable-error-once") { + return "recovered after retry"; + } if (text.startsWith("read ")) { const filePath = text.slice("read ".length).trim(); diff --git a/test/prompt-retry.test.ts b/test/prompt-retry.test.ts new file mode 100644 index 0000000..88fe634 --- /dev/null +++ b/test/prompt-retry.test.ts @@ -0,0 +1,74 @@ +import assert from "node:assert/strict"; +import test from "node:test"; +import { isRetryablePromptError } from "../src/error-normalization.js"; +import { PermissionDeniedError, PermissionPromptUnavailableError } from "../src/errors.js"; + +// --- isRetryablePromptError --- + +test("isRetryablePromptError returns true for ACP internal error (-32603)", () => { + const error = { code: -32603, message: "Internal error" }; + assert.equal(isRetryablePromptError(error), true); +}); + +test("isRetryablePromptError returns true for ACP parse error (-32700)", () => { + const error = { code: -32700, message: "Parse error" }; + assert.equal(isRetryablePromptError(error), true); +}); + +test("isRetryablePromptError returns true for wrapped ACP internal error", () => { + const error = new Error("prompt failed"); + (error as Error & { error?: unknown }).error = { + code: -32603, + message: "Internal error", + data: { details: "model returned HTTP 400" }, + }; + assert.equal(isRetryablePromptError(error), true); +}); + +test("isRetryablePromptError returns false for auth-required error (-32000)", () => { + const error = { code: -32000, message: "Authentication required" }; + assert.equal(isRetryablePromptError(error), false); +}); + +test("isRetryablePromptError returns false for method-not-found error (-32601)", () => { + const error = { code: -32601, message: "Method not found: session/prompt" }; + assert.equal(isRetryablePromptError(error), false); +}); + +test("isRetryablePromptError returns false for invalid-params error (-32602)", () => { + const error = { code: -32602, message: "Invalid params" }; + assert.equal(isRetryablePromptError(error), false); +}); + +test("isRetryablePromptError returns false for resource-not-found error (-32002)", () => { + const error = { code: -32002, message: "Resource not found: session" }; + assert.equal(isRetryablePromptError(error), false); +}); + +test("isRetryablePromptError returns false for PermissionDeniedError", () => { + assert.equal(isRetryablePromptError(new PermissionDeniedError("denied")), false); +}); + +test("isRetryablePromptError returns false for PermissionPromptUnavailableError", () => { + assert.equal(isRetryablePromptError(new PermissionPromptUnavailableError()), false); +}); + +test("isRetryablePromptError returns false for TimeoutError", () => { + const error = new Error("timeout"); + error.name = "TimeoutError"; + assert.equal(isRetryablePromptError(error), false); +}); + +test("isRetryablePromptError returns false for non-ACP errors", () => { + assert.equal(isRetryablePromptError(new Error("random failure")), false); +}); + +test("isRetryablePromptError returns false for null/undefined", () => { + assert.equal(isRetryablePromptError(null), false); + assert.equal(isRetryablePromptError(undefined), false); +}); + +test("isRetryablePromptError returns false for auth message in -32603 error", () => { + const error = { code: -32000, message: "auth required" }; + assert.equal(isRetryablePromptError(error), false); +});