diff --git a/packages/cli/src/serve/routes/session-turn-failure.test.ts b/packages/cli/src/serve/routes/session-turn-failure.test.ts new file mode 100644 index 00000000000..73441cdefee --- /dev/null +++ b/packages/cli/src/serve/routes/session-turn-failure.test.ts @@ -0,0 +1,94 @@ +/** + * @license + * Copyright 2026 Qwen Team + * SPDX-License-Identifier: Apache-2.0 + */ + +import { describe, expect, it } from 'vitest'; +import { describePromptTurnFailure } from './session.js'; + +describe('describePromptTurnFailure', () => { + it('keeps Error name and message', () => { + const err = new Error('agent channel closed mid-request'); + err.name = 'BridgeChannelClosedError'; + expect(describePromptTurnFailure(err)).toBe( + '[BridgeChannelClosedError] agent channel closed mid-request', + ); + }); + + it('extracts data details from Error rejections carrying JSON-RPC data', () => { + const err = Object.assign(new Error('Internal error'), { + name: 'RequestError', + data: { details: 'session not found' }, + }); + expect(describePromptTurnFailure(err)).toBe( + '[RequestError] session not found', + ); + }); + + it('keeps the Error name prefix over a code prefix', () => { + const err = Object.assign(new Error('write EPIPE'), { code: 'EPIPE' }); + expect(describePromptTurnFailure(err)).toBe('[Error] write EPIPE'); + }); + + it('extracts message and code from a bare JSON-RPC error object', () => { + expect( + describePromptTurnFailure({ code: -32603, message: 'Internal error' }), + ).toBe('[code -32603] Internal error'); + }); + + it('prefers JSON-RPC data details over the generic message', () => { + expect( + describePromptTurnFailure({ + code: -32603, + message: 'Internal error', + data: { details: 'model provider rejected the request' }, + }), + ).toBe('[code -32603] model provider rejected the request'); + }); + + it('prefers nested provider error text shipped as parsed data', () => { + expect( + describePromptTurnFailure({ + code: -32603, + message: 'Internal error', + data: { error: { message: 'upstream 429 rate limited' } }, + }), + ).toBe('[code -32603] upstream 429 rate limited'); + }); + + it('reads a plain object message property without a code', () => { + expect(describePromptTurnFailure({ message: 'something broke' })).toBe( + 'something broke', + ); + }); + + it('never degrades structured rejections to [object Object]', () => { + const candidates: unknown[] = [ + { code: -32000, message: 'boom' }, + { code: 'EPIPE', message: 'write failed' }, + { data: 'provider closed the stream' }, + { message: 'partial' }, + { code: -32603 }, + {}, + { message: '' }, + ]; + for (const candidate of candidates) { + const rendered = describePromptTurnFailure(candidate); + expect(rendered).not.toBe(''); + expect(rendered).not.toContain('[object Object]'); + } + }); + + it('renders circular rejections without degrading', () => { + const err: Record = { code: -32603 }; + err['self'] = err; + const rendered = describePromptTurnFailure(err); + expect(rendered).toContain('[code -32603]'); + expect(rendered).not.toContain('[object Object]'); + }); + + it('stringifies primitive rejections', () => { + expect(describePromptTurnFailure('socket hang up')).toBe('socket hang up'); + }); +}); diff --git a/packages/cli/src/serve/routes/session.ts b/packages/cli/src/serve/routes/session.ts index e36dc8d956b..cfff0e5c0b5 100644 --- a/packages/cli/src/serve/routes/session.ts +++ b/packages/cli/src/serve/routes/session.ts @@ -7,6 +7,7 @@ import * as crypto from 'node:crypto'; import * as fs from 'node:fs'; import * as path from 'node:path'; +import { inspect } from 'node:util'; import { APPROVAL_MODES, BTW_MAX_INPUT_LENGTH, @@ -46,7 +47,11 @@ import { type BridgeBranchedSession, } from '@qwen-code/acp-bridge/bridgeTypes'; import type { BridgeEvent } from '@qwen-code/acp-bridge/eventBus'; -import { parseSessionSource } from '@qwen-code/acp-bridge'; +import { + extractErrorCode, + extractErrorMessage, + parseSessionSource, +} from '@qwen-code/acp-bridge'; import { isReservedLiveSessionSource, isReservedStandaloneSessionSource, @@ -450,6 +455,31 @@ function sendSessionOrganizationError(res: Response, err: unknown): boolean { return true; } +/** + * Renders a prompt-turn rejection for the daemon log. Non-Error rejections + * (bare JSON-RPC error objects forwarded by the bridge) must not degrade to + * `[object Object]` — that hid the failure cause in production incidents + * (#10710), so extract the structured message/code instead. + */ +export function describePromptTurnFailure(err: unknown): string { + const detail = extractErrorMessage(err); + if (err instanceof Error) { + return `[${err.name}] ${detail}`; + } + const code = extractErrorCode(err); + // `extractErrorMessage` terminates in `String(err)`, which renders a plain + // object as `[object Object]`. Fall back to `inspect` for those: unlike + // `JSON.stringify` it never throws, so circular and non-serializable + // rejections stay readable instead of degrading again. + const rendered = + typeof err === 'object' && + err !== null && + (detail === '' || detail === String(err)) + ? inspect(err, { depth: 2, breakLength: Infinity }) + : detail; + return code === undefined ? rendered : `[code ${code}] ${rendered}`; +} + function parseTranscriptLimitQuery( rawLimit: unknown, res: Response, @@ -5397,9 +5427,8 @@ export function registerSessionRoutes( }, (err) => { if (daemonLog) { - const errName = err instanceof Error ? err.name : undefined; daemonLog.warn( - `prompt turn failed: ${errName ? `[${errName}] ` : ''}${err instanceof Error ? err.message : String(err)}`, + `prompt turn failed: ${describePromptTurnFailure(err)}`, { sessionId, promptId, clientId }, ); }