diff --git a/packages/client/ui-conversation/src/client/chat/chat-flow.ts b/packages/client/ui-conversation/src/client/chat/chat-flow.ts
index ad98d3aaad..f925ffbebb 100644
--- a/packages/client/ui-conversation/src/client/chat/chat-flow.ts
+++ b/packages/client/ui-conversation/src/client/chat/chat-flow.ts
@@ -1,7 +1,8 @@
/**
* Chat flow derivation: ConversationSnapshot nodes -> render items. Tool
* results group into consecutive-run tool groups (figma step-summary flow,
- * VERTICAL gap10) alternating with narration; everything else passes through.
+ * VERTICAL gap10) alternating with narration. Consecutive retry notices
+ * reuse the first notice's row while projecting the latest retry turn.
* Item identity keys are stable across snapshots so the list parent can
* subscribe to keys only while rows subscribe to content. IconActions ownership
* (last content assistant per turn) is derived here too so ChatView and the
@@ -49,7 +50,7 @@ export function assistantActionsSeqs(nodes: readonly ConversationNode[]): Readon
/**
* Group finalized nodes into the step-summary flow.
* @param nodes - snapshot nodes (surface order).
- * @returns flow items; consecutive tool-results merged into one group keyed by the first seq.
+ * @returns flow items; consecutive tool results and retry notices reuse their first key.
*/
export function deriveChatFlow(nodes: readonly ConversationNode[]): ChatFlowItem[] {
const items: ChatFlowItem[] = []
@@ -63,6 +64,17 @@ export function deriveChatFlow(nodes: readonly ConversationNode[]): ChatFlowItem
} else {
group.push(node)
}
+ } else if (node.kind === 'model-retry') {
+ group = null
+ const previous = items[items.length - 1]
+ if (
+ previous?.kind === 'node'
+ && previous.node.kind === 'model-retry'
+ ) {
+ items[items.length - 1] = { ...previous, node }
+ } else {
+ items.push({ kind: 'node', key: `n${node.seq}`, node })
+ }
} else {
group = null
items.push({ kind: 'node', key: `n${node.seq}`, node })
diff --git a/packages/client/ui-conversation/src/client/locales.ts b/packages/client/ui-conversation/src/client/locales.ts
index fdf859e744..1a9c7a1f34 100644
--- a/packages/client/ui-conversation/src/client/locales.ts
+++ b/packages/client/ui-conversation/src/client/locales.ts
@@ -53,6 +53,13 @@ export const zh = {
'message.unknownBlock': '未知内容块',
'message.stopped': '已停止',
'message.branch': '在新对话中分支',
+ 'message.retry.active': '正在重试模型请求',
+ 'message.retry.cancelled': '模型请求重试已取消',
+ 'message.retry.started': '已重试模型请求',
+ 'message.retry.scheduled': '等待重试模型请求',
+ 'message.retry.status': '{label}({retry}/{maximum}) · {seconds}s',
+ 'message.retry.delay': '重试延迟:',
+ 'message.retry.failure': '失败原因:',
'command.running': '执行中…',
'command.failed': '命令失败',
'command.done': '已完成',
@@ -140,6 +147,13 @@ export const en = {
'message.unknownBlock': 'Unknown content block',
'message.stopped': 'Stopped',
'message.branch': 'Branch into a new conversation',
+ 'message.retry.active': 'Retrying model request',
+ 'message.retry.cancelled': 'Model request retry cancelled',
+ 'message.retry.started': 'Retried model request',
+ 'message.retry.scheduled': 'Waiting to retry model request',
+ 'message.retry.status': '{label} ({retry}/{maximum}) · {seconds}s',
+ 'message.retry.delay': 'Retry delay: ',
+ 'message.retry.failure': 'Failure reason: ',
'command.running': 'Running…',
'command.failed': 'Command failed',
'command.done': 'Completed',
diff --git a/packages/client/ui-conversation/tests/chat-branch-tails.spec.tsx b/packages/client/ui-conversation/tests/chat-branch-tails.spec.tsx
index a88d1175ee..779b925fd9 100644
--- a/packages/client/ui-conversation/tests/chat-branch-tails.spec.tsx
+++ b/packages/client/ui-conversation/tests/chat-branch-tails.spec.tsx
@@ -18,7 +18,10 @@ import { AssistantMarkdown } from '../src/client/chat/AssistantMarkdown.tsx'
import { StatsLine, type StatsLineProps } from '../src/client/chat/StatsLine.tsx'
import { zh } from '../src/client/locales.ts'
-afterEach(cleanup)
+afterEach(() => {
+ cleanup()
+ vi.useRealTimers()
+})
// Mirrors the real lookup chain (conversation namespace, then common).
const t: MessageItemProps['t'] = makeTranslate(zh, commonZh)
@@ -160,6 +163,157 @@ describe('MessageItem arms', () => {
)
expect(unknownView.getByText(/未知 surface 事件:surface\/next/)).toBeTruthy()
})
+
+ it('collapses retry details behind the durable model retry status', () => {
+ vi.useFakeTimers()
+ vi.setSystemTime(10_000)
+ const view = render(
+
,
+ )
+ const details = view.container.querySelector('details')
+ const summary = view.container.querySelector('summary')
+ expect(details?.open).toBe(false)
+ expect(details?.dataset.active).toBe('true')
+ expect(view.getByRole('status').textContent).toBe('正在重试模型请求(1/2) · 3s')
+ expect(view.getByText('重试延迟:').parentElement?.textContent).toBe('重试延迟:2500ms')
+ expect(view.getByText('失败原因:').parentElement?.textContent).toBe('失败原因:连接被重置')
+
+ act(() => { vi.advanceTimersByTime(1_100) })
+ expect(view.getByRole('status').textContent).toBe('正在重试模型请求(1/2) · 2s')
+ act(() => { vi.advanceTimersByTime(1_000) })
+ expect(view.getByRole('status').textContent).toBe('正在重试模型请求(1/2) · 1s')
+
+ view.rerender(
+
,
+ )
+ expect(view.getByRole('status').textContent).toBe('正在重试模型请求(2/2) · 4s')
+
+ if (summary === null) throw new Error('retry summary missing')
+ fireEvent.click(summary)
+ expect(details?.open).toBe(true)
+
+ view.rerender(
+
,
+ )
+ expect(details?.dataset.active).toBeUndefined()
+ expect(view.getByRole('status').textContent).toBe('已重试模型请求(2/2) · 4s')
+
+ view.rerender(
+
,
+ )
+ expect(view.getByRole('status').textContent).toBe('已重试模型请求(3/∞) · 4s')
+
+ view.rerender(
+
,
+ )
+ expect(view.getByRole('status').textContent).toBe('模型请求重试已取消(1/2) · 4s')
+ })
+
+ it('synchronizes the countdown when an inactive retry becomes active at the one-second floor', () => {
+ vi.useFakeTimers()
+ vi.setSystemTime(10_000)
+ const node = {
+ kind: 'model-retry',
+ seq: 5,
+ time: 10_000,
+ retryState: 'scheduled',
+ turn: 1,
+ step: 0,
+ provider: 'mock',
+ mode: 'normal',
+ policyKey: 'mock-normal',
+ retry: 1,
+ maxRetries: 2,
+ delayMs: 5_000,
+ failure: { code: 'TRANSPORT', message: '连接被重置' },
+ } as const
+ const view = render(
)
+ expect(view.getByRole('status').textContent).toBe('等待重试模型请求(1/2) · 5s')
+
+ act(() => { vi.advanceTimersByTime(4_200) })
+ view.rerender(
)
+ expect(view.getByRole('status').textContent).toBe('正在重试模型请求(1/2) · 1s')
+ })
})
describe('formatMessageClock', () => {
diff --git a/packages/client/ui-conversation/tests/chat-view.spec.tsx b/packages/client/ui-conversation/tests/chat-view.spec.tsx
index 965c983e18..bfaab93375 100644
--- a/packages/client/ui-conversation/tests/chat-view.spec.tsx
+++ b/packages/client/ui-conversation/tests/chat-view.spec.tsx
@@ -7,8 +7,9 @@ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import { Profiler } from 'react'
import { act, cleanup, fireEvent, render } from '@testing-library/react'
import type {
- AssistantMessageNode, CommandNode, ConversationNode, ConversationSnapshot, RunningToolCall, SessionId,
- SessionListState, ToolResultNode, UserMessageNode, WorkspaceListState,
+ AssistantMessageNode, CommandNode, ConversationNode, ConversationSnapshot,
+ ModelRetryNode, RunningToolCall, SessionId, SessionListState, ToolResultNode,
+ UserMessageNode, WorkspaceListState,
} from '@deepseek-ai/dsh-client-runtime/client'
import { bindSnapshotSelector } from '@deepseek-ai/dsh-client-web-react'
import { createSnapshotStore, PendingWait } from '@deepseek-ai/dsh-client-runtime/client'
@@ -67,6 +68,13 @@ const user = (seq: number, text: string): UserMessageNode => ({
const assistant = (seq: number, text: string, turn = 1): AssistantMessageNode => ({
kind: 'assistant', seq, time: seq * 1_000, turn, step: 1, blocks: [{ kind: 'text', text }],
})
+const retry = (seq: number): ModelRetryNode => ({
+ kind: 'model-retry', seq, time: seq * 1_000, turn: 1, step: 0,
+ retryState: 'scheduled',
+ provider: 'mock', mode: 'normal', policyKey: 'mock-normal',
+ retry: 1, maxRetries: 2, delayMs: 450,
+ failure: { code: 'TRANSPORT', message: '连接被重置' },
+})
const toolResult = (seq: number, callId: string, name = 'bash'): ToolResultNode => ({
kind: 'tool-result', seq, time: seq * 1_000, callId,
call: { name, argsRaw: `{"command":"cmd-${callId}","description":"run ${callId}"}` },
@@ -155,6 +163,17 @@ describe('chat-flow derivation', () => {
expect(flowKeys(deriveChatFlow([...nodes, toolResult(7, 'd')]))).toBe('n1|n2|g3|n5|g6')
})
+ it('reuses one stable row for consecutive retry turns', () => {
+ const first = retry(2)
+ const second = { ...retry(3), turn: 2, retry: 2 }
+ const initial = deriveChatFlow([user(1, 'try'), first])
+ const updated = deriveChatFlow([user(1, 'try'), first, second])
+ expect(flowKeys(initial)).toBe('n1|n2')
+ expect(flowKeys(updated)).toBe('n1|n2')
+ expect(updated).toHaveLength(2)
+ expect(updated[1]?.kind === 'node' && updated[1].node).toBe(second)
+ })
+
it('skips render-nothing assistant nodes so tool runs stay one group', () => {
// A tool-call-only step message (and blank text/reasoning) renders nothing:
// it must not split the run into two groups with an empty line between.
@@ -227,6 +246,47 @@ describe('ChatView', () => {
expect(view.getByText('run a')).toBeTruthy()
})
+ it('animates only the latest unresolved model retry', () => {
+ const retryNode = retry(2)
+ const nextRetry = { ...retry(3), turn: 2, retry: 2 }
+ const context = {
+ kind: 'context', seq: 4, time: 4_000, content: [], source: null,
+ } as const satisfies ConversationNode
+ const h = makeHarness({ nodes: [user(1, 'try'), retryNode], running: true })
+ const view = render(
)
+ const disclosure = view.container.querySelector('details')
+ expect(disclosure?.dataset.active).toBe('true')
+ expect(view.getByRole('status').textContent).toBe('正在重试模型请求(1/2) · 1s')
+
+ act(() => {
+ h.set({ nodes: [user(1, 'try'), retryNode, nextRetry] })
+ })
+ expect(view.getAllByRole('status')).toHaveLength(1)
+ expect(view.container.querySelector('details')).toBe(disclosure)
+ expect(view.getByRole('status').textContent).toBe('正在重试模型请求(2/2) · 1s')
+
+ act(() => {
+ h.set({
+ nodes: [
+ user(1, 'try'),
+ retryNode,
+ { ...nextRetry, retryState: 'started' },
+ context,
+ assistant(5, 'done'),
+ ],
+ running: false,
+ })
+ })
+ expect(disclosure?.dataset.active).toBeUndefined()
+ expect(view.getByRole('status').textContent).toBe('已重试模型请求(2/2) · 1s')
+
+ act(() => {
+ h.set({ nodes: [user(1, 'try'), { ...retry(6), retryState: 'cancelled' }], running: true })
+ })
+ expect(disclosure?.dataset.active).toBeUndefined()
+ expect(view.getByRole('status').textContent).toContain('重试已取消')
+ })
+
it('the expanded row Inspect pill hands the call id to inspectCall', () => {
const h = makeHarness({
nodes: [toolResult(3, 'a')],
diff --git a/packages/client/ui-trajectory/README.i18n.yaml b/packages/client/ui-trajectory/README.i18n.yaml
index dcaa1020a7..51ff03c8af 100644
--- a/packages/client/ui-trajectory/README.i18n.yaml
+++ b/packages/client/ui-trajectory/README.i18n.yaml
@@ -3,4 +3,4 @@
# after editing either side, bring the other along and re-record with:
# pnpm run verify-translation-pairing --write packages/client/ui-trajectory/README.md
README.md: b9c8b849b3454fe46e1fc37713d9d3b9449734cf
-README.zh.md: 19ae5050a4c4f7dfe80de0ab58e772e9d26e3f6a
+README.zh.md: 6ddc32f2f27c93f8ccc80b3d9b31d56d3cf4dd94
diff --git a/packages/client/ui-trajectory/README.zh.md b/packages/client/ui-trajectory/README.zh.md
index 19ae5050a4..6ddc32f2f2 100644
--- a/packages/client/ui-trajectory/README.zh.md
+++ b/packages/client/ui-trajectory/README.zh.md
@@ -2,7 +2,7 @@
[English](README.md) | 中文
-Trajectory 渲染按轮次组织的事件记录表,其中可选择用户、助手、工具和嵌套子工具记录。较粗的分割线标示轮次边界,紧凑的行内标记标识步骤,主记录表仅保留索引、事件和内容;选择记录则会打开局部检查器,查看 token 用量、耗时、输入、输出和计时。固定在记录表上方的 Overview 区域从左到右投影记录的真实开始时间与耗时;拖选一个区间会将记录表聚焦到活动区间与该闭区间有重叠的所有记录,清除选择则恢复完整分支。runtime 的独立历史数据源提供原始上下文谱系,并投影因取消而冻结的助手和工具记录,因此 Trajectory 既不读取也不改变 Chat 会话快照。该包(package)保持为纯消费方插件(向会话的 `'conversation.view'` slot 环注册一个视图标签页,不提供服务,也不声明 Context 合并)。契约:api-contracts v3 §8。
+Trajectory 渲染按轮次组织的事件记录表,其中可选择用户、助手、工具和嵌套子工具记录。较粗的分割线标示轮次边界,紧凑的行内标记标识步骤,主记录表仅保留索引、事件和内容;选择记录则会打开局部检查器,查看 token 用量、耗时、输入、输出和计时。固定在记录表上方的 Overview 区域从左到右投影记录的真实开始时间与耗时;拖选一个区间会将记录表聚焦到活动区间与该闭区间有重叠的所有记录,清除选择则恢复完整分支。运行时的独立历史数据源提供原始上下文谱系,并投影因取消而冻结的助手和工具记录,因此 Trajectory 既不读取也不改变 Chat 会话快照。该包(package)保持为纯消费方插件(向会话的 `'conversation.view'` slot 环注册一个视图标签页,不提供服务,也不声明 Context 合并)。契约:api-contracts v3 §8。
## 模型体验
@@ -10,7 +10,7 @@ Trajectory 渲染按轮次组织的事件记录表,其中可选择用户、助
#### KV Cache 影响
-无;该包(package)既不组装也不发送提供方请求。
+无;该包既不组装也不发送提供方请求。
## 已知限制与暂缓事项
diff --git a/packages/llm/llm-retry/README.i18n.yaml b/packages/llm/llm-retry/README.i18n.yaml
index 8a0ecf3434..cf25cdcd4e 100644
--- a/packages/llm/llm-retry/README.i18n.yaml
+++ b/packages/llm/llm-retry/README.i18n.yaml
@@ -2,5 +2,5 @@
# side as of the last confirmed-consistent state. Both languages carry equal authority;
# after editing either side, bring the other along and re-record with:
# pnpm run verify-translation-pairing --write packages/llm/llm-retry/README.md
-README.md: 7a86652a794e70c4dfd00ab7427730387e3ec949
-README.zh.md: c66c04806597c11b5c64dcb01443bb84f489e0f5
+README.md: 8de3ea8c9321f04f5af1b0d7ab361f73eaabc822
+README.zh.md: 978854e9466e271535a10fcea406d0dcb5607285
diff --git a/packages/llm/llm-retry/README.md b/packages/llm/llm-retry/README.md
index 7a86652a79..8de3ea8c93 100644
--- a/packages/llm/llm-retry/README.md
+++ b/packages/llm/llm-retry/README.md
@@ -8,7 +8,7 @@ Each provider adapter owns an optional nested `retryPolicy`, captured when its r
Both modes use bounded exponential backoff with symmetric jitter. A valid `providerRetryAfterMs` at or below `maxDelayMs` replaces local backoff without jitter. An over-cap provider delay makes normal mode delegate, while always mode uses its configured local backoff so it cannot terminate on that instruction.
-Before waiting, the plugin appends a non-surface `llm/retry` event with the provider, mode, canonical resolved-policy key, failure, and scheduled delay. The key includes every behavior-affecting field and sorts normal-mode codes because eligibility uses set membership. Retry numbers continue only across events with the same provider and complete policy key, so a route replacement with different limits, code membership, or backoff starts its own history. Normal events include the finite maximum; always events omit it, and UIs render `∞`. After the wait, the listener returns `{ kind: 'retry' }`, and the loop closes the failed turn and opens a retry turn over the same durable history. Cancellation and plugin disposal abort active backoff, drain active delegated recovery before applying the abort, and make a callback captured before disposal fail closed.
+Before waiting, the plugin appends a non-surface `llm/retry` event with the provider, mode, canonical resolved-policy key, failure, and scheduled delay. Its payload is available from the browser-safe `@deepseek-ai/dsh-llm-retry/types` subpath, so remote renderers can consume the durable status without loading the policy runtime. The key includes every behavior-affecting field and sorts normal-mode codes because eligibility uses set membership. Retry numbers continue only across events with the same provider and complete policy key, so a route replacement with different limits, code membership, or backoff starts its own history. Normal events include the finite maximum; always events omit it, and UIs render `∞`. After the wait, the listener returns `{ kind: 'retry' }`, and the loop closes the failed turn and opens a retry turn over the same durable history. Cancellation and plugin disposal abort active backoff, drain active delegated recovery before applying the abort, and make a callback captured before disposal fail closed.
The separately published `./invariant` companion checks that every retry record names the current open turn and latest closed step, matches the failed request's durable provider, carries non-empty provider and policy identities, has mode-specific bounds, a unique step record, the correct provider-policy retry number, and a bounded timer delay. Full jitter may schedule zero milliseconds at its lower boundary.
diff --git a/packages/llm/llm-retry/README.zh.md b/packages/llm/llm-retry/README.zh.md
index c66c048065..978854e946 100644
--- a/packages/llm/llm-retry/README.zh.md
+++ b/packages/llm/llm-retry/README.zh.md
@@ -8,7 +8,7 @@
两种 mode 都使用带对称 jitter 的有界指数退避。有效 `providerRetryAfterMs` 不超过 `maxDelayMs` 时会替换本地退避,并且不加 jitter。超出上限的提供方延迟会使 normal mode 继续委托;always mode 则改用已配置的本地退避,避免该指令终止重试。
-等待前,插件会追加一条不进入表层的 `llm/retry` 事件,其中包含提供方、mode、已解析策略的规范 key、失败和计划延迟。该 key 包含所有影响行为的字段,并对 normal mode 的 code 排序,因为合格性采用集合成员关系判断。只有提供方与完整策略 key 都相同的事件才会延续重试编号;因此,用限制、code 成员关系或退避不同的路由替换后,会开始自己的历史。normal 事件包含有限上限;always 事件省略该上限,UI 会渲染 `∞`。等待结束后,监听器返回 `{ kind: 'retry' }`,循环关闭失败轮次,并在同一持久历史上开启重试轮次。取消与插件 dispose 会中止活跃退避,在应用中止前排空活跃的委托恢复,并使 dispose 前捕获的 callback 只能以失败结束。
+等待前,插件会追加一条不进入表层的 `llm/retry` 事件,其中包含提供方、mode、已解析策略的规范 key、失败和计划延迟。该载荷由可安全用于浏览器的 `@deepseek-ai/dsh-llm-retry/types` 子路径导出,因此远程渲染器无需加载策略运行时即可使用该持久状态。该 key 包含所有影响行为的字段,并对 normal mode 的 code 排序,因为合格性采用集合成员关系判断。只有提供方与完整策略 key 都相同的事件才会延续重试编号;因此,用限制、code 成员关系或退避不同的路由替换后,会开始自己的历史。normal 事件包含有限上限;always 事件省略该上限,UI 会渲染 `∞`。等待结束后,监听器返回 `{ kind: 'retry' }`,循环关闭失败轮次,并在同一持久历史上开启重试轮次。取消与插件 dispose 会中止活跃退避,在应用中止前排空活跃的委托恢复,并使 dispose 前捕获的 callback 只能以失败结束。
单独发布的 `./invariant` 配套模块会检查每个重试记录是否指向当前开启轮次及其最新已关闭步骤,是否与失败请求的持久提供方匹配,是否携带非空的提供方与策略标识,是否满足 mode 特定边界,是否拥有唯一步骤记录和正确的提供方策略重试编号,以及是否携带有界定时器延迟。完整 jitter 可以在下界调度为零毫秒。
diff --git a/packages/llm/llm-retry/package.json b/packages/llm/llm-retry/package.json
index 854e945491..62dacddcb3 100644
--- a/packages/llm/llm-retry/package.json
+++ b/packages/llm/llm-retry/package.json
@@ -15,11 +15,16 @@
"types": "./lib/types/invariant.d.ts",
"default": "./lib/invariant.js"
},
+ "./types": {
+ "types": "./lib/types/types.d.ts",
+ "default": "./lib/types/types.js"
+ },
"./package.json": "./package.json"
},
"files": [
"lib/index.js",
"lib/invariant.js",
+ "lib/types/**/*.js",
"lib/types/**/*.d.ts",
"lib/types/**/*.d.ts.map",
"src"
diff --git a/packages/llm/llm-retry/src/index.ts b/packages/llm/llm-retry/src/index.ts
index 7871fa8a93..ec435fbe9e 100644
--- a/packages/llm/llm-retry/src/index.ts
+++ b/packages/llm/llm-retry/src/index.ts
@@ -38,6 +38,8 @@ declare module '@deepseek-ai/dsh-session' {
}
}
+export type { LlmRetryEventData } from './types.ts'
+
export const name = 'llm-retry'
export const inject = ['agents']
diff --git a/packages/llm/llm-retry/src/types.ts b/packages/llm/llm-retry/src/types.ts
new file mode 100644
index 0000000000..f59aef4495
--- /dev/null
+++ b/packages/llm/llm-retry/src/types.ts
@@ -0,0 +1,25 @@
+import type { LlmFailure } from '@deepseek-ai/dsh-llm/types'
+
+/** Durable payload recorded before one provider-routed model-request retry wait. */
+export type LlmRetryEventData =
+ | {
+ turn: number
+ step: number
+ provider: string
+ mode: 'normal'
+ policyKey: string
+ retry: number
+ maxRetries: number
+ delayMs: number
+ failure: LlmFailure
+ }
+ | {
+ turn: number
+ step: number
+ provider: string
+ mode: 'always'
+ policyKey: string
+ retry: number
+ delayMs: number
+ failure: LlmFailure
+ }
diff --git a/packages/llm/llm-retry/tests/retry.spec.ts b/packages/llm/llm-retry/tests/retry.spec.ts
index 2a5d51116c..50545d8f21 100644
--- a/packages/llm/llm-retry/tests/retry.spec.ts
+++ b/packages/llm/llm-retry/tests/retry.spec.ts
@@ -12,7 +12,8 @@ import type {
StreamChunk,
} from '@deepseek-ai/dsh-llm'
import SessionStore, { SessionId } from '@deepseek-ai/dsh-session'
-import type { SessionEvent } from '@deepseek-ai/dsh-session'
+import type { SessionEvent, SessionEventMap } from '@deepseek-ai/dsh-session'
+import type { LlmRetryEventData } from '@deepseek-ai/dsh-llm-retry/types'
import SystemPrompt from '@deepseek-ai/dsh-system-prompt'
import ToolRegistry, { defineContentToolFixture } from '@deepseek-ai/dsh-tools'
import AgentRegistry from '@deepseek-ai/dsh-agent'
@@ -22,6 +23,10 @@ import * as retry from '../src/index.ts'
type ScriptEntry = Error | Iterable
| AsyncIterable
+it('keeps the browser-safe retry payload identical to the session event', () => {
+ expectTypeOf().toEqualTypeOf()
+})
+
class ScriptedAdapter extends LlmAdapter {
readonly requests: GenerateOptions[] = []
private retryPolicies: Readonly> = {}
diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml
index 703e0686bf..c244eaa9cd 100644
--- a/pnpm-lock.yaml
+++ b/pnpm-lock.yaml
@@ -1072,6 +1072,9 @@ importers:
'@deepseek-ai/dsh-llm':
specifier: workspace:^
version: link:../../llm/llm
+ '@deepseek-ai/dsh-llm-retry':
+ specifier: workspace:^
+ version: link:../../llm/llm-retry
'@deepseek-ai/dsh-session':
specifier: workspace:^
version: link:../../core/session
@@ -1094,6 +1097,9 @@ importers:
'@deepseek-ai/dsh-invariants':
specifier: workspace:^
version: link:../../support/invariants
+ '@deepseek-ai/dsh-timeout':
+ specifier: workspace:^
+ version: link:../../util/timeout
'@types/react':
specifier: ~18.3.1
version: 18.3.31
diff --git a/scripts/snapshots/translation-prompt-v4/request-response.expected.json b/scripts/snapshots/translation-prompt-v4/request-response.expected.json
index 46782f3dca..1cc80d9286 100644
--- a/scripts/snapshots/translation-prompt-v4/request-response.expected.json
+++ b/scripts/snapshots/translation-prompt-v4/request-response.expected.json
@@ -8,11 +8,11 @@
},
{
"role": "user",
- "content": "# DeepSeek Harness\n\nEnglish | [中文](README.zh.md)\n\nDeepSeek Harness (`dsh`) is an open-source coding agent built on the DeepSeek Harness SDK.\n\nIt uses an architecture where **everything is a plugin**.\n\n## Install\n\nInstall `dsh` with one command:\n\n```sh\ncurl -fsSL https://raw.githubusercontent.com/deepseek-harness/deepseek-harness/master/scripts/install.sh | sh\n```\n\nThe installer requires `git` and Node `^22.19 || >=24`, offers to install `pnpm` when it is missing, and prompts for a DeepSeek API key.\n\nThe installer keeps every checkout under `~/.dsh/source`: the master clone at `~/.dsh/source/master` and each install's staging checkout as a git worktree `~/.dsh/source/staging-`. The stable symlink `~/.dsh/source/current` points at the active staging worktree, and `dsh` in `~/.local/bin` links to `current/bin/dsh`, so an upgrade repoints one symlink and the `dsh` on PATH never moves. Re-running the command adds a fresh staging worktree from an updated master and repoints `current` at it. See [`scripts/install.sh`](scripts/install.sh) for alternate install locations and other options.\n\n## Use DeepSeek Harness\n\n### Web UI\n\nFor the recommended local interface, build the frontend after installation and after each update, then start the Web UI. Resolve the running checkout from the `dsh` launcher so the command holds regardless of which staging worktree is current (the launcher resolves through the stable `current` symlink):\n\n```sh\ndsh_bin=$(cd \"$(dirname \"$(command -v dsh)\")\" && pwd -P)/$(basename \"$(command -v dsh)\")\nwhile [ -L \"$dsh_bin\" ]; do\n link=$(readlink \"$dsh_bin\")\n case $link in /*) dsh_bin=$link ;; *) dsh_bin=$(cd \"$(dirname \"$dsh_bin\")\" && cd \"$(dirname \"$link\")\" && pwd -P)/$(basename \"$link\") ;; esac\ndone\ndsh_dir=$(cd \"$(dirname \"$dsh_bin\")/..\" && pwd -P)\npnpm --dir \"$dsh_dir\" run build && pnpm --dir \"$dsh_dir\" run build:web\ndsh web\n```\n\nThe Web UI is served at `http://127.0.0.1:3080` by default.\n\n### TUI\n\nStart the full-screen terminal interface:\n\n```sh\ndsh\n```\n\n### Headless\n\nRun one task, print the final answer, and exit:\n\n```sh\ndsh -p \"summarize this workspace\"\n```\n\n## Why DeepSeek Harness\n\nBuilt-in capabilities cover file reading, editing, and search; shell execution; reusable skills; task tracking; subagents and workflows; persistent sessions; and context compaction. The TUI also includes Plan Mode.\n\n- **Everything is a plugin.** Models, tools, policies, storage, context management, and interfaces are composable [Cordis plugins](docs/user/develop/basic/index.md), so deployments can extend or replace behavior without forking the agent loop. See the [architecture](docs/architecture.md) for the underlying design.\n- **Code Mode (opt-in).** It exposes a `run_code` tool and a generated TypeScript SDK; only program output re-enters model context. See [Code Mode](packages/core/tools/README.md#code-mode).\n- **Self-referential Cordis tools are opt-in.** They let the agent inspect its live runtime and mount or unmount plugins while it runs. See the [Cordis tools](packages/cordis/tool-cordis/README.md).\n\n## Community\n\nFollow DeepSeek Harness on Twitter for project updates.\n\n## Development\n\n```sh\npnpm install\npnpm run test:coverage\n```\n\nStart with the [development guide](docs/development.md) and read the [architecture](docs/architecture.md) before changing packages.\n\nFor agents, follow [AGENTS.md](AGENTS.md).\n\nDeepSeek Harness is currently pre-release.\n\n## License\n\n[BSD 3-Clause](LICENSE)\n"
+ "content": "# DeepSeek Harness\n\nEnglish | [中文](README.zh.md)\n\nDeepSeek Harness (`dsh`) is an open-source coding agent built on the DeepSeek Harness SDK.\n\nIt uses an architecture where **everything is a plugin**.\n\n## Internal testing notice\n\nThank you for making time to try DeepSeek Harness.\n\nThis version is still in internal testing. Some features remain unfinished, and parts of the experience may feel rough.\n\n“As one cuts and files, as one carves and polishes.” Products grow through repeated encounters with real use and candid feedback. The problems you uncover in practice may lead us to re-examine, or even discard, existing designs.\n\nWe especially want to hear about moments of failure, confusion, or friction. If DeepSeek Harness does not help—or instead makes your work harder—please leave a message in our WeCom group and tell us about your experience. Every report will help us refine it.\n\n## Install\n\nInstall `dsh` with one command:\n\n```sh\ncurl -fsSL https://raw.githubusercontent.com/deepseek-harness/deepseek-harness/master/scripts/install.sh | sh\n```\n\nThe installer requires `git` and Node `^22.19 || >=24`, offers to install `pnpm` when it is missing, and prompts for a DeepSeek API key.\n\nThe installer keeps every checkout under `~/.dsh/source`: the master clone at `~/.dsh/source/master` and each install's staging checkout as a git worktree `~/.dsh/source/staging-`. The stable symlink `~/.dsh/source/current` points at the active staging worktree, and `dsh` in `~/.local/bin` links to `current/bin/dsh`, so an upgrade repoints one symlink and the `dsh` on PATH never moves. Re-running the command adds a fresh staging worktree from an updated master and repoints `current` at it. See [`scripts/install.sh`](scripts/install.sh) for alternate install locations and other options.\n\n## Use DeepSeek Harness\n\n### Web UI\n\nFor the recommended local interface, build the active checkout after installation and after each update, then start the Web UI:\n\n```sh\n(cd ~/.dsh/source/current && pnpm run build)\ndsh web\n```\n\nThe full build produces the library and client bundles plus the frontend dist. The path above is the installer's default. If you set `DSH_SOURCE` or `DSH_CURRENT`, or reused an existing checkout, replace `~/.dsh/source/current` with that checkout path; see [`scripts/install.sh`](scripts/install.sh) for details. The Web UI is served at `http://127.0.0.1:3080` by default.\n\n### TUI\n\nStart the full-screen terminal interface:\n\n```sh\ndsh\n```\n\n### Headless\n\nRun one task, print the final answer, and exit:\n\n```sh\ndsh -p \"summarize this workspace\"\n```\n\n### Automation and SDKs\n\nFrom a source checkout with `DEEPSEEK_API_KEY` in the environment or its root `.env`, start the ACP automation server:\n\n```sh\npnpm run demo:acp\n```\n\nThe [Python SDK](python/README.md) drives a bundled JSON-RPC runtime. The [examples](examples/README.md) cover the runnable headless, ACP, JSON-RPC, Code Mode, and self-referential compositions.\n\n## Why DeepSeek Harness\n\nBuilt-in capabilities cover file reading, editing, and search; shell and persistent PTY execution; reusable skills; task tracking, goals, plans, todos, and background tasks; subagents and workflows; sandboxing and approvals; settings and credentials; persistent, resumable, forkable, and queryable sessions; LSP and web access; context compaction; and telemetry. Each composition selects the subset appropriate to its surface. The TUI and Web UI both include Plan Mode.\n\n- **Everything is a plugin.** Models, tools, policies, storage, context management, and interfaces are composable [Cordis plugins](docs/user/develop/basic/index.md), so deployments can extend or replace behavior without forking the agent loop. See the [architecture](docs/architecture.md) for the underlying design.\n- **Runs are reconstructable.** Anything visible to the model is logged in the authoritative session stream; persistence, resume/fork/query, replay, telemetry, and UIs derive from the same events. See the [session-log architecture](docs/architecture.md#session-log).\n- **Code Mode (opt-in).** It exposes a `run_code` tool and a generated TypeScript SDK; only program output re-enters model context. See [Code Mode](packages/core/tools/README.md#code-mode).\n- **Self-referential Cordis tools are opt-in.** They let the agent inspect its live runtime and mount or unmount plugins while it runs. See the [Cordis tools](packages/cordis/tool-cordis/README.md).\n\n## Community\n\nFollow DeepSeek Harness on Twitter for project updates.\n\n## Development\n\n```sh\npnpm install\npnpm run test:coverage\n```\n\nStart with the [development guide](docs/development.md) and read the [architecture](docs/architecture.md) before changing packages.\n\nFor agents, follow [AGENTS.md](AGENTS.md).\n\nDeepSeek Harness is currently in internal testing.\n\n## License\n\n[BSD 3-Clause](LICENSE)\n"
},
{
"role": "assistant",
- "content": "# DeepSeek Harness\n\n[English](README.md) | 中文\n\nDeepSeek Harness(`dsh`)是一款基于 DeepSeek Harness SDK 构建的开源 coding agent(编程智能体)。\n\n它采用了**一切皆插件**的架构。\n\n## 安装\n\n使用一条命令安装 `dsh`:\n\n```sh\ncurl -fsSL https://raw.githubusercontent.com/deepseek-harness/deepseek-harness/master/scripts/install.sh | sh\n```\n\n安装器要求系统已安装 `git` 和 Node `^22.19 || >=24`,缺少 `pnpm` 时可代为安装,并会提示输入 DeepSeek API 密钥。\n\n安装器会把所有检出都放在 `~/.dsh/source` 下:master 克隆位于 `~/.dsh/source/master`,每次安装的 staging 检出是一个 git worktree `~/.dsh/source/staging-<时间戳>`。稳定符号链接 `~/.dsh/source/current` 指向当前生效的 staging worktree,`~/.local/bin` 中的 `dsh` 链接到 `current/bin/dsh`,因此升级只需重指一个符号链接,PATH 上的 `dsh` 从不移动。再次运行该命令会基于更新后的 master 新增一个 staging worktree,并把 `current` 重指到它。其他安装位置和选项见 [`scripts/install.sh`](scripts/install.sh)。\n\n## 使用 DeepSeek Harness\n\n### Web UI\n\n推荐在本地使用 Web UI。安装完成后以及每次更新后,请先构建前端,再启动 Web UI。通过 `dsh` 启动器解析当前运行的检出,这样无论当前是哪个 staging worktree,命令都成立(启动器会经由稳定的 `current` 符号链接解析):\n\n```sh\ndsh_bin=$(cd \"$(dirname \"$(command -v dsh)\")\" && pwd -P)/$(basename \"$(command -v dsh)\")\nwhile [ -L \"$dsh_bin\" ]; do\n link=$(readlink \"$dsh_bin\")\n case $link in /*) dsh_bin=$link ;; *) dsh_bin=$(cd \"$(dirname \"$dsh_bin\")\" && cd \"$(dirname \"$link\")\" && pwd -P)/$(basename \"$link\") ;; esac\ndone\ndsh_dir=$(cd \"$(dirname \"$dsh_bin\")/..\" && pwd -P)\npnpm --dir \"$dsh_dir\" run build && pnpm --dir \"$dsh_dir\" run build:web\ndsh web\n```\n\nWeb UI 默认通过 `http://127.0.0.1:3080` 提供服务。\n\n### TUI\n\n启动全屏终端界面:\n\n```sh\ndsh\n```\n\n### Headless\n\n运行一项任务,打印最终答案后退出:\n\n```sh\ndsh -p \"summarize this workspace\"\n```\n\n## 为什么选择 DeepSeek Harness\n\n内置功能涵盖文件读取、编辑与搜索、shell 执行、可复用 skill(技能)、任务跟踪、subagent 与工作流、持久化会话,以及上下文压缩(context compaction)。TUI 还包含 Plan Mode。\n\n- **一切皆插件。** 模型、工具、策略、存储、上下文管理和界面均可组合为 [Cordis 插件](docs/user/develop/basic/index.md),部署方无需 fork agent loop(智能体循环)即可扩展或替换行为。底层设计见[架构文档](docs/architecture.md)。\n- **Code Mode(需显式启用)。** 它会提供 `run_code` 工具和生成的 TypeScript SDK,只有程序输出会重新进入模型上下文。参见 [Code Mode](packages/core/tools/README.md#code-mode)。\n- **自指 Cordis 工具需显式启用。** 这些工具可让 agent 检查自身的实时运行时,并在运行中挂载或卸载插件。参见 [Cordis 工具](packages/cordis/tool-cordis/README.md)。\n\n## 社区\n\n扫描二维码,或打开 DeepSeek Harness 微信社区申请页面 申请加入。\n\n\n
\n
\n\n## 开发\n\n```sh\npnpm install\npnpm run test:coverage\n```\n\n请先阅读[开发指南](docs/development.md);修改包之前,请阅读[架构文档](docs/architecture.md)。\n\n面向 agent:遵循 [AGENTS.md](AGENTS.md)。\n\nDeepSeek Harness 目前处于预发布阶段。\n\n## 许可证\n\n[BSD 3-Clause](LICENSE)\n"
+ "content": "# DeepSeek Harness\n\n[English](README.md) | 中文\n\nDeepSeek Harness(`dsh`)是一款基于 DeepSeek Harness SDK 构建的开源 coding agent(编程智能体)。\n\n它采用了**一切皆插件**的架构。\n\n## 内测声明\n\n感谢您愿意拨冗试用 DeepSeek Harness。\n\n目前的版本仍处于内部测试阶段,有些功能仍待完善,有些体验难免粗粝。\n\n“如切如磋,如琢如磨。”产品的成长,离不开一次次真实的碰撞与坦诚的反馈。您在真实使用中暴露的问题,也可能促使我们重新审视,甚至推翻已有的设计。\n\n我们尤其希望听见那些失败、困惑与不顺手的时刻——如果它未能帮到您,甚至反而为工作平添了麻烦,请在企业微信群中留言,将使用感受告诉我们。每一条反馈,都会帮助我们把它打磨得更好。\n\n## 安装\n\n使用一条命令安装 `dsh`:\n\n```sh\ncurl -fsSL https://raw.githubusercontent.com/deepseek-harness/deepseek-harness/master/scripts/install.sh | sh\n```\n\n安装器要求系统已安装 `git` 和 Node `^22.19 || >=24`,缺少 `pnpm` 时可代为安装,并会提示输入 DeepSeek API 密钥。\n\n安装器会把所有检出都放在 `~/.dsh/source` 下:master 克隆位于 `~/.dsh/source/master`,每次安装的 staging 检出是一个 git worktree `~/.dsh/source/staging-<时间戳>`。稳定符号链接 `~/.dsh/source/current` 指向当前生效的 staging worktree,`~/.local/bin` 中的 `dsh` 链接到 `current/bin/dsh`,因此升级只需重指一个符号链接,PATH 上的 `dsh` 从不移动。再次运行该命令会基于更新后的 master 新增一个 staging worktree,并把 `current` 重指到它。其他安装位置和选项见 [`scripts/install.sh`](scripts/install.sh)。\n\n## 使用 DeepSeek Harness\n\n### Web UI\n\n推荐在本地使用 Web UI。安装完成后以及每次更新后,请先构建当前生效的检出,再启动 Web UI:\n\n```sh\n(cd ~/.dsh/source/current && pnpm run build)\ndsh web\n```\n\n完整构建会生成库与客户端 bundle,以及前端 dist。上述路径是安装器的默认位置。如果你设置过 `DSH_SOURCE` 或 `DSH_CURRENT`,或者复用了已有检出,请把 `~/.dsh/source/current` 换成该检出路径;详情见 [`scripts/install.sh`](scripts/install.sh)。Web UI 默认通过 `http://127.0.0.1:3080` 提供服务。\n\n### TUI\n\n启动全屏终端界面:\n\n```sh\ndsh\n```\n\n### Headless\n\n运行一项任务,打印最终答案后退出:\n\n```sh\ndsh -p \"summarize this workspace\"\n```\n\n### 自动化与 SDK\n\n在源码检出中通过环境变量或根目录 `.env` 设置 `DEEPSEEK_API_KEY`,然后启动 ACP(Agent Client Protocol)自动化服务器:\n\n```sh\npnpm run demo:acp\n```\n\n[Python SDK](python/README.md) 驱动随附的 JSON-RPC 运行时。[示例](examples/README.md)涵盖可运行的 headless、ACP、JSON-RPC、Code Mode 和自指组合。\n\n## 为什么选择 DeepSeek Harness\n\n内置功能涵盖文件读取、编辑与搜索、shell 和持久 PTY 执行、可复用 skill(技能)、任务跟踪、目标、计划、待办事项与后台任务、subagent 与工作流、沙箱与审批、设置与凭据、可持久化、恢复、fork 与查询的会话、LSP 与 Web 访问、上下文压缩(context compaction),以及遥测。每个组合只选用适合其使用方式的能力子集。TUI 与 Web UI 均包含 Plan Mode。\n\n- **一切皆插件。** 模型、工具、策略、存储、上下文管理和界面均可组合为 [Cordis 插件](docs/user/develop/basic/index.md),部署方无需 fork agent loop(智能体循环)即可扩展或替换行为。底层设计见[架构文档](docs/architecture.md)。\n- **运行可重建。** 凡是模型可见的内容,都会记录在权威会话流中;持久化、恢复/fork/查询、回放、遥测和 UI 均从同一组事件派生。参见[会话日志架构](docs/architecture.md#session-log)。\n- **Code Mode(需显式启用)。** 它会提供 `run_code` 工具和生成的 TypeScript SDK,只有程序输出会重新进入模型上下文。参见 [Code Mode](packages/core/tools/README.md#code-mode)。\n- **自指 Cordis 工具需显式启用。** 这些工具可让 agent 检查自身的实时运行时,并在运行中挂载或卸载插件。参见 [Cordis 工具](packages/cordis/tool-cordis/README.md)。\n\n## 社区\n\n扫描二维码,或打开 DeepSeek Harness 微信社区申请页面 申请加入。\n\n\n
\n
\n\n## 开发\n\n```sh\npnpm install\npnpm run test:coverage\n```\n\n请先阅读[开发指南](docs/development.md);修改包之前,请阅读[架构文档](docs/architecture.md)。\n\n面向 agent:遵循 [AGENTS.md](AGENTS.md)。\n\nDeepSeek Harness 目前处于内测阶段。\n\n## 许可证\n\n[BSD 3-Clause](LICENSE)\n"
},
{
"role": "user",
diff --git a/tsconfig.base.json b/tsconfig.base.json
index f902aa21a5..37216a839e 100644
--- a/tsconfig.base.json
+++ b/tsconfig.base.json
@@ -56,6 +56,7 @@
"@deepseek-ai/dsh-goal/client": ["./packages/goal/goal/src/client.ts"],
"@deepseek-ai/dsh-llm/types": ["./packages/llm/llm/src/types.ts"],
"@deepseek-ai/dsh-llm/brand": ["./packages/llm/llm/src/brand.ts"],
+ "@deepseek-ai/dsh-llm-retry/types": ["./packages/llm/llm-retry/src/types.ts"],
"@deepseek-ai/dsh-llm/message": ["./packages/llm/llm/src/message.ts"],
"@deepseek-ai/dsh-commands/brand": ["./packages/ui/commands/src/brand.ts"],
"@deepseek-ai/dsh-tui/prompt": ["./packages/ui/tui/src/prompt.ts"],