From a43020742719b2bbf94334b9c42e3f46097314a0 Mon Sep 17 00:00:00 2001 From: Yichen Jiang Date: Sun, 26 Jul 2026 14:09:31 +0800 Subject: [PATCH 1/2] feat(web): retry transient model requests --- ...2026-06-21-bounded-llm-request-recovery.md | 8 +- apps/cli/README.md | 2 +- apps/cli/cordis.yml | 3 + apps/cli/package.json | 1 + apps/web/tests/session-title.snapshot.ts | 75 ++++++++++++- apps/web/tests/smoke-real.e2e.ts | 92 ++++++++++++++++ apps/web/tests/snapshots/model-retry.json | 27 +++++ docs/config-catalog.md | 2 +- .../client/connection/src/client/fixture.ts | 57 ++++++++++ .../client/connection/tests/fixture.spec.ts | 8 ++ packages/client/runtime/README.md | 4 + packages/client/runtime/package.json | 1 + packages/client/runtime/src/client/index.ts | 2 +- .../src/client/sessions/conversation.ts | 18 +++- .../runtime/src/client/sessions/session.ts | 91 ++++++++++++---- packages/client/runtime/tests/event-script.ts | 16 +++ packages/client/runtime/tests/session.spec.ts | 66 ++++++++++++ packages/client/runtime/tsconfig.json | 3 + packages/client/ui-conversation/README.md | 2 + .../src/client/chat/ChatView.tsx | 20 +++- .../src/client/chat/MessageItem.module.css | 100 ++++++++++++++++++ .../src/client/chat/MessageItem.tsx | 64 +++++++++-- .../src/client/chat/chat-flow.ts | 17 ++- .../tests/chat-branch-tails.spec.tsx | 79 +++++++++++++- .../ui-conversation/tests/chat-view.spec.tsx | 43 +++++++- packages/llm/llm-retry/README.md | 2 +- packages/llm/llm-retry/package.json | 5 + packages/llm/llm-retry/src/index.ts | 2 + packages/llm/llm-retry/src/types.ts | 11 ++ packages/llm/llm-retry/tests/retry.spec.ts | 9 +- pnpm-lock.yaml | 6 ++ tsconfig.base.json | 1 + 32 files changed, 791 insertions(+), 46 deletions(-) create mode 100644 apps/web/tests/snapshots/model-retry.json create mode 100644 packages/llm/llm-retry/src/types.ts diff --git a/.agents/notes/implemented/architecture/2026-06-21-bounded-llm-request-recovery.md b/.agents/notes/implemented/architecture/2026-06-21-bounded-llm-request-recovery.md index 932318da4f..3ec72eb19a 100644 --- a/.agents/notes/implemented/architecture/2026-06-21-bounded-llm-request-recovery.md +++ b/.agents/notes/implemented/architecture/2026-06-21-bounded-llm-request-recovery.md @@ -68,11 +68,11 @@ For an eligible failure with budget remaining, the one-based transient retry cou The plugin owns a lifetime `AbortController` and tracks every active backoff callback. Each wait fuses the waterfall's turn signal with that lifetime signal. Effect cleanup first unregisters the listener, then aborts and awaits the active callbacks; a captured callback whose lifetime signal aborts returns `fail` and can neither retry nor enter the rest of its captured waterfall after disposal. This makes HMR disposal quiescent even though Cordis has already captured the listener. -Before sleeping, `dsh-llm-retry` appends one non-surface `llm/retry` session event containing the turn, failed step, one-based transient retry number, configured maximum, scheduled delay, and `LlmFailure`. The plugin owns the `SessionEventMap` augmentation; `dsh-session` remains generic persistence and does not absorb the optional policy's vocabulary. The event says what was scheduled, not that the next request completed; cancellation during the delay is subsequently visible on `turn/end`. The event ships only with a production renderer and replay/snapshot coverage, because its purpose is operational state rather than trace collection. +Before sleeping, `dsh-llm-retry` appends one non-surface `llm/retry` session event containing the turn, failed step, one-based transient retry number, configured maximum, scheduled delay, and `LlmFailure`. The plugin owns the `SessionEventMap` augmentation and exports the payload through its browser-safe `./types` subpath; `dsh-session` remains generic persistence and does not absorb the optional policy's vocabulary. The event says what was scheduled, not that the next request completed; cancellation during the delay is subsequently visible on `turn/end`. The event ships with production renderers and replay/snapshot coverage, because its purpose is operational state rather than trace collection. The listener calls `next()` for a non-transient code, an exhausted policy budget, or an over-cap provider delay. This preserves composition with context-overflow recovery and later policy plugins. It returns `{ action: 'retry' }` only after the delay completes under both signals; turn cancellation and plugin disposal return `fail`, after which the loop's cancellation/disposal checks remain authoritative. -The agent-spine demo bundle loads the plugin so the shared stdio/TUI, one-shot CLI, and ACP example compositions use the same bounded policy. Library consumers retain explicit plugin composition: omitting the plugin leaves `agent/request-error` at its current fail default. +The agent-spine demo bundle loads the plugin so the shared stdio/TUI, one-shot CLI, and ACP example compositions use the same bounded policy. The shipped Web/headless composition also loads it, so browser and command-line requests share the TUI defaults. Library consumers retain explicit plugin composition: omitting the plugin leaves `agent/request-error` at its current fail default. ### Make one layer own visible attempts @@ -90,7 +90,7 @@ Boundary tests prove termination at both actual transports. The hand-written ada ### Keep attempts separate in the existing log -A failed attempt may leave `assistant/chunk` events in its closed step, but it never appends `assistant/message` and never dispatches a tool. A retry opens the next numbered step, reconstructs the request from the durable surface, and produces its own chunks. UIs may render live chunks while a step is open, then mark or clear that transient view when `llm/retry` identifies the failed step or `turn/end` records terminal failure; message derivation continues to ignore the failed chunks. +A failed attempt may leave `assistant/chunk` events in its closed step, but it never appends `assistant/message` and never dispatches a tool. A retry opens the next numbered step, reconstructs the request from the durable surface, and produces its own chunks. TUI and Web render live chunks while a step is open, then clear that transient view and retain replayable status when `llm/retry` identifies the failed step. Web projects consecutive same-turn retry events into one stable row updated to the latest attempt, counts its delay down in ceiling-rounded seconds with a one-second floor, animates only while unresolved, and keeps exact latest failure details collapsed behind the row. Message derivation continues to ignore the failed chunks, and Web applies the same projection during history rebuild so refreshing cannot resurrect discarded partials or duplicate retry rows. If recovery is exhausted, the final failure is stored once on `turn/end.reason` with the structured facts. If transient recovery continues, `llm/retry` is the durable home for that attempt's failure and delay. No standalone final-error event or response-id vocabulary is added. @@ -124,7 +124,7 @@ If recovery is exhausted, the final failure is stored once on `turn/end.reason` - Pure unit tests cover transient-code selection, exponential backoff and jitter bounds, valid and over-cap `Retry-After`, exhausted budgets, deterministic timer/random seams, and abort during backoff. - Real agent-loop tests cover failure before chunks, partial chunks then failure, thrown and in-band failures, retry to success in a new step, exhaustion to structured `turn/end.reason`, and composition with `dsh-compact-basic` context-overflow recovery. - The partial-chunk integration test proves failed chunks remain attributed to the failed step, no assistant message or tool side effect is committed for that step, and the successful retry has distinct provenance. -- The plugin-owned `llm/retry` event is non-surface, survives JSONL and SQLite round trips, is ignored by message derivation, and drives TUI retraction plus scheduled-retry rendering. Keyless snapshots cover scheduling, cancellation, success, and exhaustion; ACP automation snapshots confirm that a discarded attempt stays off the wire while the recovered reply is emitted. +- The plugin-owned `llm/retry` event is non-surface, survives JSONL and SQLite round trips, is ignored by message derivation, and drives TUI and Web retraction plus scheduled-retry rendering. Keyless UI snapshots cover Web scheduling and success, a real Web composition test covers partial transport failure through recovery, and ACP automation snapshots confirm that a discarded attempt stays off the wire while the recovered reply is emitted. - Idle-watchdog tests prove the stable signal is rearmed only while `next()` is outstanding, disarmed during consumer think time and in `finally`, and classified separately from a total-call deadline and an earlier caller abort; adapter tests prove the signal stops the underlying request rather than merely detaching it. - Direct `ctx.llm.stream()` callers remain single-attempt and receive the same structured failure facts. diff --git a/apps/cli/README.md b/apps/cli/README.md index 2afd9c346b..301544d1ab 100644 --- a/apps/cli/README.md +++ b/apps/cli/README.md @@ -10,7 +10,7 @@ The TUI surface: - tells the agent where its own source lives: after boot it adds a prompt section naming this harness checkout, resolved from the launcher's real path so it holds under a PATH symlink and an arbitrary cwd, so the self-referential `cordis` toolset can read and modify it; - applies the personal overlay from `~/.dsh` (see [app-boot's Personal config](../../packages/ui/app-boot/README.md#personal-config)): `.env` fills environment gaps (ambient > project `.env` > personal `.env`), `config.yaml` patches the booted tree. -The Web and headless surfaces boot one shared composition (`cordis.yml`): both treat the invoking directory as the default project and Workspace root, create named Workspaces beneath that root unless `--workspace-root ` overrides it, load applicable `AGENTS.md`/`CLAUDE.md` instructions into each agent-loop request prefix with a 65,536-byte render budget, and opt into first-message model titles. Headless differs only in listening on an OS-assigned port (parallel `dsh -p` runs never collide; the stderr-printed URL opens the live session in a browser). Both need the frontend dist and client bundles built (`pnpm run build && pnpm run build:web`). +The Web and headless surfaces boot one shared composition (`cordis.yml`): both treat the invoking directory as the default project and Workspace root, create named Workspaces beneath that root unless `--workspace-root ` overrides it, load applicable `AGENTS.md`/`CLAUDE.md` instructions into each agent-loop request prefix with a 65,536-byte render budget, opt into first-message model titles, and use the same bounded transient model-request retry policy as the TUI. Headless differs only in listening on an OS-assigned port (parallel `dsh -p` runs never collide; the stderr-printed URL opens the live session in a browser). Both need the frontend dist and client bundles built (`pnpm run build && pnpm run build:web`). ## Install (developer machine) diff --git a/apps/cli/cordis.yml b/apps/cli/cordis.yml index b89df77c46..4e42b1c725 100644 --- a/apps/cli/cordis.yml +++ b/apps/cli/cordis.yml @@ -59,6 +59,9 @@ config: agents: [] +- id: llm-retry + name: '@deepseek-ai/dsh-llm-retry' + # The native DeepSeek adapter; reads the key/base-url the boot's layered # .env loading (cwd then $DSH_HOME) left in the environment. - id: llm-deepseek diff --git a/apps/cli/package.json b/apps/cli/package.json index e1c07f90b5..6cf696f542 100644 --- a/apps/cli/package.json +++ b/apps/cli/package.json @@ -41,6 +41,7 @@ "@deepseek-ai/dsh-host-webserver": "workspace:^", "@deepseek-ai/dsh-llm": "workspace:^", "@deepseek-ai/dsh-llm-deepseek": "workspace:^", + "@deepseek-ai/dsh-llm-retry": "workspace:^", "@deepseek-ai/dsh-paths": "workspace:^", "@deepseek-ai/dsh-session": "workspace:^", "@deepseek-ai/dsh-session-persistence-jsonl": "workspace:^", diff --git a/apps/web/tests/session-title.snapshot.ts b/apps/web/tests/session-title.snapshot.ts index c1616bb724..c24fd12a63 100644 --- a/apps/web/tests/session-title.snapshot.ts +++ b/apps/web/tests/session-title.snapshot.ts @@ -25,6 +25,9 @@ const bundles = new Map(PLUGINS.map(plugin => [ interface FixtureTiming { appendTitle(id: string, title: string): void + beginModelRetry(id: string): void + scheduleModelRetry(id: string, retry?: number, delayMs?: number): void + completeModelRetry(id: string): void } interface FixtureWindow extends Window { @@ -78,7 +81,7 @@ function titleSurfaces(label: string): { sidebar: string; breadcrumb: string; do return { sidebar, breadcrumb, documentTitle: document.title } } -it('projects initial and revised durable titles through the built nine-plugin fixture app', async () => { +function bootFixtureApp(): void { const root = document.querySelector('#root') if (root === null) throw new Error('snapshot root missing') act(() => { @@ -92,18 +95,26 @@ it('projects initial and revised durable titles through the built nine-plugin fi void entry.run() unmount = () => { entry.dispose() } }) +} +async function selectFixtureSession(): Promise { const tree = await screen.findByRole('tree', { name: 'Sessions' }, { timeout: 10_000 }) const projectCount = await within(tree).findByText('4 sessions') const projectRow = projectCount.closest('[role="treeitem"]') if (projectRow === null) throw new Error('fixture project row missing') fireEvent.click(projectRow) - const initialLabel = 'Fixture 历史会话' - const initialRowLabel = await screen.findByText(initialLabel) + const initialRowLabel = await screen.findByText('Fixture 历史会话') const initialRow = initialRowLabel.closest('[role="treeitem"]') if (initialRow === null) throw new Error('fixture session row missing') fireEvent.click(initialRow) +} + +it('projects initial and revised durable titles through the built nine-plugin fixture app', async () => { + bootFixtureApp() + await selectFixtureSession() + + const initialLabel = 'Fixture 历史会话' await waitFor(() => { expect(document.title).toBe(`${initialLabel} — DeepSeek Harness`) }) const initial = titleSurfaces(initialLabel) @@ -116,3 +127,61 @@ it('projects initial and revised durable titles through the built nine-plugin fi await expect(`${JSON.stringify({ initial, revised }, null, 2)}\n`) .toMatchFileSnapshot('./snapshots/session-title.json') }) + +it('retracts a failed stream at llm/retry and retains the durable notice after recovery', async () => { + bootFixtureApp() + await selectFixtureSession() + const timing = (globalThis as Record).__fxTiming as FixtureTiming + + act(() => { timing.beginModelRetry('fx-alpha') }) + const partial = await screen.findByText('应撤回的半截回复') + const beforeRetry = { partial: partial.textContent } + + act(() => { timing.scheduleModelRetry('fx-alpha') }) + const firstNotice = await screen.findByRole('status') + await waitFor(() => { expect(screen.queryByText('应撤回的半截回复')).toBeNull() }) + const disclosure = firstNotice.closest('details') + if (disclosure === null) throw new Error('retry disclosure missing') + const firstRetry = { + notice: firstNotice.textContent, + rows: screen.getAllByRole('status').length, + } + + act(() => { timing.scheduleModelRetry('fx-alpha', 2, 1_500) }) + const notice = screen.getByRole('status') + await waitFor(() => { expect(notice.textContent).toContain('(2/2)') }) + const latestDisclosure = notice.closest('details') + const summary = notice.closest('summary') + if (latestDisclosure === null || summary === null) throw new Error('latest retry disclosure missing') + await waitFor(() => { expect(screen.queryByText('第 2 次应撤回的回复')).toBeNull() }) + const scheduled = { + partialVisible: screen.queryByText('应撤回的半截回复') !== null + || screen.queryByText('第 2 次应撤回的回复') !== null, + notice: notice.textContent, + rows: screen.getAllByRole('status').length, + reusedDisclosure: latestDisclosure === disclosure, + detailsOpen: latestDisclosure.open, + animated: latestDisclosure.dataset.active === 'true', + } + fireEvent.click(summary) + const expanded = { + detailsOpen: latestDisclosure.open, + delay: screen.getByText('重试延迟:').parentElement?.textContent, + failure: screen.getByText('失败原因:').parentElement?.textContent, + } + + act(() => { timing.completeModelRetry('fx-alpha') }) + const recovered = await screen.findByText('重试后的完整回复') + await waitFor(() => { expect(screen.getByRole('status').textContent).toContain('已重试') }) + const completedNotice = screen.getByRole('status') + const completedDisclosure = completedNotice.closest('details') + if (completedDisclosure === null) throw new Error('completed retry disclosure missing') + const completed = { + recovered: recovered.textContent, + retryNoticeStillVisible: completedNotice.textContent, + animated: completedDisclosure.dataset.active === 'true', + } + + await expect(`${JSON.stringify({ beforeRetry, firstRetry, scheduled, expanded, completed }, null, 2)}\n`) + .toMatchFileSnapshot('./snapshots/model-retry.json') +}) diff --git a/apps/web/tests/smoke-real.e2e.ts b/apps/web/tests/smoke-real.e2e.ts index a3d511df16..eb6ae6d26a 100644 --- a/apps/web/tests/smoke-real.e2e.ts +++ b/apps/web/tests/smoke-real.e2e.ts @@ -271,6 +271,98 @@ describe('dsh web keyless CLI smoke', () => { rmSync(workspace, { recursive: true, force: true }) } }) + + it('retries a partial transport failure through the shipped Web composition', async () => { + requireDist() + const workspace = mkdtempSync(join(tmpdir(), 'dsh-web-retry-')) + const promptMarker = 'WEB_RETRY_REQUEST' + const recoveredMarker = 'WEB_RETRY_RECOVERED' + let mainAttempts = 0 + const provider = createServer((request, response) => { + let body = '' + request.setEncoding('utf8') + request.on('data', (chunk: string) => { body += chunk }) + request.on('end', () => { + const parsed = JSON.parse(body) as { max_tokens?: number; messages?: unknown[] } + const titleRequest = parsed.max_tokens === 64 + const mainRequest = !titleRequest && body.includes(promptMarker) + response.writeHead(200, { 'content-type': 'text/event-stream' }) + if (!mainRequest) { + response.end([ + 'data: {"choices":[{"delta":{"content":"Web retry title"}}]}', + 'data: {"choices":[{"delta":{"content":""},"finish_reason":"stop"}],"usage":{"prompt_tokens":1,"completion_tokens":1}}', + 'data: [DONE]', + '', + ].join('\n\n')) + return + } + mainAttempts++ + if (mainAttempts === 1) { + response.write('data: {"choices":[{"delta":{"content":"WEB_RETRY_DISCARDED"}}]}\n\n') + setTimeout(() => { response.destroy() }, 20) + return + } + response.end([ + `data: {"choices":[{"delta":{"content":"${recoveredMarker}"}}]}`, + 'data: {"choices":[{"delta":{"content":""},"finish_reason":"stop"}],"usage":{"prompt_tokens":3,"completion_tokens":1}}', + 'data: [DONE]', + '', + ].join('\n\n')) + }) + }) + await new Promise(resolve => provider.listen(0, '127.0.0.1', resolve)) + const address = provider.address() + if (address === null || typeof address === 'string') throw new Error('mock provider did not bind a TCP port') + const tsxLoader = pathToFileURL(createRequire(join(REPO_ROOT, 'package.json')).resolve('tsx')).href + const child = spawn( + process.execPath, + ['--import', tsxLoader, join(REPO_ROOT, 'apps/cli/src/bin.ts'), 'web', '--port', '0'], + { + cwd: workspace, + env: { + ...process.env, + DEEPSEEK_API_KEY: 'keyless-web-retry', + DEEPSEEK_BASE_URL: `http://127.0.0.1:${address.port}`, + DSH_HOME: join(workspace, '.dsh'), + TSX_TSCONFIG_PATH: join(REPO_ROOT, 'tsconfig.json'), + }, + stdio: ['ignore', 'pipe', 'pipe'], + }, + ) + try { + const baseUrl = await waitForReadyLine(child) + const created = await rpc<{ sessionId: string }>(baseUrl, 'session.create', {}) + await rpc<{ accepted: true }>(baseUrl, 'session.prompt', { + sessionId: created.sessionId, + mode: 'queue', + content: [{ type: 'text', text: promptMarker }], + }) + let page: HistoryPage | undefined + await expect.poll(async () => { + page = await history(baseUrl, created.sessionId) + return hasAssistantMarker(page, recoveredMarker) + }, { timeout: 20_000 }).toBe(true) + if (page === undefined) throw new Error('retry history was not observed') + const retry = page.events.find(({ event }) => event.type === 'llm/retry')?.event + expect(mainAttempts).toBe(2) + expect(retry?.data).toMatchObject({ + turn: 1, + step: 1, + retry: 1, + maxRetries: 2, + failure: { code: 'TRANSPORT' }, + }) + expect(JSON.stringify(page.events)).toContain('WEB_RETRY_DISCARDED') + } finally { + const closed = child.exitCode === null + ? new Promise((resolveClose) => { child.once('close', () => { resolveClose() }) }) + : Promise.resolve() + if (child.exitCode === null) child.kill('SIGTERM') + await closed + await new Promise(resolveClose => provider.close(() => { resolveClose() })) + rmSync(workspace, { recursive: true, force: true }) + } + }, 30_000) }) describe.skipIf(!process.env.DEEPSEEK_API_KEY || notReady.length > 0)('web smoke (real host, real key, W5)', () => { diff --git a/apps/web/tests/snapshots/model-retry.json b/apps/web/tests/snapshots/model-retry.json new file mode 100644 index 0000000000..df2ce854da --- /dev/null +++ b/apps/web/tests/snapshots/model-retry.json @@ -0,0 +1,27 @@ +{ + "beforeRetry": { + "partial": "应撤回的半截回复" + }, + "firstRetry": { + "notice": "正在重试模型请求(1/2) · 1s", + "rows": 1 + }, + "scheduled": { + "partialVisible": false, + "notice": "正在重试模型请求(2/2) · 2s", + "rows": 1, + "reusedDisclosure": true, + "detailsOpen": false, + "animated": true + }, + "expanded": { + "detailsOpen": true, + "delay": "重试延迟:1500ms", + "failure": "失败原因:连接被重置" + }, + "completed": { + "recovered": "重试后的完整回复", + "retryNoticeStillVisible": "已重试模型请求(2/2) · 2s", + "animated": false + } +} diff --git a/docs/config-catalog.md b/docs/config-catalog.md index 6c7627aca4..7793fa5236 100644 --- a/docs/config-catalog.md +++ b/docs/config-catalog.md @@ -708,7 +708,7 @@ export interface Config { } ``` -Source: [`packages/llm/llm-retry/src/index.ts:39`](../packages/llm/llm-retry/src/index.ts) +Source: [`packages/llm/llm-retry/src/index.ts:41`](../packages/llm/llm-retry/src/index.ts) ## `@deepseek-ai/dsh-lsp-local` diff --git a/packages/client/connection/src/client/fixture.ts b/packages/client/connection/src/client/fixture.ts index 53c18dca5c..24ad94451d 100644 --- a/packages/client/connection/src/client/fixture.ts +++ b/packages/client/connection/src/client/fixture.ts @@ -425,6 +425,8 @@ export function createFixtureApi(options: FixtureOptions = {}): ApiProxy { let failNextHistory = false /** Force-enders for currently open stream generators (timing hook: simulated connection loss). */ const streamBreakers = new Set<() => void>() + /** Retry scenarios opened by timing hooks and completed in a later browser assertion phase. */ + const retryScenarios = new Map() // Timing-acceptance hooks (browser test backdoor): the in-memory fixture is ideally timed, which // is exactly what masked the open-window and reconnect-gap bugs (audit S1/S3). These let @@ -448,6 +450,61 @@ export function createFixtureApi(options: FixtureOptions = {}): ApiProxy { const messageSeqs = log.filter(event => event.type === 'user/message').map(event => event.seq) append(sid(id), { type: 'session/title', data: { title, messageSeqs, source: { kind: 'provider', provider: 'fixture' } } }) }, + /** Open one failed model step whose partial remains visible until llm/retry arrives. */ + beginModelRetry(id: string): void { + const sessionId = sid(id) + const turn = nextTurn.get(sessionId) ?? 0 + nextTurn.set(sessionId, turn + 1) + retryScenarios.set(sessionId, { turn, failedStep: 0 }) + setRunning(sessionId, true) + append(sessionId, { type: 'turn/start', data: { turn, trigger: { kind: 'message', source: { kind: 'user' } } } }) + append(sessionId, { type: 'user/message', surfaceOp: 'append', data: { content: text('请重试这个请求'), source: { kind: 'user' } } }) + append(sessionId, { type: 'step/start', data: { turn, step: 0 } }) + append(sessionId, { type: 'assistant/chunk', data: { turn, step: 0, chunk: { type: 'block-start', index: 0, blockType: 'text' } } }) + append(sessionId, { type: 'assistant/chunk', data: { turn, step: 0, chunk: { type: 'text-delta', index: 0, text: '应撤回的半截回复' } } }) + append(sessionId, { type: 'step/end', data: { turn, step: 0 } }) + }, + /** Record one retry decision, synthesizing the later failed step when needed. */ + scheduleModelRetry(id: string, retry = 1, delayMs = 450): void { + const sessionId = sid(id) + const scenario = retryScenarios.get(sessionId) + if (scenario === undefined) throw new Error(`fixture: no model retry scenario for ${id}`) + const failedStep = retry - 1 + if (failedStep > scenario.failedStep) { + append(sessionId, { type: 'step/start', data: { turn: scenario.turn, step: failedStep } }) + append(sessionId, { type: 'assistant/chunk', data: { turn: scenario.turn, step: failedStep, chunk: { type: 'block-start', index: 0, blockType: 'text' } } }) + append(sessionId, { type: 'assistant/chunk', data: { turn: scenario.turn, step: failedStep, chunk: { type: 'text-delta', index: 0, text: `第 ${String(retry)} 次应撤回的回复` } } }) + append(sessionId, { type: 'step/end', data: { turn: scenario.turn, step: failedStep } }) + scenario.failedStep = failedStep + } + append(sessionId, { + type: 'llm/retry', + data: { + turn: scenario.turn, step: failedStep, retry, maxRetries: 2, delayMs, + failure: { code: 'TRANSPORT', message: '连接被重置' }, + }, + }) + }, + /** Finish the timing-hook retry with a finalized response on the next step. */ + completeModelRetry(id: string): void { + const sessionId = sid(id) + const scenario = retryScenarios.get(sessionId) + if (scenario === undefined) throw new Error(`fixture: no model retry scenario for ${id}`) + retryScenarios.delete(sessionId) + const step = scenario.failedStep + 1 + append(sessionId, { type: 'step/start', data: { turn: scenario.turn, step } }) + append(sessionId, { + type: 'assistant/message', + surfaceOp: 'append', + data: { + turn: scenario.turn, step, content: text('重试后的完整回复'), + provenance: { provider: 'fixture', model: 'fx-1' }, + }, + }) + append(sessionId, { type: 'step/end', data: { turn: scenario.turn, step } }) + append(sessionId, { type: 'turn/end', data: { turn: scenario.turn, reason: { kind: 'completed' } } }) + setRunning(sessionId, false) + }, /** Log append WITHOUT the mux emit: a frame lost in transit — history still serves it, the client must repull. */ appendSilent(id: string, msg: string): void { const log = logOf(sid(id)) diff --git a/packages/client/connection/tests/fixture.spec.ts b/packages/client/connection/tests/fixture.spec.ts index 16fa4b4ed6..e7912dc34b 100644 --- a/packages/client/connection/tests/fixture.spec.ts +++ b/packages/client/connection/tests/fixture.spec.ts @@ -19,6 +19,9 @@ interface TimingHooks { failNextHistory(): void appendUser(id: string, msg: string): void appendTitle(id: string, title: string): void + beginModelRetry(id: string): void + scheduleModelRetry(id: string, retry?: number, delayMs?: number): void + completeModelRetry(id: string): void appendSilent(id: string, msg: string): void breakStreams(): void } @@ -489,9 +492,14 @@ describe('createFixtureApi', () => { hooks.appendSilent('fx-alpha', '静默丢帧') hooks.appendUser('fx-alpha', '正常直播') hooks.appendTitle('fx-alpha', 'Fixture 修订标题') + hooks.beginModelRetry('fx-alpha') + hooks.scheduleModelRetry('fx-alpha') + hooks.completeModelRetry('fx-alpha') await vi.waitFor(() => { expect(seen.some(f => f.type === 'session/event' && JSON.stringify(f.event.data).includes('正常直播'))).toBe(true) expect(seen.some(f => f.type === 'session/title' && f.title === 'Fixture 修订标题')).toBe(true) + expect(seen.some(f => f.type === 'session/event' && (f.event as { type: string }).type === 'llm/retry')).toBe(true) + expect(seen.some(f => f.type === 'session/event' && JSON.stringify(f.event.data).includes('重试后的完整回复'))).toBe(true) }) expect(seen.some(f => f.type === 'session/event' && JSON.stringify(f.event.data).includes('静默丢帧'))).toBe(false) const rawTitleIndex = seen.findIndex(f => f.type === 'session/event' && (f.event as { type: string }).type === 'session/title') diff --git a/packages/client/runtime/README.md b/packages/client/runtime/README.md index 6b697cbed9..4c09bfd6e8 100644 --- a/packages/client/runtime/README.md +++ b/packages/client/runtime/README.md @@ -16,6 +16,10 @@ SlotsService gives the renderer separate bare observables for `useSessions` and `SessionManager` retains the latest validated `session/title` control snapshot independently of list and session-instance arrival. Newer event seqs replace older snapshots, title timestamps contribute to list recency, and a subscription baseline discards any retained title beyond its `lastSeq` before the optional folded title arrives. Explicit session removal also clears the retained title. The client-facing `SessionSummary.title` is therefore only the actual durable title; `displayTitle` is always present and falls back through the cwd basename and session id. A cold persisted session keeps that fallback until opening or resuming it causes the host to fold and project its log-backed title. +## Model retry projection + +The Session object validates plugin-owned `llm/retry` payloads at the event wire boundary. A valid event removes the matching failed step's streaming partial and inserts a durable retry notice at the event's sequence position. Window rebuild and history replay apply the same projection, so logged chunks from the discarded attempt never reappear as an interrupted reply after refresh. A terminal turn without `llm/retry` retains the existing behavior: visible unfinalized output is frozen as an interrupted assistant node. + ## Model Experience None, as the client runtime hosts browser-side services and the session object layer; nothing here reaches a model request. diff --git a/packages/client/runtime/package.json b/packages/client/runtime/package.json index 4bd95595c1..d4dfa8b526 100644 --- a/packages/client/runtime/package.json +++ b/packages/client/runtime/package.json @@ -35,6 +35,7 @@ "@deepseek-ai/dsh-client-ui-slots": "workspace:^", "@deepseek-ai/dsh-host-apiproxy": "workspace:^", "@deepseek-ai/dsh-llm": "workspace:^", + "@deepseek-ai/dsh-llm-retry": "workspace:^", "@deepseek-ai/dsh-session": "workspace:^", "immer": "^10.1.1", "react": "^18.2.0", diff --git a/packages/client/runtime/src/client/index.ts b/packages/client/runtime/src/client/index.ts index 830d1b8249..bea041e422 100644 --- a/packages/client/runtime/src/client/index.ts +++ b/packages/client/runtime/src/client/index.ts @@ -25,7 +25,7 @@ export type { } from './contract/store.ts' export type { AssistantBlock, AssistantMessageNode, ComposerPhase, ContextMessageNode, ConversationNode, - ConversationSnapshot, PendingPrompt, RunningToolCall, SessionIntentSnapshot, SessionIntentTarget, + ConversationSnapshot, ModelRetryNode, PendingPrompt, RunningToolCall, SessionIntentSnapshot, SessionIntentTarget, SteeringMessageNode, ToolResultNode, UnknownSurfaceNode, UserMessageNode, } from './sessions/conversation.ts' export { PendingWait } from './sessions/pending.ts' diff --git a/packages/client/runtime/src/client/sessions/conversation.ts b/packages/client/runtime/src/client/sessions/conversation.ts index 78d1eeabf5..886168b581 100644 --- a/packages/client/runtime/src/client/sessions/conversation.ts +++ b/packages/client/runtime/src/client/sessions/conversation.ts @@ -4,6 +4,7 @@ // string here (narrow to real brands when convenient). import type { ContentBlock } from '@deepseek-ai/dsh-llm/types' +import type { LlmRetryEventData } from '@deepseek-ai/dsh-llm-retry/types' import type { RpcError, SessionId, ToolCallView, ToolResultView, WorkspaceId, } from '@deepseek-ai/dsh-client-connection/client' @@ -87,6 +88,20 @@ export interface ContextMessageNode { meta?: unknown } +/** Durable notice that a closed failed step is waiting for a model-request retry. */ +export interface ModelRetryNode { + kind: 'model-retry' + seq: number + /** Unix epoch ms from the llm/retry session event. */ + time: number + turn: number + step: number + retry: number + maxRetries: number + delayMs: number + failure: LlmRetryEventData['failure'] +} + /** A tool result paired (when in-window) with its call head. */ export interface ToolResultNode { kind: 'tool-result' @@ -124,6 +139,7 @@ export type ConversationNode = | AssistantMessageNode | SteeringMessageNode | ContextMessageNode + | ModelRetryNode | ToolResultNode | UnknownSurfaceNode @@ -206,7 +222,7 @@ export interface PendingPrompt { /** The immutable snapshot contract Session hands to uSES (see the web client architecture RFC). */ export interface ConversationSnapshot { sessionId: SessionId - /** Surface fold product (finalized conversation nodes in surface order). */ + /** Finalized surface events and durable operational notices in event order. */ nodes: readonly ConversationNode[] /** Fold degradation flag (cross-window replace defense): when true, nodes come from the lenient linear scan. */ foldDegraded: boolean diff --git a/packages/client/runtime/src/client/sessions/session.ts b/packages/client/runtime/src/client/sessions/session.ts index 396d0aa798..d3fc31659b 100644 --- a/packages/client/runtime/src/client/sessions/session.ts +++ b/packages/client/runtime/src/client/sessions/session.ts @@ -1,6 +1,7 @@ // Sessions remain resident after creation so they continue consuming mux frames off-screen. import type { ContentBlock } from '@deepseek-ai/dsh-llm/types' +import type { LlmRetryEventData } from '@deepseek-ai/dsh-llm-retry/types' import type { SessionEvent } from '@deepseek-ai/dsh-session/types' import type { HistoryEntry, IApiClient, MuxFrame, RpcError, RpcId, RpcResult, @@ -52,9 +53,9 @@ export class Session implements ObservableSnapshot { private readonly foldAdapter = new FoldAdapter() private partial: PartialAccumulator | null = null private openCalls = new Map() - /** Interrupted-turn terminal nodes (frozen partial text / aborted tool cards), merged into the flow by seq. - * Derived from window events (turn/end sweep) — rebuilt by rebuildDerivedFromWindow like partial/openCalls. */ - private frozenNodes: ConversationNode[] = [] + /** Operational notices and interrupted-turn terminal nodes merged into the flow by seq. + * Derived from window events — rebuilt by rebuildDerivedFromWindow like partial/openCalls. */ + private derivedNodes: ConversationNode[] = [] private pending = new Map() // Revision counters preserve array identity when derived content is unchanged, so // React.memo children survive unrelated snapshot swaps (chunk storms must not re-render every @@ -64,8 +65,8 @@ export class Session implements ObservableSnapshot { private callsCache: { rev: number; value: RunningToolCall[] } | null = null private pendingRev = 0 private pendingCache: { rev: number; value: PendingInteraction[] } | null = null - private frozenRev = 0 - private nodesCache: { folded: readonly ConversationNode[]; frozenRev: number; value: readonly ConversationNode[] } | null = null + private derivedRev = 0 + private nodesCache: { folded: readonly ConversationNode[]; derivedRev: number; value: readonly ConversationNode[] } | null = null private running = false /** * Sticky send marker, private input of the composerPhase derivation: set @@ -609,8 +610,27 @@ export class Session implements ObservableSnapshot { } /** Per-event side effects (right column of the §A.9 dispatch table): - * chunk accumulation / partial clear on finalize / openCalls add-remove. */ + * chunk/retry projection and openCalls add-remove. */ private applyEventSideEffects(event: SessionEvent, view?: ToolEventView): void { + const eventType: string = event.type + if (eventType === 'llm/retry') { + const data = parseRetryEventData(event.data) + if (data === null) { + console.error(`[web-runtime] ignored malformed llm/retry event at seq ${event.seq}`) + return + } + if (this.partial !== null && this.partial.turn === data.turn && this.partial.step === data.step) { + this.partial = null + } + this.derivedNodes.push({ + kind: 'model-retry', + seq: event.seq, + time: event.time, + ...data, + }) + this.derivedRev++ + return + } switch (event.type) { case 'assistant/chunk': { const { turn, step, chunk } = event.data @@ -649,12 +669,12 @@ export class Session implements ObservableSnapshot { const visible = blocks.some(b => (b.kind === 'text' || b.kind === 'reasoning' ? b.text !== '' : true)) if (visible) { // Fractional seq: strictly after every event of this turn (all < turn/end seq), before the next turn. - this.frozenNodes.push({ + this.derivedNodes.push({ kind: 'assistant', seq: event.seq - 0.9, time: event.time, turn: this.partial.turn, step: this.partial.step, blocks, interrupted: true, }) - this.frozenRev++ + this.derivedRev++ } this.partial = null } @@ -664,7 +684,7 @@ export class Session implements ObservableSnapshot { this.openCalls.delete(callId) this.callsRev++ // The spinner card becomes an interrupted terminal card (never vanishes mid-flow). - this.frozenNodes.push({ + this.derivedNodes.push({ kind: 'tool-result', seq: event.seq - 0.8 + callOffset++ * 0.01, time: event.time, callId, call: { name: call.name, argsRaw: call.argsRaw }, @@ -672,7 +692,7 @@ export class Session implements ObservableSnapshot { content: [], isError: true, error: { name: 'Interrupted', code: 'interrupted' }, callView: call.callView, resultView: null, }) - this.frozenRev++ + this.derivedRev++ } return } @@ -681,15 +701,15 @@ export class Session implements ObservableSnapshot { } } - /** Re-derive state (partial/openCalls/frozenNodes) from raw window events after a rebuild — keeps + /** Re-derive state (partial/openCalls/derivedNodes) from raw window events after a rebuild — keeps * paging/stitching consistent, and makes the live freeze and the history replay converge on the - * same interrupted nodes (chunks are logged, so the replayed sweep re-freezes identical text). */ + * same retry notices and interrupted nodes. */ private rebuildDerivedFromWindow(): void { this.partial = null this.openCalls.clear() this.callsRev++ - this.frozenNodes = [] - this.frozenRev++ + this.derivedNodes = [] + this.derivedRev++ for (let i = 0; i < this.events.length; i++) { const event = this.events[i] /* v8 ignore next -- dense-array guard: i stays within events.length, so the undefined arm needs a sparse array no caller builds. */ @@ -704,17 +724,17 @@ export class Session implements ObservableSnapshot { private buildSnapshot(): ConversationSnapshot { const { nodes: folded, degraded } = this.foldAdapter.nodes() - // Frozen interrupted nodes ride fractional seqs: a stable merge keeps them in flow order. - // The merged array is cached on (folded reference, frozenRev) so an unchanged flow keeps its + // Derived nodes use their event seq or a nearby fractional seq: a stable merge keeps flow order. + // The merged array is cached on (folded reference, derivedRev) so an unchanged flow keeps its // reference across snapshot swaps (§A.9.4). let nodes: readonly ConversationNode[] - if (this.nodesCache !== null && this.nodesCache.folded === folded && this.nodesCache.frozenRev === this.frozenRev) { + if (this.nodesCache !== null && this.nodesCache.folded === folded && this.nodesCache.derivedRev === this.derivedRev) { nodes = this.nodesCache.value } else { - nodes = this.frozenNodes.length === 0 + nodes = this.derivedNodes.length === 0 ? folded - : [...folded, ...this.frozenNodes].sort((a, b) => a.seq - b.seq) - this.nodesCache = { folded, frozenRev: this.frozenRev, value: nodes } + : [...folded, ...this.derivedNodes].sort((a, b) => a.seq - b.seq) + this.nodesCache = { folded, derivedRev: this.derivedRev, value: nodes } } if (this.callsCache === null || this.callsCache.rev !== this.callsRev) { this.callsCache = { rev: this.callsRev, value: [...this.openCalls.values()] } @@ -752,6 +772,37 @@ function rpcErrorMessage(error: RpcError): string { return `${error.code}: ${error.message}` } +/** Validate the plugin-owned payload at the session-event wire boundary. */ +function parseRetryEventData(value: unknown): LlmRetryEventData | null { + if (value === null || typeof value !== 'object') return null + const data = value as Record + const failure = data.failure + if (failure === null || typeof failure !== 'object') return null + const failureData = failure as Record + if (!nonNegativeInteger(data.turn) + || !nonNegativeInteger(data.step) + || !positiveInteger(data.retry) + || !positiveInteger(data.maxRetries) + || data.retry > data.maxRetries + || typeof data.delayMs !== 'number' + || !Number.isFinite(data.delayMs) + || data.delayMs < 0 + || typeof failureData.message !== 'string' + || typeof failureData.code !== 'string') return null + const optionalNumbers = [failureData.status, failureData.providerRetryAfterMs] + if (optionalNumbers.some(item => item !== undefined && (typeof item !== 'number' || !Number.isFinite(item)))) return null + if (failureData.requestId !== undefined && typeof failureData.requestId !== 'string') return null + return data as unknown as LlmRetryEventData +} + +function nonNegativeInteger(value: unknown): value is number { + return typeof value === 'number' && Number.isInteger(value) && value >= 0 +} + +function positiveInteger(value: unknown): value is number { + return nonNegativeInteger(value) && value > 0 +} + /** * The composerPhase judgment — the single site that knows the predicate * (consumers switch on the result, never re-derive). Monotone per session diff --git a/packages/client/runtime/tests/event-script.ts b/packages/client/runtime/tests/event-script.ts index b567800c9b..b9d6556a40 100644 --- a/packages/client/runtime/tests/event-script.ts +++ b/packages/client/runtime/tests/event-script.ts @@ -28,6 +28,22 @@ export const ev = { at(seq, { type: 'tool/result', surfaceOp: 'append', data: { turn, step, callId, content: text(body), isError: false } }), stepEnd: (seq: number, turn: number, step = 0): SessionEvent => at(seq, { type: 'step/end', data: { turn, step } }), + retry: ( + seq: number, + turn: number, + step = 0, + retry = 1, + maxRetries = 2, + delayMs = 500, + message = 'temporary transport failure', + ): SessionEvent => + at(seq, { + type: 'llm/retry', + data: { + turn, step, retry, maxRetries, delayMs, + failure: { code: 'TRANSPORT', message }, + }, + }), turnEnd: (seq: number, turn: number, reason: 'completed' | 'cancelled' = 'completed'): SessionEvent => at(seq, { type: 'turn/end', data: { turn, reason: { kind: reason } } }), } diff --git a/packages/client/runtime/tests/session.spec.ts b/packages/client/runtime/tests/session.spec.ts index 136709b20c..320fee9f05 100644 --- a/packages/client/runtime/tests/session.spec.ts +++ b/packages/client/runtime/tests/session.spec.ts @@ -119,6 +119,72 @@ describe('live event path', () => { expect((last as { interrupted?: true }).interrupted).toBeUndefined() }) + it('retracts the failed step partial on retry and keeps a replayable notice before the recovered response', async () => { + const { session } = await opened() + const feed = (event: SessionEvent) => { session.handleMuxEnvelope('r' as never, { type: 'session/event', sessionId: SID, event }) } + const retryTurn = [ + ev.turnStart(6, 1), + ev.user(7, '请重试'), + ev.stepStart(8, 1), + ev.chunkStart(9, 1), + ev.chunkText(10, 1, '不完整回复'), + ev.stepEnd(11, 1), + ev.retry(12, 1, 0, 1, 2, 450, '连接被重置'), + ev.stepStart(13, 1, 1), + ev.assistant(14, 1, '完整回复', 1), + ev.stepEnd(15, 1, 1), + ev.turnEnd(16, 1), + ] + for (const event of retryTurn.slice(0, 7)) feed(event) + + let snapshot = session.getSnapshot() + expect(snapshot.partial).toBeNull() + expect(snapshot.nodes.at(-1)).toMatchObject({ + kind: 'model-retry', + turn: 1, + step: 0, + retry: 1, + maxRetries: 2, + delayMs: 450, + failure: { code: 'TRANSPORT', message: '连接被重置' }, + }) + expect(JSON.stringify(snapshot.nodes)).not.toContain('不完整回复') + + for (const event of retryTurn.slice(7)) feed(event) + snapshot = session.getSnapshot() + expect(snapshot.nodes.slice(-2).map(node => node.kind)).toEqual(['model-retry', 'assistant']) + expect(snapshot.nodes.at(-1)).toMatchObject({ kind: 'assistant', blocks: [{ kind: 'text', text: '完整回复' }] }) + + const replay = makeSession() + replay.api.onHistory = () => histResponse([...plainTurn(0, 0, 'a', 'b'), ...retryTurn]) + await replay.session.open() + expect(replay.session.getSnapshot().nodes).toEqual(snapshot.nodes) + expect(replay.session.getSnapshot().partial).toBeNull() + }) + + it('ignores malformed retry payloads without retracting the current partial', async () => { + const { session } = await opened() + const feed = (event: SessionEvent) => { session.handleMuxEnvelope('r' as never, { type: 'session/event', sessionId: SID, event }) } + feed(ev.turnStart(6, 1)) + feed(ev.chunkStart(7, 1)) + feed(ev.chunkText(8, 1, '仍在生成')) + const errorSpy = vi.spyOn(console, 'error').mockImplementation(() => undefined) + try { + feed(at(9, { + type: 'llm/retry', + data: { + turn: 1, step: 0, retry: 3, maxRetries: 2, delayMs: 500, + failure: { code: 'TRANSPORT', message: 'bad budget' }, + }, + })) + expect(session.getSnapshot().partial?.blocks).toEqual([{ kind: 'text', text: '仍在生成' }]) + expect(session.getSnapshot().nodes.filter(node => node.kind === 'model-retry')).toEqual([]) + expect(errorSpy).toHaveBeenCalledWith('[web-runtime] ignored malformed llm/retry event at seq 9') + } finally { + errorSpy.mockRestore() + } + }) + it('freezes an unfinalized partial into an interrupted node on turn/end (cancel path)', async () => { const { session } = await opened() const feed = (event: SessionEvent) => { session.handleMuxEnvelope('r' as never, { type: 'session/event', sessionId: SID, event }) } diff --git a/packages/client/runtime/tsconfig.json b/packages/client/runtime/tsconfig.json index 2e22ea1013..fb75d70312 100644 --- a/packages/client/runtime/tsconfig.json +++ b/packages/client/runtime/tsconfig.json @@ -26,6 +26,9 @@ { "path": "../../llm/llm" }, + { + "path": "../../llm/llm-retry" + }, { "path": "../../support/invariants" } diff --git a/packages/client/ui-conversation/README.md b/packages/client/ui-conversation/README.md index 0711adcccb..6be40ddb91 100644 --- a/packages/client/ui-conversation/README.md +++ b/packages/client/ui-conversation/README.md @@ -8,6 +8,8 @@ The view ring IS a slot: the conversation registration declares the `'conversati Generic tool rows classify the built-in bash, read, search, write, and edit names into dedicated visual variants. The filesystem variants render the edit icon and `Write · ` or `Edit · ` summary while retaining the shared row-to-details interaction. +The chat flow projects consecutive model-retry nodes from one turn into one stable, muted status row updated to the latest attempt; every retry event remains in the runtime snapshot and session log. Its frontend countdown derives from the scheduled delay, rounds remaining time up to seconds, and has a one-second floor. The latest unresolved retry uses a left-to-right text shimmer, then settles to a static completed label. Activating the row reveals the latest exact retry delay and failure message. The client runtime removes each failed step's streaming tail before its retry node arrives, while the status remains visible after a later attempt succeeds. + Tool rows are slots too — the standalone tool ring (`ToolViewRegistry`/`ctx.toolviews`/outlet) is retired. The chat entry declares the keyed `'conversation.chat.toolview'` hole (session scope; the key space is runtime-open); its render site dispatches per row via `entryKey: toolName` with `GenericToolCard` as the call-site `fallback`. The owner payload is the uniform `ToolRowOwnerProps` (`callId`/`toolName`/`block`/`openDetails`) and `ToolRowProps` pre-composes it with the session standard kit. A registrant is a plain plugin: `ctx.slots.register({ name: 'conversation.chat.toolview', key: '', inject? }, Row)` with `inject: ['slots', 'conversation']` as the load-order seam (apply mounts ConversationService after the chat registration, so the service being present guarantees the slot is declared); session differentiation happens inside the component (`useSessions` reading `parentId` — the bash sample is the third-party-posture exemplar). Trajectory/waterfall toolview slots share this shape and land with their own render sites (RendersCheck rejects a declaration nobody renders). Per-session UI state (selection, ordinary composer draft, active view) lives in the declared chat store (`stores.ts` `createChatStore`): apply constructs one handle and passes it to the conversation, chat-view, and details registrations, so the session slots share one instance per session (selection written by the chat view, read by details) and the framework owns instance lifecycle and draft persistence. The frontend Session Intent comes from the Session list projection; after publication, any retained prompt comes from that Session's conversation snapshot. Components are pure — the framework standard kit (`useSession`/`sessionId` when session-scoped, plus global `useSessions`/`useWorkspaces`) and the store faces (`useStore`/`actions`) arrive automatically from the registration declaration; inject factories contribute plain data and callbacks for runtime Session actions, send/stop, tabs, details, and paging. diff --git a/packages/client/ui-conversation/src/client/chat/ChatView.tsx b/packages/client/ui-conversation/src/client/chat/ChatView.tsx index 8023acddde..79577d18e8 100644 --- a/packages/client/ui-conversation/src/client/chat/ChatView.tsx +++ b/packages/client/ui-conversation/src/client/chat/ChatView.tsx @@ -45,6 +45,16 @@ type RenderToolRow = ChatViewSlotProps['renderSlot'] * chat view narrows once to the runtime snapshot the binding actually feeds. */ type UseConversation = SnapshotSelectorHook +function activeRetrySeq(nodes: readonly ConversationNode[], running: boolean): number | null { + if (!running) return null + for (let index = nodes.length - 1; index >= 0; index -= 1) { + const node = nodes[index]! + if (node.kind === 'model-retry') return node.seq + if (node.kind === 'assistant' || node.kind === 'user') return null + } + return null +} + /** One tool call row (result or running): dispatches through the keyed * toolview slot with the owner payload; unregistered tools fall back to * GenericToolCard at this render site. */ @@ -115,6 +125,7 @@ function StreamingTail({ useSession, onGrow }: { /** The chat view slot entry: pure component over the composed props (tool rows render through the declared keyed hole's renderSlot share). */ export function ChatView({ useSession, useStore, renderSlot, openDetails, loadOlder }: ChatViewSlotProps) { const nodes = useSession((s) => s.nodes) + const running = useSession((s) => s.running) const runningCalls = useSession((s) => s.runningCalls) const pending = useSession((s) => s.pending) const openState = useSession((s) => s.openState) @@ -124,6 +135,7 @@ export function ChatView({ useSession, useStore, renderSlot, openDetails, loadOl const selectedCallId = useStore((s) => s.selection?.callId) const items = useMemo(() => deriveChatFlow(nodes), [nodes]) + const activeRetry = useMemo(() => activeRetrySeq(nodes, running), [nodes, running]) const listRef = useRef(null) const atBottomRef = useRef(true) @@ -220,7 +232,13 @@ export function ChatView({ useSession, useStore, renderSlot, openDetails, loadOl } /* v8 ignore next -- tool-result never reaches here: deriveChatFlow folds them into groups. */ if (node.kind === 'tool-result') return null - return + return ( + + ) } return ( diff --git a/packages/client/ui-conversation/src/client/chat/MessageItem.module.css b/packages/client/ui-conversation/src/client/chat/MessageItem.module.css index 50e560278d..870f646099 100644 --- a/packages/client/ui-conversation/src/client/chat/MessageItem.module.css +++ b/packages/client/ui-conversation/src/client/chat/MessageItem.module.css @@ -32,3 +32,103 @@ .contextRow { padding: 2px 0; } + +.retryRow { + color: var(--dsw-alias-label-tertiary); + font-size: 13px; + line-height: 20px; +} + +.retrySummary { + display: inline-flex; + align-items: center; + width: fit-content; + padding: 2px 0; + gap: 7px; + border-radius: 3px; + color: inherit; + cursor: pointer; + list-style: none; + user-select: none; +} + +.retrySummary::-webkit-details-marker { + display: none; +} + +.retrySummary::after { + width: 6px; + height: 6px; + border-right: 1.5px solid currentcolor; + border-bottom: 1.5px solid currentcolor; + content: ''; + opacity: 0.8; + transform: rotate(-45deg); + transition: transform 120ms ease; +} + +.retrySummary:hover { + color: var(--dsw-alias-label-secondary); +} + +.retrySummary:focus-visible { + outline: 1.5px solid var(--dsw-alias-button-info-fill); + outline-offset: 2px; +} + +.retryText { + color: inherit; +} + +.retryRow[data-active] .retryText { + background: + linear-gradient( + 90deg, + var(--dsw-alias-label-tertiary) 0%, + var(--dsw-alias-label-tertiary) 40%, + var(--dsw-alias-label-secondary) 50%, + var(--dsw-alias-label-tertiary) 60%, + var(--dsw-alias-label-tertiary) 100% + ); + background-position: 100% 50%; + background-size: 200% 100%; + background-clip: text; + color: transparent; + animation: retry-shimmer 1.6s ease-in-out infinite; +} + +.retryRow[open] .retrySummary::after { + transform: rotate(45deg); +} + +.retryDetails { + display: grid; + gap: 2px; + margin-top: 3px; + padding-left: 14px; + overflow-wrap: anywhere; + font-size: 12px; + line-height: 18px; +} + +.retryDetailLabel { + color: var(--dsw-alias-label-secondary); +} + +@keyframes retry-shimmer { + from { + background-position: 100% 50%; + } + + to { + background-position: 0 50%; + } +} + +@media (prefers-reduced-motion: reduce) { + .retryRow[data-active] .retryText { + background: none; + color: inherit; + animation: none; + } +} diff --git a/packages/client/ui-conversation/src/client/chat/MessageItem.tsx b/packages/client/ui-conversation/src/client/chat/MessageItem.tsx index 4bfe07d687..c1c4ac5b4f 100644 --- a/packages/client/ui-conversation/src/client/chat/MessageItem.tsx +++ b/packages/client/ui-conversation/src/client/chat/MessageItem.tsx @@ -1,17 +1,18 @@ -// MessageItem: the four simple node kinds — user bubble (right-aligned), -// steering (badged bubble), context injection and unknown-surface JSON rows. +// MessageItem: simple chat nodes — user bubble (right-aligned), steering +// (badged bubble), context injection, retry disclosure and unknown JSON rows. // Props are frozen node slices off the snapshot cache; memo holds across // streaming because unchanged nodes keep their references. -import { memo } from 'react' +import { memo, useEffect, useState } from 'react' import type { - ContextMessageNode, SteeringMessageNode, UnknownSurfaceNode, UserMessageNode, + ContextMessageNode, ModelRetryNode, SteeringMessageNode, UnknownSurfaceNode, UserMessageNode, } from '@deepseek-ai/dsh-client-runtime/client' import { JsonBlock, MessageText } from '@deepseek-ai/dsh-client-ui-primitives' import css from './MessageItem.module.css' export interface MessageItemProps { - node: UserMessageNode | SteeringMessageNode | ContextMessageNode | UnknownSurfaceNode + node: UserMessageNode | SteeringMessageNode | ContextMessageNode | ModelRetryNode | UnknownSurfaceNode + retryActive?: boolean } function contentText(content: readonly unknown[]): { text: string; rest: unknown[] } { @@ -25,7 +26,56 @@ function contentText(content: readonly unknown[]): { text: string; rest: unknown return { text: texts.join(''), rest } } -export const MessageItem = memo(function MessageItem({ node }: MessageItemProps) { +function retrySeconds(milliseconds: number): number { + return Math.max(1, Math.ceil(milliseconds / 1_000)) +} + +interface RetryCountdown { + deadline: number + seconds: number +} + +function ModelRetryItem({ node, active }: { node: ModelRetryNode; active: boolean }) { + const deadline = node.time + node.delayMs + const scheduledSeconds = retrySeconds(node.delayMs) + const [countdown, setCountdown] = useState(() => ({ + deadline, + seconds: retrySeconds(deadline - Date.now()), + })) + const remainingSeconds = countdown.deadline === deadline + ? countdown.seconds + : retrySeconds(deadline - Date.now()) + + useEffect(() => { + if (!active || retrySeconds(deadline - Date.now()) === 1) return + const timer = window.setInterval(() => { + const next = retrySeconds(deadline - Date.now()) + setCountdown(current => ( + current.deadline === deadline && current.seconds === next + ? current + : { deadline, seconds: next } + )) + if (next === 1) window.clearInterval(timer) + }, 250) + return () => { window.clearInterval(timer) } + }, [active, deadline]) + + return ( +
+ + + {active ? '正在重试' : '已重试'}模型请求({node.retry}/{node.maxRetries}) · {active ? remainingSeconds : scheduledSeconds}s + + +
+
重试延迟:{Math.round(node.delayMs)}ms
+
失败原因:{node.failure.message}
+
+
+ ) +} + +export const MessageItem = memo(function MessageItem({ node, retryActive = false }: MessageItemProps) { switch (node.kind) { case 'user': case 'steering': { @@ -46,6 +96,8 @@ export const MessageItem = memo(function MessageItem({ node }: MessageItemProps) ) + case 'model-retry': + return default: return (
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 8f47e334c5..39906c5aa8 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 from + * one turn reuse the first notice's row while projecting the latest attempt. * Item identity keys are stable across snapshots so the list parent can * subscribe to keys only while rows subscribe to content. */ @@ -15,7 +16,7 @@ export type ChatFlowItem = /** * 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 same-turn retry notices reuse their first key. */ export function deriveChatFlow(nodes: readonly ConversationNode[]): ChatFlowItem[] { const items: ChatFlowItem[] = [] @@ -28,6 +29,18 @@ 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' + && previous.node.turn === node.turn + ) { + 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/tests/chat-branch-tails.spec.tsx b/packages/client/ui-conversation/tests/chat-branch-tails.spec.tsx index be50356185..38d901dafc 100644 --- a/packages/client/ui-conversation/tests/chat-branch-tails.spec.tsx +++ b/packages/client/ui-conversation/tests/chat-branch-tails.spec.tsx @@ -5,7 +5,7 @@ // machinery specs since the tool ring dissolved into renderSlot.) import { afterEach, describe, expect, it, vi } from 'vitest' -import { cleanup, render } from '@testing-library/react' +import { act, cleanup, fireEvent, render } from '@testing-library/react' import { RpcId } from '@deepseek-ai/dsh-client-connection/client' import type { SessionId } from '@deepseek-ai/dsh-client-runtime/client' import { PendingWait } from '@deepseek-ai/dsh-client-runtime/client' @@ -15,7 +15,10 @@ import { PendingCard } from '../src/client/chat/PendingCard.tsx' import { AssistantMarkdown } from '../src/client/chat/AssistantMarkdown.tsx' import { StatsLine, type StatsLineProps } from '../src/client/chat/StatsLine.tsx' -afterEach(cleanup) +afterEach(() => { + cleanup() + vi.useRealTimers() +}) describe('MessageItem arms', () => { it('steering bubbles carry the interjection badge and non-text rest blocks', () => { @@ -41,6 +44,78 @@ 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') + }) }) describe('small branch tails', () => { diff --git a/packages/client/ui-conversation/tests/chat-view.spec.tsx b/packages/client/ui-conversation/tests/chat-view.spec.tsx index bb55fcac77..ba661b276c 100644 --- a/packages/client/ui-conversation/tests/chat-view.spec.tsx +++ b/packages/client/ui-conversation/tests/chat-view.spec.tsx @@ -7,7 +7,7 @@ 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, ConversationNode, ConversationSnapshot, RunningToolCall, SessionId, SessionListState, ToolResultNode, UserMessageNode, WorkspaceListState, + AssistantMessageNode, 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' @@ -59,6 +59,11 @@ const user = (seq: number, text: string): UserMessageNode => ({ const assistant = (seq: number, text: string): AssistantMessageNode => ({ kind: 'assistant', seq, time: seq * 1_000, turn: 1, step: 1, blocks: [{ kind: 'text', text }], }) +const retry = (seq: number): ModelRetryNode => ({ + kind: 'model-retry', seq, time: seq * 1_000, turn: 1, step: 0, + 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}"}` }, @@ -128,6 +133,17 @@ describe('chat-flow derivation', () => { expect(flowKeys(items)).toBe('n1|n2|g3|n5|g6') expect(flowKeys(deriveChatFlow([...nodes, toolResult(7, 'd')]))).toBe('n1|n2|g3|n5|g6') }) + + it('reuses one stable row for consecutive retries in the same turn', () => { + const first = retry(2) + const second = { ...retry(3), step: 1, 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) + }) }) describe('ChatView', () => { @@ -168,6 +184,31 @@ describe('ChatView', () => { expect(view.getByText('run a')).toBeTruthy() }) + it('animates only the latest unresolved model retry', () => { + const retryNode = retry(2) + const nextRetry = { ...retry(3), step: 1, 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, context, assistant(5, 'done')] })) + expect(disclosure?.dataset.active).toBeUndefined() + expect(view.getByRole('status').textContent).toBe('已重试模型请求(2/2) · 1s') + + act(() => h.set({ nodes: [user(1, 'try'), retry(6)], running: false })) + expect(disclosure?.dataset.active).toBeUndefined() + }) + it('renders assistant Markdown across history, streaming, final, and interrupted states while user text stays literal', () => { const markdown = '# Rendered\n\n- **one**\n- `two`' const h = makeHarness({ nodes: [user(1, markdown), assistant(2, markdown)] }) diff --git a/packages/llm/llm-retry/README.md b/packages/llm/llm-retry/README.md index da1084ba31..8628e8aa37 100644 --- a/packages/llm/llm-retry/README.md +++ b/packages/llm/llm-retry/README.md @@ -4,7 +4,7 @@ Function plugin that retries selected transient model-request failures on the ag The default policy permits two retries for `EMPTY_RESPONSE`, `RATE_LIMIT`, `SERVER`, `TIMEOUT`, and `TRANSPORT`, using bounded exponential backoff from 500 ms to 10 seconds with 10 percent jitter. `EMPTY_RESPONSE` is the adapters' classification of a degenerate provider completion (a terminal stop with zero content blocks); the attempt produced nothing durable, so repeating it is safe. Delay bounds must fit Node's supported timer range. A valid `providerRetryAfterMs` replaces local backoff when it is within the configured cap; an over-cap instruction delegates to the next recovery policy instead. -Before waiting, the plugin appends a non-surface `llm/retry` event with the failure and scheduled delay. Cancellation and plugin disposal abort the wait; disposal drains the plugin's active backoffs, and a callback captured before disposal fails closed if invoked afterward. +Before waiting, the plugin appends a non-surface `llm/retry` event with the 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. Cancellation and plugin disposal abort the wait; disposal drains the plugin's active backoffs, and a callback captured before disposal fails closed if invoked afterward. The separately published `./invariant` companion checks that every retry record names the current open turn and its latest closed step, has a unique step record and increasing retry number, and carries a positive bounded retry budget and non-negative bounded timer delay. Full jitter may schedule zero milliseconds at its lower boundary. diff --git a/packages/llm/llm-retry/package.json b/packages/llm/llm-retry/package.json index 6d6c27636c..757bf49730 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 f37cf47e7e..ecf1be4233 100644 --- a/packages/llm/llm-retry/src/index.ts +++ b/packages/llm/llm-retry/src/index.ts @@ -26,6 +26,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..5e3fb0322f --- /dev/null +++ b/packages/llm/llm-retry/src/types.ts @@ -0,0 +1,11 @@ +import type { LlmFailure } from '@deepseek-ai/dsh-llm/types' + +/** Durable payload recorded before one transient model-request retry wait. */ +export interface LlmRetryEventData { + turn: number + step: number + retry: number + maxRetries: 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 284a3686dc..5ba0361cc4 100644 --- a/packages/llm/llm-retry/tests/retry.spec.ts +++ b/packages/llm/llm-retry/tests/retry.spec.ts @@ -1,10 +1,11 @@ -import { afterEach, describe, expect, it, vi } from 'vitest' +import { afterEach, describe, expect, expectTypeOf, it, vi } from 'vitest' import { Context } from 'cordis' import type { Fiber } from 'cordis' import LlmService, { CallId, EMPTY_RESPONSE_CODE, LlmAdapter, LlmError } from '@deepseek-ai/dsh-llm' import type { GenerateOptions, 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' @@ -15,6 +16,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[] = [] diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 7f19dcce27..bcb6c0402b 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -179,6 +179,9 @@ importers: '@deepseek-ai/dsh-llm-deepseek': specifier: workspace:^ version: link:../../packages/llm/llm-deepseek + '@deepseek-ai/dsh-llm-retry': + specifier: workspace:^ + version: link:../../packages/llm/llm-retry '@deepseek-ai/dsh-paths': specifier: workspace:^ version: link:../../packages/util/paths @@ -782,6 +785,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 diff --git a/tsconfig.base.json b/tsconfig.base.json index a449a34c4e..1846e55366 100644 --- a/tsconfig.base.json +++ b/tsconfig.base.json @@ -43,6 +43,7 @@ "@deepseek-ai/dsh-session/surface": ["./packages/core/session/src/surface.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-tools/presentation": ["./packages/core/tools/src/presentation.ts"], "@deepseek-ai/dsh-user-approval/types": ["./packages/ui/user-approval/src/types.ts"], "@deepseek-ai/dsh-user-interaction/types": ["./packages/ui/user-interaction/src/types.ts"], From 49131c47c514466e460c251c8e021e6f642cb087 Mon Sep 17 00:00:00 2001 From: Yichen Jiang Date: Wed, 29 Jul 2026 14:23:03 +0800 Subject: [PATCH 2/2] fix(web): address model retry review feedback --- ...-21-bounded-llm-request-recovery.i18n.yaml | 4 +- ...2026-06-21-bounded-llm-request-recovery.md | 4 +- ...6-06-21-bounded-llm-request-recovery.zh.md | 4 +- apps/web/tests/session-title.snapshot.ts | 25 ++++ .../tests/snapshots/model-retry-cancel.json | 5 + .../client/connection/src/client/fixture.ts | 18 +++ .../client/connection/tests/fixture.spec.ts | 6 + packages/client/runtime/README.i18n.yaml | 4 +- packages/client/runtime/README.md | 2 +- packages/client/runtime/README.zh.md | 2 +- packages/client/runtime/package.json | 1 + .../src/client/sessions/conversation.ts | 5 + .../runtime/src/client/sessions/session.ts | 73 +++++++++--- packages/client/runtime/tests/event-script.ts | 2 +- packages/client/runtime/tests/session.spec.ts | 112 +++++++++++++++--- .../client/ui-conversation/README.i18n.yaml | 4 +- packages/client/ui-conversation/README.md | 2 +- packages/client/ui-conversation/README.zh.md | 2 +- .../src/client/chat/ChatView.tsx | 2 +- .../src/client/chat/MessageItem.tsx | 16 ++- .../tests/chat-branch-tails.spec.tsx | 27 ++++- .../ui-conversation/tests/chat-view.spec.tsx | 15 ++- .../client/ui-trajectory/README.i18n.yaml | 6 +- packages/client/ui-trajectory/README.md | 2 +- packages/client/ui-trajectory/README.zh.md | 2 +- .../client/ui-trajectory/src/client/spans.ts | 7 +- .../client/ui-trajectory/tests/views.spec.tsx | 7 +- pnpm-lock.yaml | 3 + 28 files changed, 301 insertions(+), 61 deletions(-) create mode 100644 apps/web/tests/snapshots/model-retry-cancel.json diff --git a/.agents/notes/implemented/architecture/2026-06-21-bounded-llm-request-recovery.i18n.yaml b/.agents/notes/implemented/architecture/2026-06-21-bounded-llm-request-recovery.i18n.yaml index f6dee03e61..8193e5e839 100644 --- a/.agents/notes/implemented/architecture/2026-06-21-bounded-llm-request-recovery.i18n.yaml +++ b/.agents/notes/implemented/architecture/2026-06-21-bounded-llm-request-recovery.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 .agents/notes/implemented/architecture/2026-06-21-bounded-llm-request-recovery.md -2026-06-21-bounded-llm-request-recovery.md: aadd73bc921e942f523c26027e8fc06aaea38be7 -2026-06-21-bounded-llm-request-recovery.zh.md: 847e20a39bcee697fe61329b68964364c057f666 +2026-06-21-bounded-llm-request-recovery.md: 5c76ed5d754ea40f41dff78cb56ee7fc139a32b1 +2026-06-21-bounded-llm-request-recovery.zh.md: 1fa56f3fe0405cab663c2843d423a78d910170dd diff --git a/.agents/notes/implemented/architecture/2026-06-21-bounded-llm-request-recovery.md b/.agents/notes/implemented/architecture/2026-06-21-bounded-llm-request-recovery.md index aadd73bc92..5c76ed5d75 100644 --- a/.agents/notes/implemented/architecture/2026-06-21-bounded-llm-request-recovery.md +++ b/.agents/notes/implemented/architecture/2026-06-21-bounded-llm-request-recovery.md @@ -82,7 +82,7 @@ Boundary tests prove termination at both actual transports. The hand-written ada ### Keep attempts separate in the existing log -A failed attempt may leave `assistant/chunk` events in its closed step, but it never appends `assistant/message` and never dispatches a tool. A retry closes the failed turn, opens the next numbered turn, reconstructs the request from the durable surface, and produces its own chunks. UIs may render live chunks while a step is open, then mark or clear that transient view when `llm/retry` identifies the failed step or `turn/end` records failure. Web clears the failed partial at `llm/retry`, projects consecutive retry-turn events into one stable row updated to the latest attempt, counts its delay down in ceiling-rounded seconds with a one-second floor, animates only while unresolved, and keeps exact latest failure details collapsed behind the row. Message derivation continues to ignore the failed chunks, and Web applies the same projection during history rebuild so refreshing cannot resurrect discarded partials or duplicate retry rows. +A failed attempt may leave `assistant/chunk` events in its closed step, but it never appends `assistant/message` and never dispatches a tool. A retry closes the failed turn, opens the next numbered turn, reconstructs the request from the durable surface, and produces its own chunks. UIs may render live chunks while a step is open, then mark or clear that transient view when `llm/retry` identifies the failed step or `turn/end` records failure. Web validates the complete retry payload contract, clears the failed partial at `llm/retry`, projects consecutive retry-turn events into one stable row updated to the latest attempt, and derives scheduled, started, or cancelled status from subsequent turn facts. Its countdown anchors the scheduled delay to browser receipt rather than the Host event clock, uses ceiling-rounded seconds with a one-second floor, animates only while unresolved, and keeps exact latest failure details collapsed behind the row. Retry nodes anchor their own trajectory turn even when the failed attempt has no assistant node. Message derivation continues to ignore the failed chunks, and Web applies the same projection during history rebuild so refreshing cannot resurrect discarded partials or duplicate retry rows. If recovery is exhausted, the final failure is stored once on `turn/end.reason` with the structured facts. If transient recovery continues, `llm/retry` is the durable home for that attempt's failure and delay. No standalone final-error event or response-id vocabulary is added. @@ -116,7 +116,7 @@ If recovery is exhausted, the final failure is stored once on `turn/end.reason` - Pure unit tests cover transient-code selection, exponential backoff and jitter bounds, valid and over-cap `Retry-After`, exhausted budgets, deterministic timer/random seams, and abort during backoff. - Real agent-loop tests cover failure before chunks, partial chunks then failure, thrown and in-band failures, retry to success in a new turn, exhaustion to structured `turn/end.reason`, and composition with `dsh-compact-basic` context-overflow recovery. - The partial-chunk integration test proves failed chunks remain attributed to the failed step, no assistant message or tool side effect is committed for that step, and the successful retry has distinct provenance. -- The plugin-owned `llm/retry` event is non-surface, survives JSONL and SQLite round trips, is ignored by message derivation, and drives TUI and Web retraction plus scheduled-retry rendering. Keyless UI snapshots cover Web scheduling and success, a real Web composition test covers partial transport failure through recovery, and ACP automation snapshots confirm that a discarded attempt stays off the wire while the recovered reply is emitted. +- The plugin-owned `llm/retry` event is non-surface, survives JSONL and SQLite round trips, is ignored by message derivation, and drives TUI and Web retraction plus scheduled-retry rendering. Client tests cover complete wire validation, clock-independent countdown, cancellation versus completed retry labels, and trajectory attribution; keyless UI snapshots cover Web scheduling and success, a real Web composition test covers partial transport failure through recovery, and ACP automation snapshots confirm that a discarded attempt stays off the wire while the recovered reply is emitted. - Idle-watchdog tests prove the stable signal is rearmed only while `next()` is outstanding, disarmed during consumer think time and in `finally`, and classified separately from a total-call deadline and an earlier caller abort; adapter tests prove the signal stops the underlying request rather than merely detaching it. - Direct `ctx.llm.stream()` callers remain single-attempt and receive the same structured failure facts. diff --git a/.agents/notes/implemented/architecture/2026-06-21-bounded-llm-request-recovery.zh.md b/.agents/notes/implemented/architecture/2026-06-21-bounded-llm-request-recovery.zh.md index 847e20a39b..1fa56f3fe0 100644 --- a/.agents/notes/implemented/architecture/2026-06-21-bounded-llm-request-recovery.zh.md +++ b/.agents/notes/implemented/architecture/2026-06-21-bounded-llm-request-recovery.zh.md @@ -82,7 +82,7 @@ agent-spine 演示组合包加载该插件,因此共享的 stdio/TUI、一次 ### 在现有日志中分隔尝试 -一次失败尝试可以在已关闭的步骤中留下 `assistant/chunk` 事件,但绝不会追加 `assistant/message`,也不会分发工具。重试会关闭失败轮次,开启下一个编号轮次,从持久表层重建请求,并生成自己的分片。步骤仍处于打开状态时,UI 可以渲染实时分片;当 `llm/retry` 标识失败步骤,或 `turn/end` 记录失败时,UI 再标记或清除这份暂时视图。Web 会在 `llm/retry` 到达时清除失败的部分输出,将连续重试轮次的事件投影为稳定的一行,并用最新一次尝试更新该行;它按向上取整且不低于 1 秒的秒数对延迟倒计时,仅在重试尚未结束时显示动画,并把最近一次失败的准确详情折叠在该行之后。消息派生仍会忽略失败分片;Web 在重建历史时也会应用同一投影,因此刷新页面不会让已丢弃的部分输出重新出现,也不会生成重复的重试行。 +一次失败尝试可以在已关闭的步骤中留下 `assistant/chunk` 事件,但绝不会追加 `assistant/message`,也不会分发工具。重试会关闭失败轮次,开启下一个编号轮次,从持久表层重建请求,并生成自己的分片。步骤仍处于打开状态时,UI 可以渲染实时分片;当 `llm/retry` 标识失败步骤,或 `turn/end` 记录失败时,UI 再标记或清除这份暂时视图。Web 会验证完整的重试载荷契约,在 `llm/retry` 到达时清除失败的部分输出,将连续重试轮次的事件投影为稳定的一行,并用最新一次尝试更新该行,再从后续轮次事实派生 scheduled、started 或 cancelled 状态。倒计时以浏览器收到事件的时刻为计划延迟的起点,而不是使用 Host 事件时钟;它按向上取整且不低于 1 秒的秒数显示,仅在重试尚未结束时显示动画,并把最近一次失败的准确详情折叠在该行之后。即使失败尝试没有 assistant 节点,重试节点也会锚定自身的轨迹轮次。消息派生仍会忽略失败分片;Web 在重建历史时也会应用同一投影,因此刷新页面不会让已丢弃的部分输出重新出现,也不会生成重复的重试行。 如果恢复预算耗尽,最终失败会连同结构化事实在 `turn/end.reason` 中存储一次。如果暂时性恢复继续,`llm/retry` 就是该次尝试的失败与延迟的持久归属位置。本决策不增加独立的最终错误事件或响应 id 词汇。 @@ -116,7 +116,7 @@ agent-spine 演示组合包加载该插件,因此共享的 stdio/TUI、一次 - 纯单元测试覆盖暂时性 code 选择、指数退避和抖动边界、有效及超出上限的 `Retry-After`、耗尽的预算、确定性定时器/随机数 seam,以及退避期间中止。 - 真实 agent-loop 测试覆盖分片前失败、部分分片后失败、抛出及带内失败、在新轮次中重试至成功、耗尽后写入结构化 `turn/end.reason`,以及与 `dsh-compact-basic` 上下文溢出恢复的组合。 - 部分分片集成测试证明:失败分片仍归属于失败步骤,该步骤不会提交 assistant 消息或工具副作用,成功的重试具有不同的来源信息。 -- 插件拥有的不进入表层的 `llm/retry` 事件可在 JSONL 和 SQLite 往返后保留,被消息派生忽略,并驱动 TUI 和 Web 撤回及计划重试渲染。无密钥 UI 快照覆盖 Web 的调度与成功,真实 Web 组合测试覆盖部分传输失败直至恢复,ACP 自动化快照确认,被丢弃的尝试不会通过协议发出,而恢复后的回复会正常发出。 +- 插件拥有的不进入表层的 `llm/retry` 事件可在 JSONL 和 SQLite 往返后保留,被消息派生忽略,并驱动 TUI 和 Web 撤回及计划重试渲染。客户端测试覆盖完整的 wire 验证、独立于时钟的倒计时、已取消与已完成重试标签的区别以及轨迹归属;无密钥 UI 快照覆盖 Web 的调度与成功,真实 Web 组合测试覆盖部分传输失败直至恢复,ACP 自动化快照确认,被丢弃的尝试不会通过协议发出,而恢复后的回复会正常发出。 - 空闲看门狗测试证明:只有 `next()` 尚未完成时才会重新布防稳定信号;在消费方思考期间及 `finally` 中会解除布防;它与总调用 deadline 以及更早发生的调用方中止分开分类。适配器测试证明该信号会终止底层请求,而不只是与其脱离。 - `ctx.llm.stream()` 的直接调用方仍只尝试一次,并收到相同的结构化失败事实。 diff --git a/apps/web/tests/session-title.snapshot.ts b/apps/web/tests/session-title.snapshot.ts index b77f82edd5..849ea4a879 100644 --- a/apps/web/tests/session-title.snapshot.ts +++ b/apps/web/tests/session-title.snapshot.ts @@ -33,6 +33,7 @@ interface FixtureTiming { appendTitle(id: string, title: string): void beginModelRetry(id: string): void scheduleModelRetry(id: string, retry?: number, delayMs?: number): void + cancelModelRetryDuringBackoff(id: string, delayMs?: number): void completeModelRetry(id: string): void } @@ -216,3 +217,27 @@ it('retracts a failed stream at llm/retry and retains the durable notice after r await expect(`${JSON.stringify({ beforeRetry, firstRetry, scheduled, expanded, completed }, null, 2)}\n`) .toMatchFileSnapshot('./snapshots/model-retry.json') }) + +it('labels a retry cancelled during backoff without claiming that it started', async () => { + bootFixtureApp() + await selectFixtureSession() + const timing = (globalThis as Record).__fxTiming as FixtureTiming + + act(() => { timing.beginModelRetry('fx-alpha') }) + await screen.findByText('应撤回的半截回复') + act(() => { timing.cancelModelRetryDuringBackoff('fx-alpha', 1_500) }) + + const notice = await screen.findByRole('status') + await waitFor(() => { expect(notice.textContent).toContain('重试已取消') }) + await waitFor(() => { expect(screen.queryByText('应撤回的半截回复')).toBeNull() }) + const disclosure = notice.closest('details') + if (disclosure === null) throw new Error('cancelled retry disclosure missing') + const cancelled = { + notice: notice.textContent, + partialVisible: screen.queryByText('应撤回的半截回复') !== null, + animated: disclosure.dataset.active === 'true', + } + + await expect(`${JSON.stringify(cancelled, null, 2)}\n`) + .toMatchFileSnapshot('./snapshots/model-retry-cancel.json') +}) diff --git a/apps/web/tests/snapshots/model-retry-cancel.json b/apps/web/tests/snapshots/model-retry-cancel.json new file mode 100644 index 0000000000..a61756904b --- /dev/null +++ b/apps/web/tests/snapshots/model-retry-cancel.json @@ -0,0 +1,5 @@ +{ + "notice": "模型请求重试已取消(1/2) · 2s", + "partialVisible": false, + "animated": false +} diff --git a/packages/client/connection/src/client/fixture.ts b/packages/client/connection/src/client/fixture.ts index 1ba6d84173..269203b4ce 100644 --- a/packages/client/connection/src/client/fixture.ts +++ b/packages/client/connection/src/client/fixture.ts @@ -792,6 +792,24 @@ export function createFixtureApi(options: FixtureOptions = {}): ApiProxy { scenario.turn = next scenario.stepStarted = false }, + /** Record one retry decision, then cancel its source turn before the retry starts. */ + cancelModelRetryDuringBackoff(id: string, delayMs = 450): void { + const sessionId = sid(id) + const scenario = retryScenarios.get(sessionId) + if (scenario === undefined) throw new Error(`fixture: no model retry scenario for ${id}`) + const failure = { code: 'TRANSPORT', message: '连接被重置' } + append(sessionId, { + type: 'llm/retry', + data: { + turn: scenario.turn, step: 1, + provider: 'fixture', mode: 'normal', policyKey: 'fixture-normal', + retry: 1, maxRetries: 2, delayMs, failure, + }, + }) + append(sessionId, { type: 'turn/end', data: { turn: scenario.turn, reason: { kind: 'aborted' } } }) + retryScenarios.delete(sessionId) + setRunning(sessionId, false) + }, /** Finish the timing-hook retry with a finalized response in the open retry turn. */ completeModelRetry(id: string): void { const sessionId = sid(id) diff --git a/packages/client/connection/tests/fixture.spec.ts b/packages/client/connection/tests/fixture.spec.ts index beb855597a..1734f540b3 100644 --- a/packages/client/connection/tests/fixture.spec.ts +++ b/packages/client/connection/tests/fixture.spec.ts @@ -21,6 +21,7 @@ interface TimingHooks { appendTitle(id: string, title: string): void beginModelRetry(id: string): void scheduleModelRetry(id: string, retry?: number, delayMs?: number): void + cancelModelRetryDuringBackoff(id: string, delayMs?: number): void completeModelRetry(id: string): void appendSilent(id: string, msg: string): void breakStreams(): void @@ -630,10 +631,15 @@ describe('createFixtureApi', () => { hooks.beginModelRetry('fx-alpha') hooks.scheduleModelRetry('fx-alpha') hooks.completeModelRetry('fx-alpha') + hooks.beginModelRetry('fx-alpha') + hooks.cancelModelRetryDuringBackoff('fx-alpha') await vi.waitFor(() => { expect(seen.some(f => f.type === 'session/event' && JSON.stringify(f.event.data).includes('正常直播'))).toBe(true) expect(seen.some(f => f.type === 'session/event' && (f.event as { type: string }).type === 'llm/retry')).toBe(true) expect(seen.some(f => f.type === 'session/event' && JSON.stringify(f.event.data).includes('重试后的完整回复'))).toBe(true) + expect(seen.some(f => f.type === 'session/event' + && f.event.type === 'turn/end' + && f.event.data.reason.kind === 'aborted')).toBe(true) expect(seen.some(f => f.type === 'session/projection' && f.key === 'title' && f.value === 'Fixture 修订标题')).toBe(true) }) expect(seen.some(f => f.type === 'session/event' && JSON.stringify(f.event.data).includes('静默丢帧'))).toBe(false) diff --git a/packages/client/runtime/README.i18n.yaml b/packages/client/runtime/README.i18n.yaml index 6e00fb6c7b..1cf0e42f7b 100644 --- a/packages/client/runtime/README.i18n.yaml +++ b/packages/client/runtime/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/client/runtime/README.md -README.md: 4f4a957baa8e8e24486318d3ffde235155b5cf73 -README.zh.md: 51f8bc91d59b6971d4a3ff9dfe3d0edd210d2557 +README.md: 45cc032db81aee79061fc2f6a9d9062513629000 +README.zh.md: a769661f6e4f7b1eb6d9872c5b979a871aba3eba diff --git a/packages/client/runtime/README.md b/packages/client/runtime/README.md index 4f4a957baa..45cc032db8 100644 --- a/packages/client/runtime/README.md +++ b/packages/client/runtime/README.md @@ -26,7 +26,7 @@ SlotsService gives the renderer separate bare observables for `useSessions` and ## Model retry projection -The Session object validates plugin-owned, provider-routed `llm/retry` payloads at the event wire boundary. A valid event removes the matching failed step's streaming partial and inserts a durable retry notice at the event's sequence position. Normal-mode notices carry their finite maximum; always-mode notices remain explicitly unbounded. Window rebuild and history replay apply the same projection, so logged chunks from the discarded attempt never reappear as an interrupted reply after refresh. A terminal turn without `llm/retry` retains the existing behavior: visible unfinalized output is frozen as an interrupted assistant node. +The Session object validates plugin-owned, provider-routed `llm/retry` payloads at the event wire boundary against the producer's complete field contract, including timer, integer, status, provider-delay, and non-empty diagnostic bounds. A valid event removes the matching failed step's streaming partial and inserts a durable retry notice at the event's sequence position. The notice is `scheduled` until a following retry turn starts; an aborted or disposed source turn marks it `cancelled`, while the retry turn marks it `started`. Normal-mode notices carry their finite maximum; always-mode notices remain explicitly unbounded. Window rebuild and history replay apply the same projection, so logged chunks from the discarded attempt never reappear as an interrupted reply after refresh. A terminal turn without `llm/retry` retains the existing behavior: visible unfinalized output is frozen as an interrupted assistant node. ## Session model selection diff --git a/packages/client/runtime/README.zh.md b/packages/client/runtime/README.zh.md index 51f8bc91d5..a769661f6e 100644 --- a/packages/client/runtime/README.zh.md +++ b/packages/client/runtime/README.zh.md @@ -26,7 +26,7 @@ SlotsService 分别为 renderer 提供 `useSessions` 与 `useWorkspaces` 的裸 ## 模型重试投影 -Session 对象会在事件 wire 边界验证由插件负责、按提供方路由的 `llm/retry` 载荷。有效事件会移除对应失败步骤的流式输出片段,并在该事件的序列位置插入一条持久的重试提示。normal mode 提示携带其有限上限;always mode 提示则保持显式无界。窗口重建与历史回放应用相同的投影,因此刷新后,来自已丢弃尝试的日志分片绝不会重新显示为中断回复。没有 `llm/retry` 的终止轮次保留现有行为:可见但尚未定稿的输出会冻结为中断的 assistant 节点。 +Session 对象会在事件 wire 边界依据生产方的完整字段契约,验证由插件负责、按提供方路由的 `llm/retry` 载荷,包括计时器、整数、状态、提供方延迟和非空诊断字段的边界。有效事件会移除对应失败步骤的流式输出片段,并在该事件的序列位置插入一条持久的重试提示。该提示在后续重试轮次开始前为 `scheduled`;源轮次中止或释放会将其标记为 `cancelled`,重试轮次则会将其标记为 `started`。normal mode 提示携带其有限上限;always mode 提示则保持显式无界。窗口重建与历史回放应用相同的投影,因此刷新后,来自已丢弃尝试的日志分片绝不会重新显示为中断回复。没有 `llm/retry` 的终止轮次保留现有行为:可见但尚未定稿的输出会冻结为中断的 assistant 节点。 ## 会话模型选择 diff --git a/packages/client/runtime/package.json b/packages/client/runtime/package.json index 850b504fb3..a3897e9523 100644 --- a/packages/client/runtime/package.json +++ b/packages/client/runtime/package.json @@ -50,6 +50,7 @@ }, "devDependencies": { "@deepseek-ai/dsh-invariants": "workspace:^", + "@deepseek-ai/dsh-timeout": "workspace:^", "@types/react": "~18.3.1", "cordis": "^4.0.0-rc.7" }, diff --git a/packages/client/runtime/src/client/sessions/conversation.ts b/packages/client/runtime/src/client/sessions/conversation.ts index 6d1a448685..530d47f197 100644 --- a/packages/client/runtime/src/client/sessions/conversation.ts +++ b/packages/client/runtime/src/client/sessions/conversation.ts @@ -97,6 +97,11 @@ export type ModelRetryNode = LlmRetryEventData & { seq: number /** Unix epoch ms from the llm/retry session event. */ time: number + /** + * Client-derived lifecycle: scheduled until a retry turn starts, started + * once it does, or cancelled when the failed turn aborts first. + */ + retryState: 'scheduled' | 'started' | 'cancelled' } /** A tool result paired (when in-window) with its call head. */ diff --git a/packages/client/runtime/src/client/sessions/session.ts b/packages/client/runtime/src/client/sessions/session.ts index d8e0bb7978..9be61438d1 100644 --- a/packages/client/runtime/src/client/sessions/session.ts +++ b/packages/client/runtime/src/client/sessions/session.ts @@ -13,8 +13,8 @@ import type { import { transportError } from '@deepseek-ai/dsh-host-apiproxy/api' import type { SessionFace } from '../contract/session.ts' import type { - CodeSubCall, ComposerPhase, ConversationNode, ConversationSnapshot, OpenState, - PromptError, QueuedMessage, RunningToolCall, + CodeSubCall, ComposerPhase, ConversationNode, ConversationSnapshot, ModelRetryNode, + OpenState, PromptError, QueuedMessage, RunningToolCall, } from './conversation.ts' import type { PendingInteraction } from './pending.ts' import { PendingWait } from './pending.ts' @@ -27,6 +27,10 @@ import type { ProjectionsBaseline } from './projection-store.ts' /** Messages requested per history page. */ export const PAGE_MESSAGES = 50 +// Browser bundles cannot value-import the host timeout library. This protocol +// bound is pinned to @deepseek-ai/dsh-timeout's MAX_TIMER_DELAY_MS in tests. +const MAX_RETRY_DELAY_MS = 2_147_483_647 + /** Manager-owned observers of a Session object's local state edges. */ export interface SessionOptions { /** @@ -637,6 +641,7 @@ export class Session implements SessionFace { kind: 'model-retry', seq: event.seq, time: event.time, + retryState: 'scheduled', ...data, }) this.derivedRev++ @@ -702,6 +707,10 @@ export class Session implements SessionFace { return } switch (event.type) { + case 'turn/start': { + if (event.data.trigger.kind === 'retry') this.settleScheduledRetry('started') + return + } case 'assistant/chunk': { const { turn, step, chunk } = event.data if (this.partial === null || this.partial.turn !== turn || this.partial.step !== step) { @@ -730,6 +739,9 @@ export class Session implements SessionFace { return } case 'turn/end': { + if (event.data.reason.kind === 'aborted' || event.data.reason.kind === 'disposed') { + this.settleScheduledRetry('cancelled', event.data.turn) + } // Aborted turns never finalize. The accumulated partial is VALUE, not residue: freeze it // into an interrupted terminal node (pulse stops, text survives) instead of deleting it. // Shared by live and window-replay paths, so a refresh reconstructs the same frozen node @@ -771,6 +783,27 @@ export class Session implements SessionFace { } } + /** + * Settle the newest scheduled retry, optionally restricted to its failed turn. + * @param retryState - next client projection state to publish. + * @param turn - failed turn required for cancellation; omitted for the next retry turn start. + */ + private settleScheduledRetry( + retryState: Exclude, + turn?: number, + ): void { + const index = this.derivedNodes.findLastIndex(node => + node.kind === 'model-retry' + && node.retryState === 'scheduled' + && (turn === undefined || node.turn === turn)) + if (index < 0) return + const node = this.derivedNodes[index] + /* v8 ignore next -- findLastIndex's predicate narrows the indexed node only at runtime. */ + if (node?.kind !== 'model-retry') return + this.derivedNodes[index] = { ...node, retryState } + this.derivedRev++ + } + /** Re-derive state (partial/openCalls/derivedNodes) from raw window events after a rebuild — keeps * paging/stitching consistent, and makes live handling and history replay converge on the same * retry notices and interrupted nodes. */ @@ -854,37 +887,49 @@ function parseRetryEventData(value: unknown): LlmRetryEventData | null { const failure = data.failure if (failure === null || typeof failure !== 'object') return null const failureData = failure as Record - if (!nonNegativeInteger(data.turn) - || !nonNegativeInteger(data.step) + if (!nonNegativeSafeInteger(data.turn) + || !nonNegativeSafeInteger(data.step) || typeof data.provider !== 'string' || data.provider.length === 0 || typeof data.policyKey !== 'string' || data.policyKey.length === 0 - || !positiveInteger(data.retry) + || !positiveSafeInteger(data.retry) || typeof data.delayMs !== 'number' || !Number.isFinite(data.delayMs) || data.delayMs < 0 + || data.delayMs > MAX_RETRY_DELAY_MS || typeof failureData.message !== 'string' - || typeof failureData.code !== 'string') return null + || failureData.message.length === 0 + || typeof failureData.code !== 'string' + || failureData.code.length === 0) return null if (data.mode === 'normal') { - if (!positiveInteger(data.maxRetries) || data.retry > data.maxRetries) return null + if (!positiveSafeInteger(data.maxRetries) || data.retry > data.maxRetries) return null } else if (data.mode === 'always') { if ('maxRetries' in data) return null } else { return null } - const optionalNumbers = [failureData.status, failureData.providerRetryAfterMs] - if (optionalNumbers.some(item => item !== undefined && (typeof item !== 'number' || !Number.isFinite(item)))) return null - if (failureData.requestId !== undefined && typeof failureData.requestId !== 'string') return null + if (failureData.status !== undefined + && (typeof failureData.status !== 'number' + || !Number.isInteger(failureData.status) + || failureData.status < 100 + || failureData.status > 599)) return null + if (failureData.providerRetryAfterMs !== undefined + && (typeof failureData.providerRetryAfterMs !== 'number' + || !Number.isFinite(failureData.providerRetryAfterMs) + || failureData.providerRetryAfterMs <= 0)) return null + if (failureData.requestId !== undefined + && (typeof failureData.requestId !== 'string' + || failureData.requestId.length === 0)) return null return data as unknown as LlmRetryEventData } -function nonNegativeInteger(value: unknown): value is number { - return typeof value === 'number' && Number.isInteger(value) && value >= 0 +function nonNegativeSafeInteger(value: unknown): value is number { + return typeof value === 'number' && Number.isSafeInteger(value) && value >= 0 } -function positiveInteger(value: unknown): value is number { - return nonNegativeInteger(value) && value > 0 +function positiveSafeInteger(value: unknown): value is number { + return nonNegativeSafeInteger(value) && value > 0 } /** diff --git a/packages/client/runtime/tests/event-script.ts b/packages/client/runtime/tests/event-script.ts index d77c3a7251..b96fffaae9 100644 --- a/packages/client/runtime/tests/event-script.ts +++ b/packages/client/runtime/tests/event-script.ts @@ -81,7 +81,7 @@ export const ev = { failure: { code: 'TRANSPORT', message }, }, }), - turnEnd: (seq: number, turn: number, reason: 'completed' | 'cancelled' = 'completed'): SessionEvent => + turnEnd: (seq: number, turn: number, reason: 'completed' | 'aborted' | 'disposed' = 'completed'): SessionEvent => at(seq, { type: 'turn/end', data: { turn, reason: { kind: reason } } }), commandRun: (seq: number, commandId: string, name: string, args = ''): SessionEvent => at(seq, { type: 'command/run', data: { commandId, name, args, source: { kind: 'user' } } }), diff --git a/packages/client/runtime/tests/session.spec.ts b/packages/client/runtime/tests/session.spec.ts index 99bd9bf0c3..df465b063f 100644 --- a/packages/client/runtime/tests/session.spec.ts +++ b/packages/client/runtime/tests/session.spec.ts @@ -8,6 +8,7 @@ import { describe, expect, it, vi } from 'vitest' import type { SessionEvent } from '@deepseek-ai/dsh-session/types' +import { MAX_TIMER_DELAY_MS } from '@deepseek-ai/dsh-timeout' import type { SessionId } from '@deepseek-ai/dsh-client-connection/client' import { Session } from '../src/client/sessions/session.ts' import { FakeApiClient, deferred, err, ok } from './fake-api.ts' @@ -179,6 +180,7 @@ describe('live event path', () => { expect(snapshot.partial).toBeNull() expect(snapshot.nodes.at(-1)).toMatchObject({ kind: 'model-retry', + retryState: 'scheduled', turn: 1, step: 0, provider: 'fake', @@ -194,6 +196,7 @@ describe('live event path', () => { for (const event of retryTurn.slice(7)) feed(event) snapshot = session.getSnapshot() expect(snapshot.nodes.slice(-2).map(node => node.kind)).toEqual(['model-retry', 'assistant']) + expect(snapshot.nodes.at(-2)).toMatchObject({ kind: 'model-retry', retryState: 'started' }) expect(snapshot.nodes.at(-1)).toMatchObject({ kind: 'assistant', blocks: [{ kind: 'text', text: '完整回复' }] }) const replay = makeSession() @@ -203,31 +206,88 @@ describe('live event path', () => { expect(replay.session.getSnapshot().partial).toBeNull() }) - it('ignores malformed retry payloads without retracting the current partial', async () => { + it('rejects retry payloads outside the producer contract without retracting the current partial', async () => { const { session } = await opened() const feed = (event: SessionEvent) => { session.handleMuxEnvelope('r' as never, { type: 'session/event', sessionId: SID, event }) } feed(ev.turnStart(6, 1)) feed(ev.chunkStart(7, 1)) feed(ev.chunkText(8, 1, '仍在生成')) + const valid = { + turn: 1, step: 0, + provider: 'fake', mode: 'normal', policyKey: 'fake-normal', + retry: 1, maxRetries: 2, delayMs: 500, + failure: { code: 'TRANSPORT', message: 'temporary failure' }, + } + const invalid = [ + { ...valid, turn: Number.MAX_SAFE_INTEGER + 1 }, + { ...valid, step: Number.MAX_SAFE_INTEGER + 1 }, + { ...valid, provider: '' }, + { ...valid, policyKey: '' }, + { ...valid, retry: Number.MAX_SAFE_INTEGER + 1 }, + { ...valid, maxRetries: Number.MAX_SAFE_INTEGER + 1 }, + { ...valid, delayMs: -1 }, + { ...valid, delayMs: Number.POSITIVE_INFINITY }, + { ...valid, delayMs: MAX_TIMER_DELAY_MS + 1 }, + { ...valid, failure: { ...valid.failure, message: '' } }, + { ...valid, failure: { ...valid.failure, code: '' } }, + { ...valid, failure: { ...valid.failure, status: '429' } }, + { ...valid, failure: { ...valid.failure, status: 99 } }, + { ...valid, failure: { ...valid.failure, status: 429.5 } }, + { ...valid, failure: { ...valid.failure, status: 600 } }, + { ...valid, failure: { ...valid.failure, providerRetryAfterMs: 0 } }, + { ...valid, failure: { ...valid.failure, providerRetryAfterMs: Number.POSITIVE_INFINITY } }, + { ...valid, failure: { ...valid.failure, requestId: 1 } }, + { ...valid, failure: { ...valid.failure, requestId: '' } }, + ] const errorSpy = vi.spyOn(console, 'error').mockImplementation(() => undefined) try { - feed(at(9, { - type: 'llm/retry', - data: { - turn: 1, step: 0, - provider: 'fake', mode: 'normal', policyKey: 'fake-normal', - retry: 3, maxRetries: 2, delayMs: 500, - failure: { code: 'TRANSPORT', message: 'bad budget' }, - }, - })) + for (const [index, data] of invalid.entries()) { + feed(at(9 + index, { type: 'llm/retry', data })) + } expect(session.getSnapshot().partial?.blocks).toEqual([{ kind: 'text', text: '仍在生成' }]) expect(session.getSnapshot().nodes.filter(node => node.kind === 'model-retry')).toEqual([]) + expect(errorSpy).toHaveBeenCalledTimes(invalid.length) expect(errorSpy).toHaveBeenCalledWith('[web-runtime] ignored malformed llm/retry event at seq 9') } finally { errorSpy.mockRestore() } }) + it('accepts complete retry payloads at the producer field boundaries', async () => { + const { session } = await opened() + session.handleMuxEnvelope('r' as never, { + type: 'session/event', + sessionId: SID, + event: at(6, { + type: 'llm/retry', + data: { + turn: Number.MAX_SAFE_INTEGER, + step: Number.MAX_SAFE_INTEGER, + provider: 'fake', + mode: 'normal', + policyKey: 'fake-normal', + retry: Number.MAX_SAFE_INTEGER, + maxRetries: Number.MAX_SAFE_INTEGER, + delayMs: MAX_TIMER_DELAY_MS, + failure: { + code: 'RATE_LIMIT', + message: 'provider busy', + status: 599, + providerRetryAfterMs: Number.MIN_VALUE, + requestId: 'req-1', + }, + }, + }), + }) + expect(session.getSnapshot().nodes.at(-1)).toMatchObject({ + kind: 'model-retry', + retryState: 'scheduled', + retry: Number.MAX_SAFE_INTEGER, + delayMs: MAX_TIMER_DELAY_MS, + failure: { status: 599, providerRetryAfterMs: Number.MIN_VALUE, requestId: 'req-1' }, + }) + }) + it('projects always-mode retries and rejects mode-specific maximums or unknown modes', async () => { const { session } = await opened() const feed = (event: SessionEvent) => { session.handleMuxEnvelope('r' as never, { type: 'session/event', sessionId: SID, event }) } @@ -244,6 +304,7 @@ describe('live event path', () => { })) expect(session.getSnapshot().nodes.at(-1)).toMatchObject({ kind: 'model-retry', + retryState: 'scheduled', mode: 'always', retry: 3, }) @@ -273,6 +334,27 @@ describe('live event path', () => { } }) + it.each(['aborted', 'disposed'] as const)( + 'marks a scheduled retry as cancelled when its failed turn ends %s', + async (reason) => { + const { session } = await opened() + const feed = (event: SessionEvent) => { + session.handleMuxEnvelope('r' as never, { type: 'session/event', sessionId: SID, event }) + } + feed(ev.turnStart(6, 1)) + feed(ev.retry(7, 1)) + expect(session.getSnapshot().nodes.at(-1)).toMatchObject({ + kind: 'model-retry', + retryState: 'scheduled', + }) + feed(ev.turnEnd(8, 1, reason)) + expect(session.getSnapshot().nodes.at(-1)).toMatchObject({ + kind: 'model-retry', + retryState: 'cancelled', + }) + }, + ) + it('freezes an unfinalized partial into an interrupted node on turn/end (cancel path)', async () => { const { session } = await opened() const feed = (event: SessionEvent) => { session.handleMuxEnvelope('r' as never, { type: 'session/event', sessionId: SID, event }) } @@ -280,7 +362,7 @@ describe('live event path', () => { feed(ev.user(7, '要被打断的')) feed(ev.chunkStart(8, 1)) feed(ev.chunkText(9, 1, '说到一半')) - feed(ev.turnEnd(10, 1, 'cancelled')) // no assistant/message ever arrives + feed(ev.turnEnd(10, 1, 'aborted')) // no assistant/message ever arrives const snapshot = session.getSnapshot() expect(snapshot.partial).toBeNull() const frozen = snapshot.nodes.at(-1) @@ -299,7 +381,7 @@ describe('live event path', () => { expect(session.getSnapshot().runningCalls).toEqual([]) // Second call never resolves: turn/end freezes it as an error card. feed(ev.toolCall(9, 1, 'c2', 'slow_tool', '{}')) - feed(ev.turnEnd(10, 1, 'cancelled')) + feed(ev.turnEnd(10, 1, 'aborted')) const snapshot = session.getSnapshot() expect(snapshot.runningCalls).toEqual([]) expect(snapshot.nodes.at(-1)).toMatchObject({ @@ -615,7 +697,7 @@ describe('remaining branches', () => { const feed = (event: SessionEvent) => { session.handleMuxEnvelope('r' as never, { type: 'session/event', sessionId: SID, event }) } feed(ev.turnStart(6, 1)) feed(ev.chunkStart(7, 1)) // empty text block only, no delta - feed(ev.turnEnd(8, 1, 'cancelled')) + feed(ev.turnEnd(8, 1, 'aborted')) const snapshot = session.getSnapshot() expect(snapshot.partial).toBeNull() expect(snapshot.nodes.filter(n => n.kind === 'assistant' && (n as { interrupted?: true }).interrupted)).toEqual([]) @@ -629,7 +711,7 @@ describe('remaining branches', () => { feed(ev.turnStart(6, 1)) feed(ev.toolCall(7, 1, 'turn1-call', 'echo', '{}')) feed(ev.toolCall(8, 2, 'turn2-call', 'echo', '{}')) // stray call attributed to a later turn - feed(ev.turnEnd(9, 1, 'cancelled')) + feed(ev.turnEnd(9, 1, 'aborted')) const snapshot = session.getSnapshot() expect(snapshot.runningCalls.map(c => c.callId)).toEqual(['turn2-call']) expect(snapshot.nodes.at(-1)).toMatchObject({ kind: 'tool-result', callId: 'turn1-call', isError: true }) @@ -723,7 +805,7 @@ describe('remaining branches', () => { const feed = (event: SessionEvent) => { session.handleMuxEnvelope('r' as never, { type: 'session/event', sessionId: SID, event }) } feed(ev.turnStart(6, 1)) feed(at(7, { type: 'assistant/chunk', data: { turn: 1, step: 0, chunk: { type: 'tool-call-delta', index: 0, id: 'c1', name: 'echo', argumentsDelta: '{' } } })) - feed(ev.turnEnd(8, 1, 'cancelled')) + feed(ev.turnEnd(8, 1, 'aborted')) const frozen = session.getSnapshot().nodes.at(-1) expect(frozen).toMatchObject({ kind: 'assistant', interrupted: true, blocks: [{ kind: 'tool-call', callId: 'c1' }] }) }) diff --git a/packages/client/ui-conversation/README.i18n.yaml b/packages/client/ui-conversation/README.i18n.yaml index ffc82ea731..4e7f3e017e 100644 --- a/packages/client/ui-conversation/README.i18n.yaml +++ b/packages/client/ui-conversation/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/client/ui-conversation/README.md -README.md: cd72aaca7c82a02777ededdf542bf5272359fb77 -README.zh.md: ffbeace6d5b7c6af8b4ce6e50e643ea65f446584 +README.md: 408a76ed86c6a48762db2bae8534e950219c816a +README.zh.md: 68128c7a4c271b491e4a1463e3c7ce4a4e86b1eb diff --git a/packages/client/ui-conversation/README.md b/packages/client/ui-conversation/README.md index cd72aaca7c..408a76ed86 100644 --- a/packages/client/ui-conversation/README.md +++ b/packages/client/ui-conversation/README.md @@ -10,7 +10,7 @@ The view ring IS a slot: the conversation registration declares the `'conversati Generic tool rows classify the built-in bash, read, search, write, edit, and run_code names into dedicated visual variants. The filesystem variants render the edit icon and a path summary; that path is a hover-underline link that opens the file with the host OS default application (`host.openPath`, relative paths resolve against the session cwd). Tool rows are not whole-row click targets and do not open the details panel. The code variant summarizes with the model-authored `description` and expands to the program itself; its logged sub-dispatches render as always-visible nested rows through the SAME keyed toolview hole (custom registrations and the GenericToolCard fallback apply to sub-rows unchanged). Cordis lifecycle tools reuse those generic variants while presenting `Inspect`, `Mount temporary Plugin`, and `Unmount temporary Plugin` with a shared Cordis accent; mount keeps the code variant's expandable source rendering. -The chat flow projects consecutive model-retry nodes across retry turns into one stable, muted status row updated to the latest attempt; every retry event remains in the runtime snapshot and session log. Its frontend countdown derives from the scheduled delay, rounds remaining time up to seconds, and has a one-second floor. The latest unresolved retry uses a left-to-right text shimmer, then settles to a static completed label. Normal policy rows show the finite retry maximum; always policy rows show `∞`. Activating the row reveals the latest exact retry delay and failure message. The client runtime removes each failed step's streaming tail before its retry node arrives, while the status remains visible after a later attempt succeeds. +The chat flow projects consecutive model-retry nodes across retry turns into one stable, muted status row updated to the latest attempt; every retry event remains in the runtime snapshot and session log. Its frontend countdown anchors the scheduled delay to client receipt, avoiding host/browser clock skew, rounds remaining time up to seconds, and has a one-second floor. The latest unresolved retry uses a left-to-right text shimmer. Subsequent turn facts distinguish an attempt that started from one cancelled during backoff, while the Host running bit only controls the live animation; the row then shows a static completed or cancelled label. Normal policy rows show the finite retry maximum; always policy rows show `∞`. Activating the row reveals the latest exact retry delay and failure message. The client runtime removes each failed step's streaming tail before its retry node arrives, while the status remains visible after a later attempt succeeds. Tool rows are slots too — the standalone tool ring (`ToolViewRegistry`/`ctx.toolviews`/outlet) is retired. The chat entry declares the keyed `'conversation.chat.toolview'` hole (session scope; the key space is runtime-open); its render site dispatches per row via `entryKey: toolName` with `GenericToolCard` as the call-site `fallback`. The owner payload is the uniform `ToolRowOwnerProps` (`callId`/`toolName`/`block`/`openFile`) and `ToolRowProps` pre-composes it with the session standard kit. A registrant is a plain plugin: `ctx.slots.register({ name: 'conversation.chat.toolview', key: '', inject? }, Row)` with `inject: ['slots', 'conversation']` as the load-order seam (apply mounts ConversationService after the chat registration, so the service being present guarantees the slot is declared); session differentiation happens inside the component (`useSessions` reading `parentId` — the bash sample is the third-party-posture exemplar). Trajectory/waterfall toolview slots share this shape and land with their own render sites (RendersCheck rejects a declaration nobody renders). diff --git a/packages/client/ui-conversation/README.zh.md b/packages/client/ui-conversation/README.zh.md index ffbeace6d5..68128c7a4c 100644 --- a/packages/client/ui-conversation/README.zh.md +++ b/packages/client/ui-conversation/README.zh.md @@ -10,7 +10,7 @@ 通用工具行把内置的 bash、read、search、write、edit 和 run_code 名称归入专用视觉变体。文件系统变体会渲染 edit 图标和路径摘要;该路径是悬停下划线链接,点击后通过宿主操作系统的默认应用打开文件(`host.openPath`,相对路径相对会话 cwd 解析)。工具行不再是整行点击目标,也不会打开 details 面板。code 变体以模型撰写的 `description` 作摘要,展开后显示程序本身;其已记录的子调用经由同一个键控 toolview 空位渲染为始终可见的嵌套行(自定义注册和 GenericToolCard fallback 原样适用于子行)。Cordis 生命周期工具复用这些通用变体,同时以统一的 Cordis 强调色呈现 `Inspect`、`Mount temporary Plugin` 和 `Unmount temporary Plugin`;mount 行保留 code 变体的可展开源码渲染。 -聊天流会将跨重试轮次连续出现的模型重试节点投影为一个稳定的弱化状态行,并用最新一次尝试更新该行;每个重试事件仍保留在运行时快照与会话日志中。前端倒计时由计划延迟派生,剩余时间向上取整到秒,且下限为 1 秒。最近一次尚未完成的重试会显示从左到右的文字渐变动画,完成后则稳定显示为静态的已完成标签。normal 策略行显示有限重试上限;always 策略行显示 `∞`。激活该行会显示最近一次重试的精确延迟和失败消息。客户端运行时会在相应重试节点到达前移除每个失败步骤的流式输出尾部;后续某次尝试成功后,该状态仍保持可见。 +聊天流会将跨重试轮次连续出现的模型重试节点投影为一个稳定的弱化状态行,并用最新一次尝试更新该行;每个重试事件仍保留在运行时快照与会话日志中。前端倒计时以客户端收到事件的时刻为计划延迟的起点,避免 Host 与浏览器的时钟偏差;剩余时间向上取整到秒,且下限为 1 秒。最近一次尚未完成的重试会显示从左到右的文字渐变动画。后续轮次事实用于区分已开始的尝试与在退避期间取消的尝试,Host 的 running 位只控制实时动画;随后该行会显示静态的已完成或已取消标签。normal 策略行显示有限重试上限;always 策略行显示 `∞`。激活该行会显示最近一次重试的精确延迟和失败消息。客户端运行时会在相应重试节点到达前移除每个失败步骤的流式输出尾部;后续某次尝试成功后,该状态仍保持可见。 工具行同样是 slot:独立工具环(`ToolViewRegistry`/`ctx.toolviews`/outlet)已经退役。聊天配置项声明键控的 `'conversation.chat.toolview'` 空位(Session scope;key 空间在运行时开放);其渲染点逐行通过 `entryKey: toolName` 分发,并以 `GenericToolCard` 作为调用点 `fallback`。owner 载荷是统一的 `ToolRowOwnerProps`(`callId`/`toolName`/`block`/`openFile`),`ToolRowProps` 则预先将其与 Session 标准工具包组合。注册方只是普通插件:`ctx.slots.register({ name: 'conversation.chat.toolview', key: '', inject? }, Row)`,以 `inject: ['slots', 'conversation']` 作为加载顺序 seam(apply 在聊天注册后挂载 ConversationService,因此服务存在即可保证 slot 已声明);Session 区分在组件内部完成(`useSessions` 读取 `parentId`,bash 示例是第三方姿态的范例)。Trajectory/waterfall 工具视图 slot 共享此形状,并随各自的渲染点落地(RendersCheck 会拒绝没有任何渲染方的声明)。 diff --git a/packages/client/ui-conversation/src/client/chat/ChatView.tsx b/packages/client/ui-conversation/src/client/chat/ChatView.tsx index c45c6f1c8c..cafbf4dfcd 100644 --- a/packages/client/ui-conversation/src/client/chat/ChatView.tsx +++ b/packages/client/ui-conversation/src/client/chat/ChatView.tsx @@ -50,7 +50,7 @@ function activeRetrySeq(nodes: readonly ConversationNode[], running: boolean): n for (let index = nodes.length - 1; index >= 0; index -= 1) { const node = nodes[index] if (node === undefined) continue - if (node.kind === 'model-retry') return node.seq + if (node.kind === 'model-retry') return node.retryState === 'cancelled' ? null : node.seq if (node.kind === 'assistant' || node.kind === 'user') return null } return null diff --git a/packages/client/ui-conversation/src/client/chat/MessageItem.tsx b/packages/client/ui-conversation/src/client/chat/MessageItem.tsx index cdd0efacad..5c3c99d1e0 100644 --- a/packages/client/ui-conversation/src/client/chat/MessageItem.tsx +++ b/packages/client/ui-conversation/src/client/chat/MessageItem.tsx @@ -4,7 +4,7 @@ // off the snapshot cache; memo holds across streaming because unchanged nodes // keep their references. -import { memo, useCallback, useEffect, useState, type ReactNode } from 'react' +import { memo, useCallback, useEffect, useMemo, useState, type ReactNode } from 'react' import type { ContextMessageNode, ModelRetryNode, SteeringMessageNode, UnknownSurfaceNode, UserMessageNode, } from '@deepseek-ai/dsh-client-runtime/client' @@ -40,7 +40,9 @@ interface RetryCountdown { } function ModelRetryItem({ node, active }: { node: ModelRetryNode; active: boolean }) { - const deadline = node.time + node.delayMs + // Anchor the host-scheduled delay to this browser's first render of the + // retry node. Host event time and Date.now() may belong to different clocks. + const deadline = useMemo(() => Date.now() + node.delayMs, [node.delayMs, node.seq]) const scheduledSeconds = retrySeconds(node.delayMs) const maximum = node.mode === 'normal' ? node.maxRetries : '∞' const [countdown, setCountdown] = useState(() => ({ @@ -69,11 +71,19 @@ function ModelRetryItem({ node, active }: { node: ModelRetryNode; active: boolea return () => { window.clearInterval(timer) } }, [active, deadline]) + const label = active + ? '正在重试模型请求' + : node.retryState === 'cancelled' + ? '模型请求重试已取消' + : node.retryState === 'started' + ? '已重试模型请求' + : '等待重试模型请求' + return (
- {active ? '正在重试' : '已重试'}模型请求({node.retry}/{maximum}) · {active ? remainingSeconds : scheduledSeconds}s + {label}({node.retry}/{maximum}) · {active ? remainingSeconds : scheduledSeconds}s
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 7eda67ba91..502602c073 100644 --- a/packages/client/ui-conversation/tests/chat-branch-tails.spec.tsx +++ b/packages/client/ui-conversation/tests/chat-branch-tails.spec.tsx @@ -125,6 +125,7 @@ describe('MessageItem arms', () => { kind: 'model-retry', seq: 5, time: 10_000, + retryState: 'scheduled', turn: 1, step: 0, provider: 'mock', @@ -157,6 +158,7 @@ describe('MessageItem arms', () => { kind: 'model-retry', seq: 6, time: 12_100, + retryState: 'scheduled', turn: 2, step: 0, provider: 'mock', @@ -180,6 +182,7 @@ describe('MessageItem arms', () => { kind: 'model-retry', seq: 6, time: 12_100, + retryState: 'started', turn: 2, step: 0, provider: 'mock', @@ -200,6 +203,7 @@ describe('MessageItem arms', () => { kind: 'model-retry', seq: 7, time: 12_100, + retryState: 'started', turn: 3, step: 0, provider: 'mock', @@ -212,6 +216,26 @@ describe('MessageItem arms', () => { />, ) 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', () => { @@ -221,6 +245,7 @@ describe('MessageItem arms', () => { kind: 'model-retry', seq: 5, time: 10_000, + retryState: 'scheduled', turn: 1, step: 0, provider: 'mock', @@ -232,7 +257,7 @@ describe('MessageItem arms', () => { failure: { code: 'TRANSPORT', message: '连接被重置' }, } as const const view = render() - expect(view.getByRole('status').textContent).toBe('已重试模型请求(1/2) · 5s') + expect(view.getByRole('status').textContent).toBe('等待重试模型请求(1/2) · 5s') act(() => { vi.advanceTimersByTime(4_200) }) view.rerender() diff --git a/packages/client/ui-conversation/tests/chat-view.spec.tsx b/packages/client/ui-conversation/tests/chat-view.spec.tsx index 9ae2a6519d..8bc196aa80 100644 --- a/packages/client/ui-conversation/tests/chat-view.spec.tsx +++ b/packages/client/ui-conversation/tests/chat-view.spec.tsx @@ -67,6 +67,7 @@ const assistant = (seq: number, text: string): AssistantMessageNode => ({ }) 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: '连接被重置' }, @@ -232,15 +233,25 @@ describe('ChatView', () => { expect(view.getByRole('status').textContent).toBe('正在重试模型请求(2/2) · 1s') act(() => { - h.set({ nodes: [user(1, 'try'), retryNode, nextRetry, context, assistant(5, 'done')] }) + 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)], running: false }) + h.set({ nodes: [user(1, 'try'), { ...retry(6), retryState: 'cancelled' }], running: true }) }) expect(disclosure?.dataset.active).toBeUndefined() + expect(view.getByRole('status').textContent).toContain('重试已取消') }) it('renders assistant Markdown across history, streaming, final, and interrupted states while user text stays literal', () => { diff --git a/packages/client/ui-trajectory/README.i18n.yaml b/packages/client/ui-trajectory/README.i18n.yaml index b07fe7a1fd..57cff40b19 100644 --- a/packages/client/ui-trajectory/README.i18n.yaml +++ b/packages/client/ui-trajectory/README.i18n.yaml @@ -1,6 +1,6 @@ # Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each # 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 -README.md: 9e7dee8d5572baad13d7598d1fbde7b042dd5ab4 -README.zh.md: da14d5265c16acb4e75d21bc445ef9702c83e697 +# pnpm run verify-translation-pairing --write packages/client/ui-trajectory/README.md +README.md: fa8dee1b40c661f46a8e9962bd4606267b030a23 +README.zh.md: 0e4c6071d7cff04d2f1b40945332d9b66ca2da1b diff --git a/packages/client/ui-trajectory/README.md b/packages/client/ui-trajectory/README.md index 9e7dee8d55..fa8dee1b40 100644 --- a/packages/client/ui-trajectory/README.md +++ b/packages/client/ui-trajectory/README.md @@ -2,7 +2,7 @@ English | [中文](README.zh.md) -Trajectory turn-list chrome (sticky Turn / Message·Step groups / step cells) plus Waterfall placeholder; the pure-consumer minimal plugin exemplar (registers two view tabs into the conversation's `'conversation.view'` slot ring, provides no service, declares no Context merge). Contract: api-contracts v3 §8. +Trajectory turn-list chrome (sticky Turn / Message·Step groups / step cells) plus Waterfall placeholder; the pure-consumer minimal plugin exemplar (registers two view tabs into the conversation's `'conversation.view'` slot ring, provides no service, declares no Context merge). Conversation nodes carrying a turn, including model-retry notices without assistant output, anchor their own trajectory span instead of inheriting the preceding turn. Contract: api-contracts v3 §8. ## Model Experience diff --git a/packages/client/ui-trajectory/README.zh.md b/packages/client/ui-trajectory/README.zh.md index da14d5265c..0e4c6071d7 100644 --- a/packages/client/ui-trajectory/README.zh.md +++ b/packages/client/ui-trajectory/README.zh.md @@ -2,7 +2,7 @@ [English](README.md) | 中文 -轨迹轮次列表 chrome(吸顶 Turn/Message·Step 分组/步骤单元格)及 Waterfall 占位符;这是纯消费方最小插件范例(向会话的 `'conversation.view'` slot 环注册两个视图标签页,不提供服务,也不声明 Context 合并)。契约:api-contracts v3 §8。 +轨迹轮次列表 chrome(吸顶 Turn/Message·Step 分组/步骤单元格)及 Waterfall 占位符;这是纯消费方最小插件范例(向会话的 `'conversation.view'` slot 环注册两个视图标签页,不提供服务,也不声明 Context 合并)。携带轮次的会话节点(包括没有 assistant 输出的模型重试提示)会锚定自身的轨迹区段,而不会继承前一个轮次。契约:api-contracts v3 §8。 ## 模型体验 diff --git a/packages/client/ui-trajectory/src/client/spans.ts b/packages/client/ui-trajectory/src/client/spans.ts index 585a336333..e52c152725 100644 --- a/packages/client/ui-trajectory/src/client/spans.ts +++ b/packages/client/ui-trajectory/src/client/spans.ts @@ -43,8 +43,9 @@ export interface SpanStats { /** * Fold snapshot nodes into per-turn spans. Only assistant nodes carry a turn - * number; user/steering/context/tool nodes attach to the turn last seen in - * sequence order (turn 0 collects the pre-assistant prologue). + * number; retry and steering nodes also carry their owning turn, while + * user/context/tool nodes attach to the turn last seen in sequence order + * (turn 0 collects the pre-assistant prologue). * @param nodes - snapshot nodes in surface order. * @returns spans ordered by first appearance. */ @@ -85,7 +86,7 @@ export function deriveSpanStats(spans: readonly TurnSpan[]): SpanStats { } function hasTurn(node: ConversationNode): node is ConversationNode & { turn: number } { - return node.kind === 'assistant' || node.kind === 'steering' + return node.kind === 'assistant' || node.kind === 'steering' || node.kind === 'model-retry' } /** diff --git a/packages/client/ui-trajectory/tests/views.spec.tsx b/packages/client/ui-trajectory/tests/views.spec.tsx index 917beaccc6..432b06ccc1 100644 --- a/packages/client/ui-trajectory/tests/views.spec.tsx +++ b/packages/client/ui-trajectory/tests/views.spec.tsx @@ -208,18 +208,21 @@ describe('tab switching in ConversationRoot', () => { }) describe('span derivation', () => { - it('attributes prologue to turn 0 and follows steering turn tags', () => { + it('attributes prologue to turn 0 and follows steering and retry turn tags', () => { const nodes = [ { kind: 'user', seq: 1 }, { kind: 'steering', seq: 2, turn: 5 }, { kind: 'user', seq: 3 }, + { kind: 'model-retry', seq: 4, turn: 6 }, + { kind: 'user', seq: 5 }, ] as unknown as ConversationSnapshot['nodes'] const spans = deriveSpans(nodes) expect(spans).toEqual([ { turn: 0, steps: 0, calls: 0, nodes: 1 }, { turn: 5, steps: 0, calls: 0, nodes: 2 }, + { turn: 6, steps: 0, calls: 0, nodes: 2 }, ]) - expect(deriveSpanStats(spans)).toEqual({ turns: 2, steps: 0, calls: 0 }) + expect(deriveSpanStats(spans)).toEqual({ turns: 3, steps: 0, calls: 0 }) }) it('empty inputs produce zero stats and standalone components render their empty forms', () => { diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 3bbb55d747..430dac9073 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -935,6 +935,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