Classify IM turn failures instead of falling back to INTERNAL_UNKNOWN.

Map PI_AI_ERROR/UNKNOWN/TLS wording and Host RPC internals so WhatsApp shows an actionable code, and treat missing sessions as recreateable.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
oliver 2026-09-15 16:38:46 +08:00
parent 45f430fb28
commit 2723e94541
5 changed files with 397 additions and 220 deletions

View file

@ -11320,7 +11320,8 @@ var ACCESS_GRANT_COPY = Object.freeze({
approvedGroup: "\u672C\u7FA4\u8BBF\u95EE\u5DF2\u6279\u51C6\uFF0C\u8BF7\u5728\u672C\u7FA4 @ \u673A\u5668\u4EBA\u7EE7\u7EED\u3002",
denied: "\u8BBF\u95EE\u7533\u8BF7\u672A\u901A\u8FC7\u3002",
adminApproved: "\u5DF2\u6279\u51C6\u8BE5\u8BBF\u95EE\u7533\u8BF7\u3002",
adminDenied: "\u5DF2\u62D2\u7EDD\u8BE5\u8BBF\u95EE\u7533\u8BF7\u3002"
adminDenied: "\u5DF2\u62D2\u7EDD\u8BE5\u8BBF\u95EE\u7533\u8BF7\u3002",
processFailed: "\u65E0\u6CD5\u5904\u7406\u8BE5\u5BA1\u6279\u3002"
});
// src/channels/shared/session-scope.mjs
@ -15670,6 +15671,15 @@ function UpdatePanel({ rpcCall, clientVersion, onStatus }) {
var name = "im-settings";
var inject = ["slots", "connection", "locale", "workspaces"];
var IM_PLUGIN_VERSION = package_default.version;
function syncHostChatLanguage(ctx, language) {
if (typeof language !== "string" || !language.trim()) return;
void ctx.connection.rpc.call(
UPDATE_RPC_CHANNEL,
"host.language.set",
{ language }
).catch(() => {
});
}
function callWorkspaceDirectoryApi(ctx, method, ...args) {
const uiWorkspace = typeof ctx.get === "function" ? ctx.get("uiWorkspace") : void 0;
const service = typeof uiWorkspace?.[method] === "function" ? uiWorkspace : ctx.workspaces;
@ -15965,6 +15975,12 @@ function apply(ctx) {
);
const t = ctx.locale.bind(IM_LOCALE_NAMESPACE);
setImTranslator(t);
ctx.effect(() => {
syncHostChatLanguage(ctx, ctx.locale.getLocale().active);
return ctx.on("locale/change", (snapshot) => {
syncHostChatLanguage(ctx, snapshot?.active);
});
}, "im-settings: sync host chat language with DSH locale");
ctx.effect(() => {
const disposers = [
installFeishuStyles(),

File diff suppressed because one or more lines are too long

View file

@ -964,7 +964,14 @@ export class HarnessClient {
await this.rpc('session.history', { sessionId, maxMessages: 1 }, 30_000, options);
return true;
} catch (error) {
if (error instanceof HarnessRpcError && error.code === 'session-not-found') return false;
// Missing Session must recreate a binding; accept both legacy and branded codes.
if (error instanceof HarnessRpcError && (
error.code === 'session-not-found'
|| error.code === 'SESSION_NOT_FOUND'
|| error.code === 'not-found'
)) {
return false;
}
throw error;
}
}

View file

@ -1,4 +1,7 @@
import { randomUUID } from 'node:crypto';
import { mkdir, writeFile } from 'node:fs/promises';
import { homedir } from 'node:os';
import { join } from 'node:path';
import { t } from './i18n.mjs';
@ -13,9 +16,13 @@ const PROVIDER_FAILURES = Object.freeze({
NO_ADAPTER: 'MODEL_UNAVAILABLE',
UNSUPPORTED_OPTION: 'MODEL_CONFIG',
UNSUPPORTED_REASONING_EFFORT: 'MODEL_CONFIG',
INVALID_REQUEST: 'MODEL_CONFIG',
TIMEOUT: 'MODEL_TIMEOUT',
TRANSPORT: 'MODEL_TRANSPORT',
SERVER: 'MODEL_SERVICE',
// DSH catch-alls when the provider did not classify further.
PI_AI_ERROR: 'MODEL_SERVICE',
UNKNOWN: 'MODEL_SERVICE',
STREAM_CLOSED: 'MODEL_STREAM',
MALFORMED_RESPONSE: 'MODEL_STREAM',
EMPTY_RESPONSE: 'MODEL_EMPTY_REPLY',
@ -23,6 +30,55 @@ const PROVIDER_FAILURES = Object.freeze({
UNSUPPORTED_CONTENT: 'MODEL_CONFIG',
});
const MISSING_SESSION_CODES = new Set([
'session-not-found',
'SESSION_NOT_FOUND',
'not-found',
]);
/** Collect short diagnostic text from turn reason / message / cause chain. */
function diagnosticMessages(error) {
const out = [];
const push = (value) => {
if (typeof value === 'string' && value.trim()) out.push(value.trim());
};
const reason = error?.reason;
if (reason && typeof reason === 'object') {
const failure = reason.error ?? reason.failure;
push(failure?.message);
push(typeof failure === 'string' ? failure : null);
}
push(error?.message);
let cause = error?.cause;
const seen = new Set();
while (cause && typeof cause === 'object' && !seen.has(cause) && out.length < 6) {
seen.add(cause);
push(cause.message);
push(cause.code);
cause = cause.cause;
}
return out;
}
function providerErrorMessage(error) {
return diagnosticMessages(error).join('\n');
}
/**
* Transport / TLS wording that DSH may still emit as PI_AI_ERROR.
* Keep in sync with common Node/undici/pi-ai surfaces; do not require DSH changes.
*/
function isTransportProviderMessage(message) {
if (!message) return false;
return /\b(?:network|connection|socket|fetch)\b|\bECONN[A-Z]+\b/i.test(message)
|| /\b(?:other side closed|HTTP2 request did not get a response|WebSocket closed unexpectedly)\b/i.test(message)
|| /\bterminated\b|premature close/i.test(message)
|| /stream ended (?:before|without)\b/i.test(message)
|| /\bunable to verify the first certificate\b/i.test(message)
|| /\bUNABLE_TO_VERIFY_LEAF_SIGNATURE\b|\bUNABLE_TO_GET_ISSUER_CERT(?:_LOCALLY)?\b|\bCERT_HAS_EXPIRED\b|\bDEPTH_ZERO_SELF_SIGNED_CERT\b|\bERR_TLS_CERT_ALTNAME_INVALID\b/.test(message)
|| /\bself[- ]signed certificate\b/i.test(message);
}
const FAILURE_MESSAGES = Object.freeze({
HARNESS_CONNECT:
'无法连接处理服务,消息尚未提交。请确认 DeepSeek Harness 正在运行后重试。',
@ -94,9 +150,13 @@ const FAILURE_MESSAGES = Object.freeze({
function providerFailureCode(error) {
if (error?.code !== 'harness-turn-failed') return null;
const value = error?.providerCode ?? error?.details?.providerCode;
if (typeof value !== 'string') return null;
return PROVIDER_FAILURES[value.trim().toUpperCase()] ?? null;
const raw = error?.providerCode ?? error?.details?.providerCode;
const key = typeof raw === 'string' ? raw.trim().toUpperCase() : '';
const mapped = key ? (PROVIDER_FAILURES[key] ?? null) : null;
// Specific provider codes win; PI_AI_ERROR/UNKNOWN are catch-alls and may still be TLS/transport.
if (mapped && key !== 'PI_AI_ERROR' && key !== 'UNKNOWN') return mapped;
if (isTransportProviderMessage(providerErrorMessage(error))) return 'MODEL_TRANSPORT';
return mapped;
}
function failureCode(error) {
@ -104,6 +164,9 @@ function failureCode(error) {
const providerCode = providerFailureCode(error);
if (providerCode) return providerCode;
// Transport wording can appear on non-turn errors (RPC/cause chain) too.
if (isTransportProviderMessage(providerErrorMessage(error))) return 'MODEL_TRANSPORT';
if (code === 'harness-connect-failed') {
return ['session.prompt', 'session.history'].includes(error?.method)
? 'HARNESS_RESULT_UNCERTAIN'
@ -124,12 +187,15 @@ function failureCode(error) {
return 'HARNESS_PROTOCOL';
}
if (code === 'harness-turn-failed') return 'INTERNAL_UNKNOWN';
if (['harness-http-failed', 'harness-rpc-rejected'].includes(code)) return 'HARNESS_SERVICE';
// Host RPC catch-all (common when session/preset/workspace ops fail without a typed code).
if (code === 'internal' || ['harness-http-failed', 'harness-rpc-rejected'].includes(code)) {
return 'HARNESS_SERVICE';
}
if (code === 'model-empty-response') return 'MODEL_EMPTY_REPLY';
if (code === 'model-max-tokens') return 'MODEL_OUTPUT_LIMIT';
if (code === 'turn-blocked') return 'TURN_BLOCKED';
if (['turn-interrupted', 'turn-aborted'].includes(code)) return 'TURN_INTERRUPTED';
if (code === 'session-not-found') return 'SESSION_NOT_FOUND';
if (MISSING_SESSION_CODES.has(code)) return 'SESSION_NOT_FOUND';
if (code === 'agent-busy') return 'SESSION_BUSY';
if (code === 'workspace-session-stale') return 'SESSION_STALE';
if (code.startsWith('workspace-')) return 'WORKSPACE_UNAVAILABLE';
@ -163,6 +229,37 @@ function failureCode(error) {
return 'INTERNAL_UNKNOWN';
}
function inferredFailureReason(error) {
return safeFailureReason(error?.providerCode)
?? safeFailureReason(error?.code)
?? safeFailureReason(error?.name);
}
async function persistInternalUnknownDiagnostic(failure, error) {
try {
const dir = join(homedir(), '.dsh', 'integrations', 'dsh-im-ops');
await mkdir(dir, { recursive: true });
const payload = {
at: new Date(failure.at).toISOString(),
referenceId: failure.referenceId,
failureCode: failure.code,
failureReason: failure.reason,
errorName: typeof error?.name === 'string' ? error.name.slice(0, 80) : null,
errorCode: typeof error?.code === 'string' ? error.code.slice(0, 80) : null,
providerCode: typeof error?.providerCode === 'string' ? error.providerCode.slice(0, 80) : null,
method: typeof error?.method === 'string' ? error.method.slice(0, 80) : null,
messages: diagnosticMessages(error).map((text) => text.slice(0, 240)),
};
await writeFile(
join(dir, 'last-internal-unknown.json'),
`${JSON.stringify(payload, null, 2)}\n`,
{ encoding: 'utf8', mode: 0o600 },
);
} catch {
// Diagnostics must never mask the user-facing failure path.
}
}
function safeReferenceId(value) {
return typeof value === 'string' && /^[A-Z0-9-]{6,40}$/u.test(value)
? value
@ -180,17 +277,20 @@ export function classifyMessageFailure(error, {
referenceId,
at = Date.now(),
} = {}) {
const safeReason = safeFailureReason(reason);
const explicitReason = safeFailureReason(reason);
const classifiedCode = failureCode(error);
const code = classifiedCode === 'INTERNAL_UNKNOWN'
&& safeReason
&& explicitReason
&& typeof userMessage === 'string'
&& userMessage.trim()
? 'INPUT_INVALID'
: classifiedCode;
const safeReason = code === 'INTERNAL_UNKNOWN'
? (explicitReason ?? inferredFailureReason(error) ?? code)
: (explicitReason ?? code);
return Object.freeze({
code,
reason: safeReason ?? code,
reason: safeReason,
message: typeof userMessage === 'string' && userMessage.trim()
? userMessage.trim()
: t(FAILURE_MESSAGES[code]),
@ -206,9 +306,18 @@ export function messageFailureText(failure) {
export function setLastMessageFailure(status, error, options) {
const failure = classifyMessageFailure(error, options);
status.lastMessageError = failure;
if (failure.code === 'INTERNAL_UNKNOWN') {
void persistInternalUnknownDiagnostic(failure, error);
}
return failure;
}
/** @internal exported for sessionExists / tests */
export function isMissingSessionError(error) {
const code = typeof error?.code === 'string' ? error.code : '';
return MISSING_SESSION_CODES.has(code);
}
export function clearLastMessageFailure(status) {
status.lastMessageError = null;
}

View file

@ -40,6 +40,8 @@ test('message failures use verified turn-end provider codes without exposing pro
['STREAM_CLOSED', 'MODEL_STREAM'],
['EMPTY_RESPONSE', 'MODEL_EMPTY_REPLY'],
['CONTENT_FILTER', 'MODEL_CONTENT_REJECTED'],
['INVALID_REQUEST', 'MODEL_CONFIG'],
['PI_AI_ERROR', 'MODEL_SERVICE'],
]) {
const failure = classifyMessageFailure({
code: 'harness-turn-failed',
@ -59,11 +61,52 @@ test('message failures use verified turn-end provider codes without exposing pro
}, options).code, 'INTERNAL_UNKNOWN');
});
test('PI_AI_ERROR with TLS or transport wording maps to MODEL_TRANSPORT', () => {
for (const message of [
'unable to verify the first certificate',
'request to https://api.example.com failed, reason: UNABLE_TO_VERIFY_LEAF_SIGNATURE',
'fetch failed: other side closed',
'Stream ended without finish_reason',
]) {
const failure = classifyMessageFailure({
code: 'harness-turn-failed',
providerCode: 'PI_AI_ERROR',
reason: { kind: 'error', failure: { code: 'PI_AI_ERROR', message } },
}, options);
assert.equal(failure.code, 'MODEL_TRANSPORT', message);
assert.match(failure.message, /无法连接模型服务/);
assert.doesNotMatch(JSON.stringify(failure), /certificate|UNABLE_TO|finish_reason|example\.com/);
}
assert.equal(classifyMessageFailure({
code: 'harness-turn-failed',
providerCode: 'PI_AI_ERROR',
reason: { kind: 'error', error: { code: 'PI_AI_ERROR', message: 'unable to verify the first certificate' } },
}, options).code, 'MODEL_TRANSPORT');
});
test('RPC internal and UNKNOWN provider codes no longer fall through to INTERNAL_UNKNOWN', () => {
assert.equal(classifyMessageFailure({
code: 'internal', method: 'session.create',
}, options).code, 'HARNESS_SERVICE');
assert.equal(classifyMessageFailure({
code: 'harness-turn-failed', providerCode: 'UNKNOWN',
}, options).code, 'MODEL_SERVICE');
assert.equal(classifyMessageFailure({
code: 'harness-turn-failed',
providerCode: 'UNKNOWN',
reason: { kind: 'error', error: { code: 'UNKNOWN', message: 'unable to verify the first certificate' } },
}, options).code, 'MODEL_TRANSPORT');
const plain = classifyMessageFailure(new Error('secret-shaped internal detail'), options);
assert.equal(plain.code, 'INTERNAL_UNKNOWN');
assert.equal(plain.reason, 'ERROR');
});
test('message failure text contains a safe code and traceable reference', () => {
const failure = classifyMessageFailure(new Error('secret-shaped internal detail'), options);
assert.deepEqual(failure, {
code: 'INTERNAL_UNKNOWN',
reason: 'INTERNAL_UNKNOWN',
reason: 'ERROR',
message: '任务未完成,暂时无法确定原因。请重试;若持续发生,请将参考号提供给管理员。',
referenceId: 'MF-TEST01',
at: 123,