diff --git a/cli/__tests__/adapters.claude.environment.test.mjs b/cli/__tests__/adapters.claude.environment.test.mjs index d84b81816f..4e5ac54435 100644 --- a/cli/__tests__/adapters.claude.environment.test.mjs +++ b/cli/__tests__/adapters.claude.environment.test.mjs @@ -39,7 +39,7 @@ const fakeChild = ({ stdout = '', code = 0 } = {}) => { return proc; }; -const makeSpawnImpl = ({ code = 0 } = {}) => { +const makeSpawnImpl = ({ code = 0, stdout = 'ok' } = {}) => { const calls = []; const impl = (cmd, args, opts) => { const configIndex = args.indexOf('--mcp-config'); @@ -56,11 +56,17 @@ const makeSpawnImpl = ({ code = 0 } = {}) => { configMode: configPath ? fs.statSync(configPath).mode & 0o777 : null, configDirMode: configPath ? fs.statSync(path.dirname(configPath)).mode & 0o777 : null, }); - return fakeChild({ stdout: 'ok', code }); + return fakeChild({ stdout, code }); }; return { impl, calls }; }; +// A public seat's turn posts only on a stream whose init shows Commonly connected (TASK-256). +const COMMONLY_CONNECTED = [ + { type: 'system', subtype: 'init', mcp_servers: [{ name: 'commonly', status: 'connected' }] }, + { type: 'result', subtype: 'success', is_error: false, result: 'ok' }, +].map((event) => JSON.stringify(event)).join('\n'); + describe('claude adapter — ctx.environment', () => { let cwd; beforeEach(() => { @@ -636,7 +642,7 @@ describe('claude adapter — ctx.environment', () => { fs.writeFileSync(path.join(home, 'current'), '0.3.13\n'); fs.writeFileSync(path.join(home, '.warm.lock'), 'warming\n'); const prior = process.env.COMMONLY_MCP_HOME; - const { impl, calls } = makeSpawnImpl(); + const { impl, calls } = makeSpawnImpl({ stdout: COMMONLY_CONNECTED }); try { process.env.COMMONLY_MCP_HOME = home; Object.defineProperty(process, 'platform', { value: 'darwin' }); @@ -1764,3 +1770,176 @@ describe('claude adapter — a workspace seat\'s git tool', () => { expect(warn).toHaveBeenCalledWith(expect.stringContaining('git was not found')); }); }); + +// TASK-256: a public seat posts only when its own stream shows the Commonly MCP +// working. The stream shapes are the ones Claude Code 2.1.314 wrote against a +// stub MCP server: `mcp_servers[].status` in init, and a failed call as a +// `tool_result` with `is_error: true` whose content is the server's text. +describe('claude adapter: the public seat Commonly MCP witness (TASK-256)', () => { + const COMMONLY_MCP = [{ name: 'commonly', transport: 'stdio', command: ['npx', '-y', '@commonlyai/mcp@latest'] }]; + const stream = (...events) => events.map((event) => JSON.stringify(event)).join('\n'); + const init = (status) => ({ type: 'system', subtype: 'init', mcp_servers: [{ name: 'commonly', status }] }); + const call = (id, name) => ({ type: 'assistant', message: { content: [{ type: 'tool_use', id, name, input: {} }] } }); + const failed = (id, content) => ({ + type: 'user', message: { content: [{ type: 'tool_result', tool_use_id: id, is_error: true, content }] }, + }); + const succeeded = (id, content = '[]') => ({ + type: 'user', message: { content: [{ type: 'tool_result', tool_use_id: id, content }] }, + }); + const result = (text) => ({ type: 'result', subtype: 'success', is_error: false, result: text }); + const API_503 = JSON.stringify({ status: 503, body: '503 Service Unavailable', message: 'HTTP 503' }); + const API_404 = JSON.stringify({ status: 404, body: { error: 'Pod not found' }, message: 'Pod not found' }); + + let cwd; + let home; + let publicState; + let prior; + const originalPlatform = process.platform; + beforeEach(() => { + cwd = fs.mkdtempSync(path.join(os.tmpdir(), 'cli-claude-witness-')); + publicState = fs.mkdtempSync(path.join(os.tmpdir(), 'cli-claude-public-state-')); + home = fs.mkdtempSync(path.join(os.tmpdir(), 'cli-claude-mcp-home-')); + const pkgDir = path.join(home, '0.3.13', 'node_modules', '@commonlyai', 'mcp'); + fs.mkdirSync(path.join(pkgDir, 'src'), { recursive: true }); + fs.writeFileSync(path.join(pkgDir, 'package.json'), JSON.stringify({ + name: '@commonlyai/mcp', version: '0.3.13', bin: { 'commonly-mcp': 'src/index.js' }, + })); + fs.writeFileSync(path.join(pkgDir, 'src', 'index.js'), ''); + fs.writeFileSync(path.join(home, 'current'), '0.3.13\n'); + // A fresh registry answer naming the installed build, so no turn here starts + // a background warm: a detached `npm install` outlives the suite and writes + // into TMPDIR after it ends (the CLI temp-cleanup guard caught it). + fs.writeFileSync(path.join(home, '.registry.json'), JSON.stringify({ version: '0.3.13', checkedAt: Date.now() })); + prior = process.env.COMMONLY_MCP_HOME; + process.env.COMMONLY_MCP_HOME = home; + Object.defineProperty(process, 'platform', { value: 'darwin' }); + spawnSync.mockImplementation((cmd) => (cmd === 'which' + ? { status: 0, stdout: '/usr/bin/true\n' } : { status: 0, stdout: '' })); + }); + afterEach(() => { + if (prior === undefined) delete process.env.COMMONLY_MCP_HOME; + else process.env.COMMONLY_MCP_HOME = prior; + Object.defineProperty(process, 'platform', { value: originalPlatform }); + spawnSync.mockReset(); + for (const dir of [cwd, publicState, home]) fs.rmSync(dir, { recursive: true, force: true }); + }); + + const turn = (stdout, { trust = 'public', mcp = COMMONLY_MCP, impl = makeSpawnImpl({ stdout }).impl, sessionId } = {}) => ( + claude.spawn('hi', { + agentName: 'public-test', cwd, ...(sessionId ? { sessionId } : {}), + env: { PATH: process.env.PATH, USER: 'safe-user', LOGNAME: 'safe-user' }, + environment: { model: 'test-model', ...(trust ? { sandbox: { mode: 'workspace', trust } } : {}), mcp }, + _publicClaudeState: publicState, + _spawnImpl: impl, + }) + ); + + test('posts when init shows Commonly connected and no Commonly call failed', async () => { + await expect(turn(stream(init('connected'), result('Here you go.')))).resolves.toMatchObject({ text: 'Here you go.' }); + }); + + test.each([ + ['the server failed to start', stream(init('failed'), result('HELLO')), /status failed/], + ['the stream carries no init event', stream(result('HELLO')), /no init event/], + ['the output is plain text', 'HELLO', /no init event/], + ])('withholds the turn when %s', async (_label, stdout, reason) => { + const outcome = turn(stdout); + await expect(outcome).rejects.toMatchObject({ code: 'public_mcp_not_ready' }); + await expect(outcome).rejects.toThrow(reason); + }); + + test.each([ + ['an API 503', API_503], + ['a transport error with no status', 'MCP error -32000: Connection closed'], + ['an empty error', ''], + ['an error as text parts', [{ type: 'text', text: API_503 }]], + ])('withholds the turn when a Commonly call fails with %s', async (_label, content) => { + const stdout = stream(init('connected'), call('t1', 'mcp__commonly__list_pods'), failed('t1', content), result('Every call is failing.')); + await expect(turn(stdout)).rejects.toMatchObject({ code: 'public_tool_failed' }); + }); + + // `pending` at init is not a failure: the server may finish starting during + // the turn. Only a Commonly call that returned shows it working, so that is + // the evidence a pending server needs. + test('posts when init shows Commonly pending and a Commonly call then returned', async () => { + const stdout = stream(init('pending'), call('t1', 'mcp__commonly__list_pods'), succeeded('t1'), result('Two pods.')); + await expect(turn(stdout)).resolves.toMatchObject({ text: 'Two pods.' }); + }); + + test.each([ + ['no Commonly call was made', stream(init('pending'), result('HELLO'))], + ['only another server\'s call returned', stream(init('pending'), call('t1', 'mcp__other__ping'), succeeded('t1'), result('HELLO'))], + ])('withholds the turn when init shows Commonly pending and %s', async (_label, stdout) => { + const outcome = turn(stdout); + await expect(outcome).rejects.toMatchObject({ code: 'public_mcp_not_ready' }); + await expect(outcome).rejects.toThrow(/status pending/); + }); + + test('withholds a pending server whose Commonly call failed, even beside one that returned', async () => { + const stdout = stream( + init('pending'), + call('t1', 'mcp__commonly__list_pods'), succeeded('t1'), + call('t2', 'mcp__commonly__read_context'), failed('t2', API_503), + result('Every call is failing.'), + ); + await expect(turn(stdout)).rejects.toMatchObject({ code: 'public_tool_failed' }); + }); + + test('posts an answer to a refusal the person can be told about (a 404)', async () => { + const stdout = stream(init('connected'), call('t1', 'mcp__commonly__read_context'), failed('t1', API_404), result('I cannot see that pod.')); + await expect(turn(stdout)).resolves.toMatchObject({ text: 'I cannot see that pod.' }); + }); + + test('withholds a failed call from a server whose name Claude Code normalises in tool names', async () => { + const mcp = [{ ...COMMONLY_MCP[0], name: 'commonly.me' }]; + const stdout = stream( + { type: 'system', subtype: 'init', mcp_servers: [{ name: 'commonly.me', status: 'connected' }] }, + call('t1', 'mcp__commonly_me__list_pods'), failed('t1', API_503), result('Every call is failing.'), + ); + await expect(turn(stdout, { mcp })).rejects.toMatchObject({ code: 'public_tool_failed' }); + }); + + test('the fresh-session retry is witnessed too', async () => { + // The resumed session is refused, the retry runs fresh, and its server failed. + let spawns = 0; + const impl = () => { + spawns += 1; + const proc = new EventEmitter(); + proc.stdout = new EventEmitter(); + proc.stderr = new EventEmitter(); + proc.kill = jest.fn(); + const first = spawns === 1; + setTimeout(() => { + if (first) proc.stderr.emit('data', Buffer.from('Error: Session ID abc-123 is already in use.')); + else proc.stdout.emit('data', Buffer.from(stream(init('failed'), result('HELLO')))); + proc.emit('close', first ? 1 : 0); + }, 0); + return proc; + }; + await expect(turn('', { impl, sessionId: 'abc-123' })).rejects.toMatchObject({ code: 'public_mcp_not_ready' }); + expect(spawns).toBe(2); + }); + + test("ignores another server's failed call", async () => { + const stdout = stream(init('connected'), call('t1', 'mcp__other__lookup'), failed('t1', API_503), result('Done.')); + await expect(turn(stdout)).resolves.toMatchObject({ text: 'Done.' }); + }); + + // As in the OpenCode check: only the shipped server's error contract is known, + // so a server run some other way is not judged by it. + test('a seat that does not run the shipped Commonly MCP is not witnessed', async () => { + // A hand-run copy, as the staging seats run: not the published package. + const mcp = [{ name: 'commonly', transport: 'stdio', command: ['node', '/opt/commonly-mcp-staging/src/index.js'] }]; + await expect(turn(stream(init('failed'), result('HELLO')), { mcp })).resolves.toMatchObject({ text: 'HELLO' }); + }); + + // A record still carrying `internal` is read as public (environment.js), so it is witnessed. + test('a legacy internal-trust seat is witnessed as public', async () => { + await expect(turn(stream(init('failed'), result('HELLO')), { trust: 'internal' })) + .rejects.toMatchObject({ code: 'public_mcp_not_ready' }); + }); + + test.each([['a workspace seat', 'workspace'], ['an unconfined seat', null]])('%s is not witnessed', async (_label, trust) => { + await expect(turn(stream(init('failed'), result('HELLO')), { trust })).resolves.toMatchObject({ text: 'HELLO' }); + }); +}); diff --git a/cli/__tests__/spawn-retry.test.mjs b/cli/__tests__/spawn-retry.test.mjs index fe639c874a..0099d5419f 100644 --- a/cli/__tests__/spawn-retry.test.mjs +++ b/cli/__tests__/spawn-retry.test.mjs @@ -322,3 +322,37 @@ describe('classifies the possessive-exhaustion phrase (2026-08-25, third miss)', }); }); }); + +// TASK-256: a public seat's withheld turn carries the tool's own error text in +// its message, so a vendor that says "content_policy_violation", "quota" or +// "403 forbidden" must not decide the class. The code does: the event stays +// unacked and is retried, never acked as refused. +describe('a public witness failure is a runtime failure, whatever its text says', () => { + test.each([ + ['public_tool_failed', '{"status":500,"message":"content_policy_violation"}'], + ['public_tool_failed', 'this content was flagged'], + ['public_tool_failed', 'insufficient_quota'], + ['public_tool_failed', '403 forbidden'], + ['public_mcp_not_ready', 'connection reset'], + ])('%s with "%s" classes as runtime', (code, reason) => { + const error = new Error(`public seat withheld this turn's reply: Commonly tool x failed (${reason})`); + error.code = code; + expect(classifySpawnFailure(error)).toBe(SPAWN_FAILURE_CLASS.RUNTIME); + }); + + // A throttle is still a throttle: it keeps the event unacked like runtime, and + // the longer backoff is the point, since a quick retry meets the same 429. + test.each([ + ['public_tool_failed', '{"status":429,"message":"Too Many Requests"}'], + ['public_mcp_not_ready', 'rate limit while connecting'], + ])('%s with "%s" keeps the rate-limit backoff', (code, reason) => { + const error = new Error(`public seat withheld this turn's reply: Commonly tool x failed (${reason})`); + error.code = code; + expect(classifySpawnFailure(error)).toBe(SPAWN_FAILURE_CLASS.RATE_LIMIT); + }); + + test('positive control: the same text without the code still classes as a refusal', () => { + expect(classifySpawnFailure(new Error('{"status":500,"message":"content_policy_violation"}'))) + .toBe(SPAWN_FAILURE_CLASS.CONTENT_REFUSED); + }); +}); diff --git a/cli/src/lib/adapters/claude.js b/cli/src/lib/adapters/claude.js index e426c14a10..48f3b537df 100644 --- a/cli/src/lib/adapters/claude.js +++ b/cli/src/lib/adapters/claude.js @@ -97,6 +97,8 @@ import { claudeConfigDirFor, claudeContextTokens, claudeTranscriptPath } from '. import { skillsAsText } from '../skill-prompt.js'; import { adapterFailure, spawnCredentials } from '../upstream-refusal.js'; import { PUBLIC_WITHHELD_TOOLS } from '../public-withheld-tools.js'; +import { isShippedCommonlyMcpCommand } from '../declared-mcp-guard.js'; +import { isAnswerableToolError, PUBLIC_MCP_NOT_READY_CODE, PUBLIC_TOOL_FAILED_CODE } from '../public-tool-error.js'; // See codex.js for the rationale on bumping the default + env override. // Keeping both adapters in lockstep so any wrapper agent runtime has the @@ -533,6 +535,87 @@ export const claudeResultText = (stdout, { credentials = [] } = {}) => { .trim(); }; +// TASK-256: what a public seat's own stream says about its Commonly MCP server. +// Claude Code reports each server's state in the init event (`connected` or +// `failed`), and a failed tool call as a `tool_result` with `is_error: true` +// whose content is the server's text, the shipped MCP's `{status, body, +// message}` JSON (both measured on Claude Code 2.1.314; `-p` waits for a slow +// server, so init reports it connected rather than pending). Ready is a positive +// witness: `connected` in init, or `pending` followed by a Commonly call that +// returned, since a server may finish starting during the turn. A stream with no +// init event, or with a Commonly server in any other state, never showed the +// server working, so the turn is withheld. A failed +// Commonly call withholds it unless `isAnswerableToolError` names an answer the +// person can be given (the predicate the OpenCode adapter uses). Only servers +// that run the shipped Commonly MCP count. Returns the failure, or null. +const toolResultText = (content) => { + if (typeof content === 'string') return content; + if (!Array.isArray(content)) return ''; + return content.filter((part) => part?.type === 'text').map((part) => String(part.text ?? '')).join(''); +}; + +export const claudePublicMcpWitness = (stdout, mcpServers) => { + const names = (Array.isArray(mcpServers) ? mcpServers : []) + .filter((server) => server?.name && isShippedCommonlyMcpCommand(server.command)) + .map((server) => String(server.name)); + if (!names.length) return null; + const events = String(stdout || '').split('\n').map(parseEvent).filter(Boolean); + const init = events.find((event) => event.type === 'system' && event.subtype === 'init'); + const servers = Array.isArray(init?.mcp_servers) ? init.mcp_servers : []; + const toolNames = new Map(); + for (const event of events) { + if (event.type !== 'assistant') continue; + for (const block of event.message?.content || []) { + if (block?.type === 'tool_use' && block.id) toolNames.set(block.id, String(block.name || '')); + } + } + const results = events + .filter((event) => event.type === 'user' && Array.isArray(event.message?.content)) + .flatMap((event) => event.message.content) + .filter((block) => block?.type === 'tool_result') + .map((block) => ({ block, tool: toolNames.get(block.tool_use_id) || '' })); + // Init names a server as declared; tool names carry it with every character + // outside [A-Za-z0-9_-] turned to `_` (`commonly.me` → `mcp__commonly_me__`, + // measured on 2.1.314). + const prefixOf = (name) => `mcp__${name.replace(/[^A-Za-z0-9_-]/g, '_')}__`; + for (const name of names) { + const status = servers.find((server) => server?.name === name)?.status; + const returned = status === 'pending' + && results.some(({ block, tool }) => block.is_error !== true && tool.startsWith(prefixOf(name))); + if (status !== 'connected' && !returned) { + return { code: PUBLIC_MCP_NOT_READY_CODE, tool: name, error: init ? `status ${status || 'absent'}` : 'no init event' }; + } + } + const prefixes = names.map(prefixOf); + for (const { block, tool } of results) { + if (block.is_error !== true || !prefixes.some((prefix) => tool.startsWith(prefix))) continue; + const error = toolResultText(block.content); + if (!isAnswerableToolError(error)) return { code: PUBLIC_TOOL_FAILED_CODE, tool, error }; + } + return null; +}; + +const publicWitnessError = ({ code, tool, error }) => { + const reason = String(error).replace(/\s+/g, ' ').slice(0, 160) || '(empty error)'; + const what = code === PUBLIC_MCP_NOT_READY_CODE + ? `Commonly MCP server ${tool} was not shown connected` + : `Commonly tool ${tool} failed in a way the model must not narrate`; + const failure = new Error( + `public seat withheld this turn's reply: ${what} (${reason}), so nothing was posted and the event stays unacked for retry`, + ); + failure.code = code; + return failure; +}; + +// The reply a turn posts. A public seat's turn is witnessed first (TASK-256). +const turnReply = (stdout, { credentials, publicMcp }) => { + if (publicMcp) { + const failure = claudePublicMcpWitness(stdout, publicMcp); + if (failure) throw publicWitnessError(failure); + } + return claudeResultText(stdout, { credentials }); +}; + // The failure tail for a non-zero exit. Stream events are mostly system init // and status lines, which would push the terminal error past the 2000-char cut, // so keep the result text, or the lines that are not system or stream events. @@ -1191,6 +1274,10 @@ export default { // undefined and '' as absence itself; it does not need a guard, it needs // the value. const publicTrust = isConfinedTrust(ctx.environment?.sandbox?.trust); + // Only a stranger-facing seat is witnessed (TASK-256). A workspace seat is + // confined too, but it answers its own team, and an outage it narrates + // there is a signal, not a leak. + const witnessedMcp = ctx.environment?.sandbox?.trust === 'public' ? ctx.environment?.mcp : null; const workCues = publicTrust ? [] : WORK_CUES; const fullPrompt = buildPrompt(prompt, ctx.memoryLongTerm, { freshSession: !isResume, @@ -1330,12 +1417,16 @@ export default { const contextTokens = claudeContextTokens({ cwd: physicalPath(ctx.cwd), sessionId, configDir: claudeConfigDirFor({ ...env, HOME: childHome || env?.HOME }), }); - return { text: claudeResultText(stdout, { credentials: spawnCredentials(spawnCtx) }), newSessionId: sessionId, contextTokens }; + return { + text: turnReply(stdout, { credentials: spawnCredentials(spawnCtx), publicMcp: witnessedMcp }), + newSessionId: sessionId, + contextTokens, + }; } catch (err) { // A public seat's refusal is not a session-store failure, whatever its // message happens to contain: it must reach the run loop, never the // fresh-session retry below (#2247). - if (err?.code === PUBLIC_MCP_UNAVAILABLE_CODE) throw err; + if ([PUBLIC_MCP_UNAVAILABLE_CODE, PUBLIC_MCP_NOT_READY_CODE, PUBLIC_TOOL_FAILED_CODE].includes(err?.code)) throw err; // Self-heal against a corrupted session store. If the persisted id is // already in use (or claude has forgotten about it), discard it and // restart the turn with a fresh UUID. Without this, a single bad @@ -1393,7 +1484,11 @@ export default { cwd: physicalPath(ctx.cwd), sessionId: freshId, configDir: claudeConfigDirFor({ ...retry.env, HOME: retry.childHome || retry.env?.HOME }), }); - return { text: claudeResultText(stdout, { credentials: spawnCredentials(retryCtx) }), newSessionId: freshId, contextTokens }; + return { + text: turnReply(stdout, { credentials: spawnCredentials(retryCtx), publicMcp: witnessedMcp }), + newSessionId: freshId, + contextTokens, + }; } throw err; } finally { diff --git a/cli/src/lib/adapters/opencode.js b/cli/src/lib/adapters/opencode.js index 62f77c5d15..7c0927b0da 100644 --- a/cli/src/lib/adapters/opencode.js +++ b/cli/src/lib/adapters/opencode.js @@ -56,7 +56,7 @@ import { wrapArgvWithSeatbelt } from '../sandbox/seatbelt.js'; import { resolvePublicSandboxMode } from '../sandbox/mode.js'; import { resolveProviderKeyFile, startProviderProxy } from '../provider-proxy.js'; import { isShippedCommonlyMcpCommand } from '../declared-mcp-guard.js'; -import { isAnswerableToolError } from '../public-tool-error.js'; +import { isAnswerableToolError, PUBLIC_TOOL_FAILED_CODE } from '../public-tool-error.js'; import { startInstanceProxy } from '../instance-proxy.js'; import { PUBLIC_WITHHELD_TOOLS } from '../public-withheld-tools.js'; @@ -390,7 +390,7 @@ const makeEventParser = () => { // stays beside the adapter-agnostic readiness witness (TASK-256): it sees the // runtime's own view of each call, and the witness sees a server that never // started, so each catches what the other cannot. -export const PUBLIC_TOOL_FAILED_CODE = 'public_tool_failed'; +export { PUBLIC_TOOL_FAILED_CODE }; export const publicToolFailure = (toolErrors, mcpServers) => { const prefixes = (Array.isArray(mcpServers) ? mcpServers : []) diff --git a/cli/src/lib/public-tool-error.js b/cli/src/lib/public-tool-error.js index 0fef063d0a..3b0b51b898 100644 --- a/cli/src/lib/public-tool-error.js +++ b/cli/src/lib/public-tool-error.js @@ -33,6 +33,12 @@ export const ANSWERABLE_API_STATUSES = new Set([400, 403, 404, 409, 410, 413, 422]); +// The two ways a public turn is withheld over its Commonly MCP server: a tool +// call failed in a way the model must not narrate, or the server was never shown +// working. Both leave the event unacked for retry. +export const PUBLIC_TOOL_FAILED_CODE = 'public_tool_failed'; +export const PUBLIC_MCP_NOT_READY_CODE = 'public_mcp_not_ready'; + const INVALID_ARGUMENTS_RE = /^The \S+ tool was called with invalid arguments: /; const parsePayload = (text) => { diff --git a/cli/src/lib/spawn-retry.js b/cli/src/lib/spawn-retry.js index 527850e395..4e03a5dd4b 100644 --- a/cli/src/lib/spawn-retry.js +++ b/cli/src/lib/spawn-retry.js @@ -7,6 +7,8 @@ * retry policy here so every adapter gets the same bounded behavior. */ +import { PUBLIC_MCP_NOT_READY_CODE, PUBLIC_TOOL_FAILED_CODE } from './public-tool-error.js'; + export const SPAWN_FAILURE_CLASS = Object.freeze({ // The provider refused the request's CONTENT. Re-sending the same content // can only be refused again, so this class is never retried: the run loop @@ -121,6 +123,15 @@ const errorText = (error) => [ export const classifySpawnFailure = (error) => { const text = errorText(error); + // A public seat's withheld turn (TASK-256) quotes the tool's own error, which + // is vendor text: the code decides, so the event stays unacked for retry and + // is never acked as refused or parked behind a quota or configuration + // cooldown by that text. A throttle keeps its backoff, which also retries. + if (error?.code === PUBLIC_TOOL_FAILED_CODE || error?.code === PUBLIC_MCP_NOT_READY_CODE) { + return error?.status === 429 || RATE_LIMIT_RE.test(text) + ? SPAWN_FAILURE_CLASS.RATE_LIMIT + : SPAWN_FAILURE_CLASS.RUNTIME; + } // First: the refusal text carries none of the words below today, but a // refusal must never be read as a transient condition and retried. if (CONTENT_REFUSED_RE.test(text)) return SPAWN_FAILURE_CLASS.CONTENT_REFUSED;