dsh-cindy-host 0.1.34 → 0.1.35
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +6 -0
- package/README.md +2 -2
- package/package.json +1 -1
- package/src/cindy-message-row.js +11 -1
- package/src/dsh-input-delivery.js +9 -1
- package/src/dsh-message-fold.js +6 -6
- package/src/dsh-plugin.js +1 -0
- package/src/host-version.js +1 -1
- package/src/host.js +33 -14
- package/src/turn-error.js +74 -10
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,11 @@
|
|
|
1
1
|
# Changelog
|
|
2
2
|
|
|
3
|
+
## 0.1.35 — 2026-11-05
|
|
4
|
+
|
|
5
|
+
- Show distinct native interruption, cancellation, sign-out, output-limit and blocked-turn notices without inventing model failures. Keep explicit native scheduled/started retries nonterminal, with safe known-code/count/delay details and stable live/history identities.
|
|
6
|
+
- Match native cold-query diagnostic recovery only when both the native session and Agent are absent. Apply display-only interrupted-turn closers without changing stored events, receipt evidence or live tool outcomes; restore notices before terminal envelopes under the existing authorization and generation guards.
|
|
7
|
+
- Render canonical tool-role results alongside legacy nested results, preserving call pairing, output and error/unknown-outcome markers. Extend Host diagnostic regressions and the opt-in actual mobile contract; source tests are not handset or arbitrary desktop-UI parity certification.
|
|
8
|
+
|
|
3
9
|
## 0.1.34 — 2026-11-05
|
|
4
10
|
|
|
5
11
|
- Project durable native failed turns as stable, sanitized error rows and terminal maker events in both cold history and live delivery. Preserve native failure/retry ownership, expose only fixed known-code messages and validated HTTP status, and repair reconnect state without stopping newer turns or leaking provider diagnostics.
|
package/README.md
CHANGED
|
@@ -4,12 +4,12 @@ A Host-side adapter connecting current Cindy clients to DeepSeek Harness.
|
|
|
4
4
|
|
|
5
5
|
## Requirements and installation
|
|
6
6
|
|
|
7
|
-
Version **0.1.
|
|
7
|
+
Version **0.1.35** adds native interruption, cancellation, output-limit and retry notices, cold-session diagnostic recovery and canonical tool-result pairing. It retains 0.1.34's question synchronization and safe model-failure display, plus the earlier regional authentication, reasoning streaming and native capability subsets.
|
|
8
8
|
|
|
9
9
|
It supports the exact **DSH 0.2.2-alpha.2** carrier, while retaining `0.2.1-rc.3`, `0.2.1-rc.4` and `0.2.1-rc.5`. Alpha requires **Node.js >=24.9** (tested with 24.12); the older carriers retain this plugin's Node.js >=22.16 minimum. Agent/session/settings peers and alpha Schemastery are explicitly allowed, not a blanket promise for future prereleases. The full Web installation gate uses DSH **0.2.1-rc.5**; alpha core-contract checks do not establish alpha Web/relay/handset acceptance. Install through the Desktop DSH plugin manager. CLI-managed profiles can use:
|
|
10
10
|
|
|
11
11
|
```sh
|
|
12
|
-
dsh plugin --profile <profile> add dsh-cindy-host@0.1.
|
|
12
|
+
dsh plugin --profile <profile> add dsh-cindy-host@0.1.35
|
|
13
13
|
```
|
|
14
14
|
|
|
15
15
|
Reload the Host plugin after installation. A full DSH restart is separate. Keep existing Cindy sign-in, device identity and settings; do not run two Host processes with the same identity.
|
package/package.json
CHANGED
package/src/cindy-message-row.js
CHANGED
|
@@ -288,12 +288,22 @@ const RENDERABLE_SOURCE_KINDS = new Set(['user', 'model', 'tool']);
|
|
|
288
288
|
*/
|
|
289
289
|
export function toCindyMessageRows(message, { sessionId, createdAt, now = () => new Date(), promptClientId = null }) {
|
|
290
290
|
const rows = [];
|
|
291
|
+
// Canonical DSH results use a tool-role message with root outcome/call ID,
|
|
292
|
+
// not the legacy user-role nested tool-result block. Keep both formats.
|
|
293
|
+
if (message?.role === 'tool') {
|
|
294
|
+
if (message.source?.kind !== undefined && message.source.kind !== 'tool') return rows;
|
|
295
|
+
if (typeof message.toolCallId !== 'string' || !message.toolCallId) return rows;
|
|
296
|
+
const id = `${sessionId}:${message.id}:0`;
|
|
297
|
+
return [{ id, clientId: id, sessionId, role: 'tool_result', toolUseId: message.toolCallId, agentMeta: null,
|
|
298
|
+
createdAt: createdAt ?? now().toISOString(),
|
|
299
|
+
content: { text: flattenToolResult(message.content), isError: message.isError === true } }];
|
|
300
|
+
}
|
|
291
301
|
const role = ROW_ROLE_BY_MESSAGE_ROLE.get(message?.role);
|
|
292
302
|
// `system` (the prompt injection), a hook's injected turn, or any role this
|
|
293
303
|
// Host has never seen: none of them is something a participant said.
|
|
294
304
|
if (role === undefined) return rows;
|
|
295
305
|
const blocks = Array.isArray(message?.content) ? message.content : [];
|
|
296
|
-
// DSH
|
|
306
|
+
// Older DSH messages record a tool result as a `user`-role message whose source is
|
|
297
307
|
// `{kind: 'tool'}`. Its content block is what makes it a result row; a *text*
|
|
298
308
|
// block on such a message is not the user speaking, so it is not rendered as
|
|
299
309
|
// one.
|
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import { createAttachmentMaterializer, hostAttachmentsOf } from './host-attachments.js';
|
|
2
2
|
import { foldSessionEvent } from './dsh-message-fold.js';
|
|
3
|
+
import { interruptedTurnClosers } from '@deepseek-ai/dsh-session';
|
|
3
4
|
import { queuedRowFromController } from './host-input-queue.js';
|
|
4
5
|
import { deliveryError } from './host-input-delivery-store.js';
|
|
5
6
|
import { assertNoStructuredSessionReferences } from './session-reference-boundary.js';
|
|
@@ -36,7 +37,14 @@ export function createDshInputDeliveryBackend({getServices,sessionController,get
|
|
|
36
37
|
const backend={
|
|
37
38
|
available(){try{services();return true;}catch{return false;}},
|
|
38
39
|
stop(){active=false;},
|
|
39
|
-
async readRows(sid){
|
|
40
|
+
async readRows(sid){
|
|
41
|
+
const persisted=await readLog(sid);const s=services();
|
|
42
|
+
// Match native session-query cold reads. This is a display-only overlay;
|
|
43
|
+
// evidence()/receipts still read the untouched persisted prefix. A live
|
|
44
|
+
// session or agent must never be diagnosed as a crashed writer.
|
|
45
|
+
const cold=!s.sessions.get(sid)&&typeof s.agents?.get==='function'&&!s.agents.get(sid);
|
|
46
|
+
const events=cold?[...persisted,...interruptedTurnClosers(persisted)]:persisted;
|
|
47
|
+
const rows=[];for(const event of events){const folded=foldSessionEvent(event,{sessionId:sid});folded.forEach((row,index)=>{const rowid=event.seq*65536+index;if(!Number.isSafeInteger(rowid))throw deliveryError('DELIVERY_EVIDENCE_CORRUPT','Message order exceeds the safe protocol range');rows.push({...row,rowid});});}return rows.reverse();},
|
|
40
48
|
async evidence(sid){const events=await readLog(sid);const proof=durableInputEvidence(events,sid);const agent=getServices().agents?.get(sid);return {...proof,running:agent?.status==='running',live:agent!==undefined};},
|
|
41
49
|
async prepare(sid,row,{resolveAttachmentRef,mode='queue'}={}){
|
|
42
50
|
assertNoStructuredSessionReferences(row);
|
package/src/dsh-message-fold.js
CHANGED
|
@@ -17,7 +17,7 @@
|
|
|
17
17
|
*/
|
|
18
18
|
import { deriveEventMessage, isAppendSurfaceEvent } from '@deepseek-ai/dsh-session/surface';
|
|
19
19
|
import { createReasoningStream } from './reasoning-stream.js';
|
|
20
|
-
import {
|
|
20
|
+
import { turnDiagnosticRow, turnStateFromEvents } from './turn-error.js';
|
|
21
21
|
import { toCindyMessageRows, toCindyMessages, hydrateImageAttachments } from './cindy-message-row.js';
|
|
22
22
|
|
|
23
23
|
/** Default page size when the controller does not ask for one. */
|
|
@@ -82,8 +82,8 @@ export const TRANSCRIPT_CACHE_SESSIONS = 3;
|
|
|
82
82
|
*/
|
|
83
83
|
export function foldSessionEvent(event, { sessionId, now = () => new Date() }) {
|
|
84
84
|
if (event === null || typeof event !== 'object') return [];
|
|
85
|
-
const
|
|
86
|
-
if (
|
|
85
|
+
const diagnostic = turnDiagnosticRow(event, sessionId);
|
|
86
|
+
if (diagnostic) return [diagnostic];
|
|
87
87
|
if (!isAppendSurfaceEvent(event)) return [];
|
|
88
88
|
let message;
|
|
89
89
|
try {
|
|
@@ -390,9 +390,9 @@ export function createMessageReader({
|
|
|
390
390
|
// gap as "nothing happened here".
|
|
391
391
|
if (Number.isFinite(event.seq)) lastSeq = event.seq;
|
|
392
392
|
reasoning.consume(event);
|
|
393
|
-
const
|
|
394
|
-
if (
|
|
395
|
-
entries.push({ rows: [
|
|
393
|
+
const diagnostic = turnDiagnosticRow(event, sessionId);
|
|
394
|
+
if (diagnostic) {
|
|
395
|
+
entries.push({ rows: [diagnostic] });
|
|
396
396
|
continue;
|
|
397
397
|
}
|
|
398
398
|
const todo = entryForTodoWrite(event);
|
package/src/dsh-plugin.js
CHANGED
|
@@ -2033,6 +2033,7 @@ export function apply(ctx, config) {
|
|
|
2033
2033
|
// Make the failed history row readable before publishing its terminal identity.
|
|
2034
2034
|
// Everything above is synchronous, so a new turn cannot interleave here.
|
|
2035
2035
|
if (event?.type === 'turn/end' && typeof runtime.pushTurnIdle === 'function') runtime.pushTurnIdle(sessionId, event);
|
|
2036
|
+
if (typeof runtime.pushSessionDiagnostic === 'function') runtime.pushSessionDiagnostic(sessionId, event);
|
|
2036
2037
|
// Chunks are intentionally absent from deriveEventMessage. Publish a temporary
|
|
2037
2038
|
// thinking overlay too, retiring it before the durable assistant rows below.
|
|
2038
2039
|
runtime.pushSessionReasoning?.(sessionId, event, {
|
package/src/host-version.js
CHANGED
package/src/host.js
CHANGED
|
@@ -36,7 +36,7 @@ import { SessionReadModel } from './session-read-model.js';
|
|
|
36
36
|
import { ProjectionReadModelSink } from './projection-read-model-sink.js';
|
|
37
37
|
import { publishLifecycle, runningStatusEvent, turnEventFor } from './session-publisher.js';
|
|
38
38
|
import { createReasoningPublisher } from './reasoning-publisher.js';
|
|
39
|
-
import {
|
|
39
|
+
import { turnDiagnosticRow, turnDiagnosticPayload } from './turn-error.js';
|
|
40
40
|
import { acceptLink } from './host-authorization.js';
|
|
41
41
|
import { AuthorizationPolicy } from './authorization-policy.js';
|
|
42
42
|
import { HostStatus } from './host-status.js';
|
|
@@ -925,12 +925,18 @@ export async function startHost(initialSource, settings = DEFAULT_HOST_SETTINGS,
|
|
|
925
925
|
}
|
|
926
926
|
|
|
927
927
|
/** Send a native terminal identity once per authorized subscription/connection. */
|
|
928
|
-
function sendNativeTerminal(deviceId, sessionId, payload, identity) {
|
|
928
|
+
function sendNativeTerminal(deviceId, sessionId, payload, identity, row) {
|
|
929
929
|
if (stopped || !active || !acceptedControllers.has(deviceId) || !policy.canAccept(deviceId) || sessionIsHidden(sessionId)
|
|
930
930
|
|| !sessionSubscribers.get(sessionId)?.has(deviceId)) return false;
|
|
931
931
|
const key = terminalAnnounceKey(deviceId, sessionId);
|
|
932
932
|
const stamp = `${generation}:${subscriptionLeases.get(key) ?? 0}:${identity}`;
|
|
933
933
|
if (terminalIdentities.get(key) === stamp) return false;
|
|
934
|
+
// Reconnect must restore the actual diagnostic, not merely stop a spinner.
|
|
935
|
+
if (row) {
|
|
936
|
+
const notice = { sessionId, message: row };
|
|
937
|
+
recordPush(sessionId, 'local-db:messages:created', offlineDevices.has(deviceId) ? 0 : 1, notice);
|
|
938
|
+
send({ v: PROTOCOL_VERSION, kind: 'push', dst: deviceId, payload: { channel: 'local-db:messages:created', payload: notice } });
|
|
939
|
+
}
|
|
934
940
|
recordPush(sessionId, 'maker:event', offlineDevices.has(deviceId) ? 0 : 1, payload);
|
|
935
941
|
send({ v: PROTOCOL_VERSION, kind: 'push', dst: deviceId, payload: { channel: 'maker:event', payload } });
|
|
936
942
|
if (!offlineDevices.has(deviceId)) {
|
|
@@ -978,7 +984,7 @@ export async function startHost(initialSource, settings = DEFAULT_HOST_SETTINGS,
|
|
|
978
984
|
if (!valid() || !live || typeof live.running !== 'boolean') return;
|
|
979
985
|
if (live.running) {
|
|
980
986
|
setCachedRunning(sessionId, true);
|
|
981
|
-
const payload = { sessionId, event: runningStatusEvent() };
|
|
987
|
+
const payload = { sessionId, event: runningStatusEvent(state.retryNotice?.content ?? 'running') };
|
|
982
988
|
recordPush(sessionId, 'maker:event', 1, payload);
|
|
983
989
|
send({ v: PROTOCOL_VERSION, kind: 'push', dst: deviceId, payload: { channel: 'maker:event', payload } });
|
|
984
990
|
return;
|
|
@@ -987,8 +993,9 @@ export async function startHost(initialSource, settings = DEFAULT_HOST_SETTINGS,
|
|
|
987
993
|
if ((nativeBoundaries.get(sessionId)?.seq ?? -1) > state.boundarySeq) return;
|
|
988
994
|
// No await after the final guard: a new boundary cannot interleave with send.
|
|
989
995
|
setCachedRunning(sessionId, false);
|
|
990
|
-
const
|
|
991
|
-
|
|
996
|
+
const diagnostic = state.error ?? state.notice;
|
|
997
|
+
const payload = diagnostic ? turnDiagnosticPayload(diagnostic) : { sessionId, event: { type: 'done' } };
|
|
998
|
+
sendNativeTerminal(deviceId, sessionId, payload, `end:${state.boundarySeq}`, diagnostic);
|
|
992
999
|
} catch {
|
|
993
1000
|
if (!valid()) return;
|
|
994
1001
|
// Never reflect arbitrary upstream exception prose into remote diagnostics.
|
|
@@ -1363,7 +1370,7 @@ export async function startHost(initialSource, settings = DEFAULT_HOST_SETTINGS,
|
|
|
1363
1370
|
*/
|
|
1364
1371
|
function pushSessionUpdate(sessionId, channel, payload) {
|
|
1365
1372
|
if (channel === 'maker:input:projection' && activeDelivery) payload = activeDelivery.currentProjection(sessionId, [], payload);
|
|
1366
|
-
const guarded =
|
|
1373
|
+
const guarded = channel === 'local-db:messages:created' || channel === 'maker:event';
|
|
1367
1374
|
const devices = guarded
|
|
1368
1375
|
? new Set([...(sessionSubscribers.get(sessionId) ?? [])].filter(id => !sessionIsHidden(sessionId) && acceptedControllers.has(id) && policy.canAccept(id)))
|
|
1369
1376
|
: sessionSubscribers.get(sessionId);
|
|
@@ -1450,7 +1457,7 @@ export async function startHost(initialSource, settings = DEFAULT_HOST_SETTINGS,
|
|
|
1450
1457
|
// nothing, which is the same class of failure as the bug being fixed.)
|
|
1451
1458
|
const provider = typeof options.resolveCapabilities === 'function' ? options.resolveCapabilities() : undefined;
|
|
1452
1459
|
const hydrate = provider?.hydrateImages;
|
|
1453
|
-
if (message?.role
|
|
1460
|
+
if (!['error', 'system'].includes(message?.role) && typeof hydrate === 'function') await hydrate([message]);
|
|
1454
1461
|
} catch {
|
|
1455
1462
|
// Un-hydrated is still worth sending.
|
|
1456
1463
|
}
|
|
@@ -1816,16 +1823,16 @@ export async function startHost(initialSource, settings = DEFAULT_HOST_SETTINGS,
|
|
|
1816
1823
|
for (const deviceId of sessionSubscribers.get(sessionId) ?? []) void restoreWatchedTurnState(deviceId, sessionId);
|
|
1817
1824
|
return;
|
|
1818
1825
|
}
|
|
1819
|
-
const
|
|
1820
|
-
const
|
|
1821
|
-
if (
|
|
1826
|
+
const diagnostic = turnDiagnosticRow(event, sessionId);
|
|
1827
|
+
const requiresNotice = diagnostic !== null || ['error', 'interrupted', 'max-tokens', 'aborted', 'blocked'].includes(event?.data?.reason?.kind);
|
|
1828
|
+
if (requiresNotice && !diagnostic) {
|
|
1822
1829
|
recordHandlerError('turn-error', new Error('Invalid native failed-turn identity; history resynchronization required'));
|
|
1823
1830
|
pushHistoryViewChanged(sessionId);
|
|
1824
1831
|
return;
|
|
1825
1832
|
}
|
|
1826
1833
|
if (event && Number.isSafeInteger(event.seq)) {
|
|
1827
1834
|
if ((nativeBoundaries.get(sessionId)?.seq ?? -1) >= event.seq) return;
|
|
1828
|
-
nativeBoundaries.set(sessionId, { seq: event.seq });
|
|
1835
|
+
nativeBoundaries.set(sessionId, { seq: event.seq, turn: event.data?.turn });
|
|
1829
1836
|
}
|
|
1830
1837
|
turnVersions.set(sessionId, (turnVersions.get(sessionId) ?? 0) + 1);
|
|
1831
1838
|
const timer = pendingReconciles.get(sessionId);
|
|
@@ -1839,8 +1846,8 @@ export async function startHost(initialSource, settings = DEFAULT_HOST_SETTINGS,
|
|
|
1839
1846
|
// tells it first — and the re-link repair depends on that record meaning "it arrived", so a
|
|
1840
1847
|
// device the relay calls unreachable must not be written into it.
|
|
1841
1848
|
const watchers = [...(sessionSubscribers.get(sessionId) ?? [])];
|
|
1842
|
-
if (
|
|
1843
|
-
const payload =
|
|
1849
|
+
if (requiresNotice) {
|
|
1850
|
+
const payload = turnDiagnosticPayload(diagnostic);
|
|
1844
1851
|
for (const deviceId of watchers) sendNativeTerminal(deviceId, sessionId, payload, `end:${event.seq}`);
|
|
1845
1852
|
// Error detail never goes to list subscribers or unwatched controllers.
|
|
1846
1853
|
publishRowTurnState(sessionId, false);
|
|
@@ -2000,7 +2007,7 @@ export async function startHost(initialSource, settings = DEFAULT_HOST_SETTINGS,
|
|
|
2000
2007
|
function announceTurnRunning(sessionId, event) {
|
|
2001
2008
|
if (event && Number.isSafeInteger(event.seq)) {
|
|
2002
2009
|
if ((nativeBoundaries.get(sessionId)?.seq ?? -1) >= event.seq) return;
|
|
2003
|
-
nativeBoundaries.set(sessionId, { seq: event.seq });
|
|
2010
|
+
nativeBoundaries.set(sessionId, { seq: event.seq, turn: event.data?.turn });
|
|
2004
2011
|
}
|
|
2005
2012
|
turnVersions.set(sessionId, (turnVersions.get(sessionId) ?? 0) + 1);
|
|
2006
2013
|
setCachedRunning(sessionId, true);
|
|
@@ -2845,6 +2852,18 @@ export async function startHost(initialSource, settings = DEFAULT_HOST_SETTINGS,
|
|
|
2845
2852
|
/** Push one live session message to the controllers watching that session. */
|
|
2846
2853
|
pushSessionMessage,
|
|
2847
2854
|
pushSessionReasoning: reasoningPublisher.noteEvent,
|
|
2855
|
+
// Only explicit native scheduled/started retry events assert ongoing work.
|
|
2856
|
+
// Failed attempts alone never terminate or start a turn.
|
|
2857
|
+
pushSessionDiagnostic: (sessionId, event) => {
|
|
2858
|
+
if (!['llm/retry', 'llm/retry-started'].includes(event?.type)) return;
|
|
2859
|
+
const row = turnDiagnosticRow(event, sessionId);
|
|
2860
|
+
const boundary = nativeBoundaries.get(sessionId);
|
|
2861
|
+
if (!row || (boundary?.seq ?? -1) >= event.seq
|
|
2862
|
+
|| (boundary?.turn !== undefined && boundary.turn !== event.data?.turn)) return;
|
|
2863
|
+
turnVersions.set(sessionId, (turnVersions.get(sessionId) ?? 0) + 1);
|
|
2864
|
+
setCachedRunning(sessionId, true);
|
|
2865
|
+
pushSessionUpdate(sessionId, 'maker:event', { sessionId, event: runningStatusEvent(row.content) });
|
|
2866
|
+
},
|
|
2848
2867
|
/**
|
|
2849
2868
|
* Announce a real turn boundary to a session's watchers.
|
|
2850
2869
|
*
|
package/src/turn-error.js
CHANGED
|
@@ -1,6 +1,8 @@
|
|
|
1
|
-
/** Display-only
|
|
1
|
+
/** Display-only native turn diagnostics. DSH owns failure, recovery and retry policy. */
|
|
2
2
|
const SAFE_MESSAGES = new Map([
|
|
3
3
|
['AUTH', 'Model authentication failed. Check the provider configuration.'],
|
|
4
|
+
['ACCOUNT_SIGNED_OUT', '任务已因退出 DeepSeek 登录而停止。'],
|
|
5
|
+
['ACCOUNT_SIGN_IN_REQUIRED', '请先登录 DeepSeek,并确认请求地址支持账号认证。'],
|
|
4
6
|
['MISSING_CREDENTIAL', 'Model credentials are missing. Check the provider configuration.'],
|
|
5
7
|
['INVALID_CREDENTIAL', 'Model credentials are invalid. Check the provider configuration.'],
|
|
6
8
|
['RATE_LIMIT', 'The model request was rate limited.'],
|
|
@@ -20,16 +22,20 @@ export function turnErrorRow(event, sessionId) {
|
|
|
20
22
|
if (typeof sessionId !== 'string' || !sessionId || !Number.isSafeInteger(event.seq) || event.seq < 0) return null;
|
|
21
23
|
// No wall-clock fallback: cold replay and live delivery must produce identical rows.
|
|
22
24
|
if (!Number.isFinite(event.time) || !Number.isFinite(new Date(event.time).getTime())) return null;
|
|
23
|
-
const
|
|
25
|
+
const content = safeFailureContent(event.data.reason.error);
|
|
26
|
+
const id = `${sessionId}:turn-error:${event.seq}`;
|
|
27
|
+
return { id, clientId: id, sessionId, role: 'error', toolUseId: null, agentMeta: null,
|
|
28
|
+
createdAt: new Date(event.time).toISOString(), content };
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
/** One safe allowlist for terminal model failures and scheduled retry details. */
|
|
32
|
+
export function safeFailureContent(failure) {
|
|
24
33
|
const code = SAFE_MESSAGES.has(failure?.code) ? failure.code : null;
|
|
25
34
|
const status = Number.isInteger(failure?.status) && failure.status >= 100 && failure.status <= 599 ? failure.status : null;
|
|
26
35
|
const detail = [code, status === null ? null : `HTTP ${status}`].filter(Boolean).join('; ');
|
|
27
36
|
const message = (SAFE_MESSAGES.get(code) ?? 'This turn failed. See the local DSH diagnostics for details.')
|
|
28
37
|
+ (detail ? ` (${detail})` : '');
|
|
29
|
-
|
|
30
|
-
return { id, clientId: id, sessionId, role: 'error', toolUseId: null, agentMeta: null,
|
|
31
|
-
createdAt: new Date(event.time).toISOString(),
|
|
32
|
-
content: { message, reason: 'dsh-turn-error', ...(code ? { code } : {}), ...(status === null ? {} : { status }) } };
|
|
38
|
+
return { message, reason: 'dsh-turn-error', ...(code ? { code } : {}), ...(status === null ? {} : { status }) };
|
|
33
39
|
}
|
|
34
40
|
|
|
35
41
|
/** The maker envelope and the history row share one durable identity. */
|
|
@@ -38,10 +44,63 @@ export function turnErrorPayload(row) {
|
|
|
38
44
|
data: { ...row.content, isTerminal: true, willRetry: false } } };
|
|
39
45
|
}
|
|
40
46
|
|
|
47
|
+
/** User-visible desktop turn tails, kept separate from model failures. Never
|
|
48
|
+
* infer an interruption from an ordinary aborted turn or an arbitrary string. */
|
|
49
|
+
export function turnDiagnosticRow(event, sessionId) {
|
|
50
|
+
const failure = turnErrorRow(event, sessionId);
|
|
51
|
+
if (failure) return failure;
|
|
52
|
+
if (!Number.isSafeInteger(event?.seq) || event.seq < 0 || !Number.isFinite(event.time)
|
|
53
|
+
|| !Number.isFinite(new Date(event.time).getTime()) || typeof sessionId !== 'string' || !sessionId) return null;
|
|
54
|
+
let content;
|
|
55
|
+
const data = event.data;
|
|
56
|
+
if (event.type === 'turn/end') {
|
|
57
|
+
const reason = data?.reason;
|
|
58
|
+
switch (reason?.kind) {
|
|
59
|
+
case 'interrupted':
|
|
60
|
+
content = '任务已停止:DSH 进程在本轮完成前退出(例如被强制结束、崩溃或关闭超时),重新启动后本轮已中止。未完成的工具调用结果未知。';
|
|
61
|
+
break;
|
|
62
|
+
case 'max-tokens':
|
|
63
|
+
content = '已达到输出 token 上限。回答被截断,已有输出保留在对话中。发送“继续”可让模型接着输出。';
|
|
64
|
+
break;
|
|
65
|
+
case 'blocked': content = '本轮执行被阻止,未正常完成。原生结束事件未提供更多原因。'; break;
|
|
66
|
+
case 'aborted':
|
|
67
|
+
content = reason.reason?.kind === 'hook' && reason.reason.reason === 'deepseek-account/signed-out'
|
|
68
|
+
? '任务已因退出 DeepSeek 登录而停止。'
|
|
69
|
+
: reason.reason?.kind === 'user' ? '任务已停止:用户取消了本轮执行。'
|
|
70
|
+
: reason.reason?.kind === 'parent' ? '任务已停止:上级任务取消了本轮执行。'
|
|
71
|
+
: reason.reason?.kind === 'disposed' ? '任务已停止:本轮运行实例已被释放。'
|
|
72
|
+
: '任务已停止:本轮执行已被取消。';
|
|
73
|
+
break;
|
|
74
|
+
default:
|
|
75
|
+
if (!reason || typeof reason.kind !== 'string' || ['completed', 'forked'].includes(reason.kind)) return null;
|
|
76
|
+
content = '本轮已结束。当前 Host 尚不能显示此结束原因的详细信息,请在 DSH 桌面查看。';
|
|
77
|
+
break;
|
|
78
|
+
}
|
|
79
|
+
} else if (event.type === 'llm/retry') {
|
|
80
|
+
if (!['normal', 'always'].includes(data?.mode) || !Number.isSafeInteger(data?.retry) || data.retry < 1 || !Number.isFinite(data.delayMs) || data.delayMs < 0) return null;
|
|
81
|
+
const maximum = data.mode === 'always' ? '不限' : Number.isSafeInteger(data.maxRetries) && data.maxRetries >= data.retry ? data.maxRetries : null;
|
|
82
|
+
if (maximum === null) return null;
|
|
83
|
+
content = `模型请求失败,DSH 已安排重试(${data.retry}/${maximum}),等待 ${Math.max(1, Math.ceil(data.delayMs / 1000))} 秒。${safeFailureContent(data.failure).message}`;
|
|
84
|
+
} else if (event.type === 'llm/retry-started') {
|
|
85
|
+
if (!Number.isSafeInteger(data?.retry) || data.retry < 1) return null;
|
|
86
|
+
content = `DSH 已开始第 ${data.retry} 次模型请求重试。`;
|
|
87
|
+
} else return null;
|
|
88
|
+
const id = `${sessionId}:turn-notice:${event.seq}`;
|
|
89
|
+
return { id, clientId: id, sessionId, role: 'system', toolUseId: null, agentMeta: null,
|
|
90
|
+
createdAt: new Date(event.time).toISOString(), content };
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
/** Notices close the running flag without pretending cancellation is a model failure. */
|
|
94
|
+
export function turnDiagnosticPayload(row) {
|
|
95
|
+
return row.role === 'error' ? turnErrorPayload(row) : { sessionId: row.sessionId, persistId: row.clientId,
|
|
96
|
+
event: { type: 'done', data: { message: row.content, reason: 'dsh-turn-notice' } } };
|
|
97
|
+
}
|
|
98
|
+
|
|
41
99
|
/** Latest native turn boundary, not the latest error in an arbitrary history page. */
|
|
42
100
|
export function turnStateFromEvents(events, sessionId) {
|
|
43
101
|
if (!Array.isArray(events)) throw new TypeError('Native turn recovery requires a session event snapshot');
|
|
44
102
|
let boundary = null;
|
|
103
|
+
let retryNotice = null;
|
|
45
104
|
let seq = -1;
|
|
46
105
|
for (const event of events) {
|
|
47
106
|
if (!Number.isSafeInteger(event?.seq) || event.seq < 0 || event.seq <= seq
|
|
@@ -49,12 +108,17 @@ export function turnStateFromEvents(events, sessionId) {
|
|
|
49
108
|
throw new TypeError('Native turn recovery requires ordered unique event sequences');
|
|
50
109
|
}
|
|
51
110
|
seq = event.seq;
|
|
52
|
-
if (event.type === 'turn/start' || event.type === 'turn/end') boundary = event;
|
|
111
|
+
if (event.type === 'turn/start' || event.type === 'turn/end') { boundary = event; retryNotice = null; }
|
|
112
|
+
else if (['step/start', 'assistant/message'].includes(event.type)) retryNotice = null;
|
|
113
|
+
else if (boundary?.type === 'turn/start' && boundary.data?.turn === event.data?.turn
|
|
114
|
+
&& ['llm/retry', 'llm/retry-started'].includes(event.type)) retryNotice = turnDiagnosticRow(event, sessionId);
|
|
53
115
|
}
|
|
54
116
|
if (!boundary) return null;
|
|
55
|
-
if (boundary.type === 'turn/start') return { seq, boundarySeq: boundary.seq, running: true, error: null };
|
|
56
|
-
const
|
|
117
|
+
if (boundary.type === 'turn/start') return { seq, boundarySeq: boundary.seq, running: true, error: null, ...(retryNotice ? { retryNotice } : {}) };
|
|
118
|
+
const diagnostic = turnDiagnosticRow(boundary, sessionId);
|
|
119
|
+
const error = diagnostic?.role === 'error' ? diagnostic : null;
|
|
57
120
|
// A malformed failure is not evidence of a successful ending.
|
|
58
121
|
if (boundary.data?.reason?.kind === 'error' && !error) throw new TypeError('Invalid native failed-turn identity');
|
|
59
|
-
|
|
122
|
+
if (['interrupted', 'max-tokens', 'blocked', 'aborted'].includes(boundary.data?.reason?.kind) && !diagnostic) throw new TypeError('Invalid native turn-notice identity');
|
|
123
|
+
return { seq, boundarySeq: boundary.seq, running: false, error, ...(diagnostic?.role === 'system' ? { notice: diagnostic } : {}) };
|
|
60
124
|
}
|