mirror of
https://github.com/hansjone/dsh-im-ops.git
synced 2026-10-09 00:33:20 +08:00
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:
parent
45f430fb28
commit
2723e94541
5 changed files with 397 additions and 220 deletions
|
|
@ -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(),
|
||||
|
|
|
|||
420
lib/index.js
420
lib/index.js
File diff suppressed because one or more lines are too long
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue