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 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.34** improves native/phone question synchronization and cancellation, and safely displays durable model/turn failures. It retains 0.1.33's regional authentication UI, public reasoning streaming and native capability subsets.
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.34
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
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "dsh-cindy-host",
3
- "version": "0.1.34",
3
+ "version": "0.1.35",
4
4
  "private": false,
5
5
  "description": "Cindy 手机客户端连接 DSH 的 Host 侧插件:会话/消息/审批/todo/goal 的手机通道,以及配套的设置卡片",
6
6
  "license": "MIT",
@@ -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 records a tool result as a `user`-role message whose source is
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){const events=await readLog(sid);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
+ 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);
@@ -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 { turnErrorRow, turnStateFromEvents } from './turn-error.js';
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 error = turnErrorRow(event, sessionId);
86
- if (error) return [error];
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 error = turnErrorRow(event, sessionId);
394
- if (error) {
395
- entries.push({ rows: [error] });
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, {
@@ -4,4 +4,4 @@
4
4
  */
5
5
  export const HOST_APP_VERSION = '0.1.82';
6
6
  /** Actual npm release, separate from the Cindy compatibility identity. */
7
- export const HOST_PLUGIN_VERSION = '0.1.34';
7
+ export const HOST_PLUGIN_VERSION = '0.1.35';
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 { turnErrorRow, turnErrorPayload } from './turn-error.js';
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 payload = state.error ? turnErrorPayload(state.error) : { sessionId, event: { type: 'done' } };
991
- sendNativeTerminal(deviceId, sessionId, payload, `end:${state.boundarySeq}`);
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 = payload?.message?.role === 'error' || payload?.event?.type === 'error';
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 !== 'error' && typeof hydrate === 'function') await hydrate([message]);
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 error = turnErrorRow(event, sessionId);
1820
- const failed = event?.data?.reason?.kind === 'error';
1821
- if (failed && !error) {
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 (failed) {
1843
- const payload = turnErrorPayload(error);
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 projection of a durable failed turn. DSH owns failure and retry policy. */
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 failure = event.data.reason.error;
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
- const id = `${sessionId}:turn-error:${event.seq}`;
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 error = turnErrorRow(boundary, sessionId);
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
- return { seq, boundarySeq: boundary.seq, running: false, error };
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
  }