feat(web): retry transient model requests

This commit is contained in:
Yichen Jiang
2026-07-26 14:09:31 +08:00
parent 84be7cc622
commit a430207427
32 changed files with 791 additions and 46 deletions

View File

@@ -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. 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 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 ### 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 ### 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. 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. - 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. - 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 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. - 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. - Direct `ctx.llm.stream()` callers remain single-attempt and receive the same structured failure facts.

View File

@@ -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; - 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. - 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 <path>` 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 <path>` 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) ## Install (developer machine)

View File

@@ -59,6 +59,9 @@
config: config:
agents: [] agents: []
- id: llm-retry
name: '@deepseek-ai/dsh-llm-retry'
# The native DeepSeek adapter; reads the key/base-url the boot's layered # The native DeepSeek adapter; reads the key/base-url the boot's layered
# .env loading (cwd then $DSH_HOME) left in the environment. # .env loading (cwd then $DSH_HOME) left in the environment.
- id: llm-deepseek - id: llm-deepseek

View File

@@ -41,6 +41,7 @@
"@deepseek-ai/dsh-host-webserver": "workspace:^", "@deepseek-ai/dsh-host-webserver": "workspace:^",
"@deepseek-ai/dsh-llm": "workspace:^", "@deepseek-ai/dsh-llm": "workspace:^",
"@deepseek-ai/dsh-llm-deepseek": "workspace:^", "@deepseek-ai/dsh-llm-deepseek": "workspace:^",
"@deepseek-ai/dsh-llm-retry": "workspace:^",
"@deepseek-ai/dsh-paths": "workspace:^", "@deepseek-ai/dsh-paths": "workspace:^",
"@deepseek-ai/dsh-session": "workspace:^", "@deepseek-ai/dsh-session": "workspace:^",
"@deepseek-ai/dsh-session-persistence-jsonl": "workspace:^", "@deepseek-ai/dsh-session-persistence-jsonl": "workspace:^",

View File

@@ -25,6 +25,9 @@ const bundles = new Map(PLUGINS.map(plugin => [
interface FixtureTiming { interface FixtureTiming {
appendTitle(id: string, title: string): void 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 { interface FixtureWindow extends Window {
@@ -78,7 +81,7 @@ function titleSurfaces(label: string): { sidebar: string; breadcrumb: string; do
return { sidebar, breadcrumb, documentTitle: document.title } 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<HTMLElement>('#root') const root = document.querySelector<HTMLElement>('#root')
if (root === null) throw new Error('snapshot root missing') if (root === null) throw new Error('snapshot root missing')
act(() => { act(() => {
@@ -92,18 +95,26 @@ it('projects initial and revised durable titles through the built nine-plugin fi
void entry.run() void entry.run()
unmount = () => { entry.dispose() } unmount = () => { entry.dispose() }
}) })
}
async function selectFixtureSession(): Promise<void> {
const tree = await screen.findByRole('tree', { name: 'Sessions' }, { timeout: 10_000 }) const tree = await screen.findByRole('tree', { name: 'Sessions' }, { timeout: 10_000 })
const projectCount = await within(tree).findByText('4 sessions') const projectCount = await within(tree).findByText('4 sessions')
const projectRow = projectCount.closest<HTMLElement>('[role="treeitem"]') const projectRow = projectCount.closest<HTMLElement>('[role="treeitem"]')
if (projectRow === null) throw new Error('fixture project row missing') if (projectRow === null) throw new Error('fixture project row missing')
fireEvent.click(projectRow) fireEvent.click(projectRow)
const initialLabel = 'Fixture 历史会话' const initialRowLabel = await screen.findByText('Fixture 历史会话')
const initialRowLabel = await screen.findByText(initialLabel)
const initialRow = initialRowLabel.closest<HTMLElement>('[role="treeitem"]') const initialRow = initialRowLabel.closest<HTMLElement>('[role="treeitem"]')
if (initialRow === null) throw new Error('fixture session row missing') if (initialRow === null) throw new Error('fixture session row missing')
fireEvent.click(initialRow) 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`) }) await waitFor(() => { expect(document.title).toBe(`${initialLabel} — DeepSeek Harness`) })
const initial = titleSurfaces(initialLabel) 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`) await expect(`${JSON.stringify({ initial, revised }, null, 2)}\n`)
.toMatchFileSnapshot('./snapshots/session-title.json') .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<string, unknown>).__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')
})

View File

@@ -271,6 +271,98 @@ describe('dsh web keyless CLI smoke', () => {
rmSync(workspace, { recursive: true, force: true }) 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<void>(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<void>((resolveClose) => { child.once('close', () => { resolveClose() }) })
: Promise.resolve()
if (child.exitCode === null) child.kill('SIGTERM')
await closed
await new Promise<void>(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)', () => { describe.skipIf(!process.env.DEEPSEEK_API_KEY || notReady.length > 0)('web smoke (real host, real key, W5)', () => {

View File

@@ -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
}
}

View File

@@ -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` ## `@deepseek-ai/dsh-lsp-local`

View File

@@ -425,6 +425,8 @@ export function createFixtureApi(options: FixtureOptions = {}): ApiProxy {
let failNextHistory = false let failNextHistory = false
/** Force-enders for currently open stream generators (timing hook: simulated connection loss). */ /** Force-enders for currently open stream generators (timing hook: simulated connection loss). */
const streamBreakers = new Set<() => void>() const streamBreakers = new Set<() => void>()
/** Retry scenarios opened by timing hooks and completed in a later browser assertion phase. */
const retryScenarios = new Map<SessionId, { turn: number; failedStep: number }>()
// Timing-acceptance hooks (browser test backdoor): the in-memory fixture is ideally timed, which // 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 // 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) 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' } } }) 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. */ /** 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 { appendSilent(id: string, msg: string): void {
const log = logOf(sid(id)) const log = logOf(sid(id))

View File

@@ -19,6 +19,9 @@ interface TimingHooks {
failNextHistory(): void failNextHistory(): void
appendUser(id: string, msg: string): void appendUser(id: string, msg: string): void
appendTitle(id: string, title: 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 appendSilent(id: string, msg: string): void
breakStreams(): void breakStreams(): void
} }
@@ -489,9 +492,14 @@ describe('createFixtureApi', () => {
hooks.appendSilent('fx-alpha', '静默丢帧') hooks.appendSilent('fx-alpha', '静默丢帧')
hooks.appendUser('fx-alpha', '正常直播') hooks.appendUser('fx-alpha', '正常直播')
hooks.appendTitle('fx-alpha', 'Fixture 修订标题') hooks.appendTitle('fx-alpha', 'Fixture 修订标题')
hooks.beginModelRetry('fx-alpha')
hooks.scheduleModelRetry('fx-alpha')
hooks.completeModelRetry('fx-alpha')
await vi.waitFor(() => { 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' && 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/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) 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') const rawTitleIndex = seen.findIndex(f => f.type === 'session/event' && (f.event as { type: string }).type === 'session/title')

View File

@@ -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. `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 ## Model Experience
None, as the client runtime hosts browser-side services and the session object layer; nothing here reaches a model request. None, as the client runtime hosts browser-side services and the session object layer; nothing here reaches a model request.

View File

@@ -35,6 +35,7 @@
"@deepseek-ai/dsh-client-ui-slots": "workspace:^", "@deepseek-ai/dsh-client-ui-slots": "workspace:^",
"@deepseek-ai/dsh-host-apiproxy": "workspace:^", "@deepseek-ai/dsh-host-apiproxy": "workspace:^",
"@deepseek-ai/dsh-llm": "workspace:^", "@deepseek-ai/dsh-llm": "workspace:^",
"@deepseek-ai/dsh-llm-retry": "workspace:^",
"@deepseek-ai/dsh-session": "workspace:^", "@deepseek-ai/dsh-session": "workspace:^",
"immer": "^10.1.1", "immer": "^10.1.1",
"react": "^18.2.0", "react": "^18.2.0",

View File

@@ -25,7 +25,7 @@ export type {
} from './contract/store.ts' } from './contract/store.ts'
export type { export type {
AssistantBlock, AssistantMessageNode, ComposerPhase, ContextMessageNode, ConversationNode, AssistantBlock, AssistantMessageNode, ComposerPhase, ContextMessageNode, ConversationNode,
ConversationSnapshot, PendingPrompt, RunningToolCall, SessionIntentSnapshot, SessionIntentTarget, ConversationSnapshot, ModelRetryNode, PendingPrompt, RunningToolCall, SessionIntentSnapshot, SessionIntentTarget,
SteeringMessageNode, ToolResultNode, UnknownSurfaceNode, UserMessageNode, SteeringMessageNode, ToolResultNode, UnknownSurfaceNode, UserMessageNode,
} from './sessions/conversation.ts' } from './sessions/conversation.ts'
export { PendingWait } from './sessions/pending.ts' export { PendingWait } from './sessions/pending.ts'

View File

@@ -4,6 +4,7 @@
// string here (narrow to real brands when convenient). // string here (narrow to real brands when convenient).
import type { ContentBlock } from '@deepseek-ai/dsh-llm/types' import type { ContentBlock } from '@deepseek-ai/dsh-llm/types'
import type { LlmRetryEventData } from '@deepseek-ai/dsh-llm-retry/types'
import type { import type {
RpcError, SessionId, ToolCallView, ToolResultView, WorkspaceId, RpcError, SessionId, ToolCallView, ToolResultView, WorkspaceId,
} from '@deepseek-ai/dsh-client-connection/client' } from '@deepseek-ai/dsh-client-connection/client'
@@ -87,6 +88,20 @@ export interface ContextMessageNode {
meta?: unknown 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. */ /** A tool result paired (when in-window) with its call head. */
export interface ToolResultNode { export interface ToolResultNode {
kind: 'tool-result' kind: 'tool-result'
@@ -124,6 +139,7 @@ export type ConversationNode =
| AssistantMessageNode | AssistantMessageNode
| SteeringMessageNode | SteeringMessageNode
| ContextMessageNode | ContextMessageNode
| ModelRetryNode
| ToolResultNode | ToolResultNode
| UnknownSurfaceNode | UnknownSurfaceNode
@@ -206,7 +222,7 @@ export interface PendingPrompt {
/** The immutable snapshot contract Session hands to uSES (see the web client architecture RFC). */ /** The immutable snapshot contract Session hands to uSES (see the web client architecture RFC). */
export interface ConversationSnapshot { export interface ConversationSnapshot {
sessionId: SessionId sessionId: SessionId
/** Surface fold product (finalized conversation nodes in surface order). */ /** Finalized surface events and durable operational notices in event order. */
nodes: readonly ConversationNode[] nodes: readonly ConversationNode[]
/** Fold degradation flag (cross-window replace defense): when true, nodes come from the lenient linear scan. */ /** Fold degradation flag (cross-window replace defense): when true, nodes come from the lenient linear scan. */
foldDegraded: boolean foldDegraded: boolean

View File

@@ -1,6 +1,7 @@
// Sessions remain resident after creation so they continue consuming mux frames off-screen. // Sessions remain resident after creation so they continue consuming mux frames off-screen.
import type { ContentBlock } from '@deepseek-ai/dsh-llm/types' 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 { SessionEvent } from '@deepseek-ai/dsh-session/types'
import type { import type {
HistoryEntry, IApiClient, MuxFrame, RpcError, RpcId, RpcResult, HistoryEntry, IApiClient, MuxFrame, RpcError, RpcId, RpcResult,
@@ -52,9 +53,9 @@ export class Session implements ObservableSnapshot<ConversationSnapshot> {
private readonly foldAdapter = new FoldAdapter() private readonly foldAdapter = new FoldAdapter()
private partial: PartialAccumulator | null = null private partial: PartialAccumulator | null = null
private openCalls = new Map<string, RunningToolCall>() private openCalls = new Map<string, RunningToolCall>()
/** Interrupted-turn terminal nodes (frozen partial text / aborted tool cards), merged into the flow by seq. /** Operational notices and interrupted-turn terminal nodes merged into the flow by seq.
* Derived from window events (turn/end sweep) — rebuilt by rebuildDerivedFromWindow like partial/openCalls. */ * Derived from window events — rebuilt by rebuildDerivedFromWindow like partial/openCalls. */
private frozenNodes: ConversationNode[] = [] private derivedNodes: ConversationNode[] = []
private pending = new Map<string, PendingInteraction>() private pending = new Map<string, PendingInteraction>()
// Revision counters preserve array identity when derived content is unchanged, so // 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 // React.memo children survive unrelated snapshot swaps (chunk storms must not re-render every
@@ -64,8 +65,8 @@ export class Session implements ObservableSnapshot<ConversationSnapshot> {
private callsCache: { rev: number; value: RunningToolCall[] } | null = null private callsCache: { rev: number; value: RunningToolCall[] } | null = null
private pendingRev = 0 private pendingRev = 0
private pendingCache: { rev: number; value: PendingInteraction[] } | null = null private pendingCache: { rev: number; value: PendingInteraction[] } | null = null
private frozenRev = 0 private derivedRev = 0
private nodesCache: { folded: readonly ConversationNode[]; frozenRev: number; value: readonly ConversationNode[] } | null = null private nodesCache: { folded: readonly ConversationNode[]; derivedRev: number; value: readonly ConversationNode[] } | null = null
private running = false private running = false
/** /**
* Sticky send marker, private input of the composerPhase derivation: set * Sticky send marker, private input of the composerPhase derivation: set
@@ -609,8 +610,27 @@ export class Session implements ObservableSnapshot<ConversationSnapshot> {
} }
/** Per-event side effects (right column of the §A.9 dispatch table): /** 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 { 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) { switch (event.type) {
case 'assistant/chunk': { case 'assistant/chunk': {
const { turn, step, chunk } = event.data const { turn, step, chunk } = event.data
@@ -649,12 +669,12 @@ export class Session implements ObservableSnapshot<ConversationSnapshot> {
const visible = blocks.some(b => (b.kind === 'text' || b.kind === 'reasoning' ? b.text !== '' : true)) const visible = blocks.some(b => (b.kind === 'text' || b.kind === 'reasoning' ? b.text !== '' : true))
if (visible) { if (visible) {
// Fractional seq: strictly after every event of this turn (all < turn/end seq), before the next turn. // 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, kind: 'assistant', seq: event.seq - 0.9, time: event.time,
turn: this.partial.turn, step: this.partial.step, turn: this.partial.turn, step: this.partial.step,
blocks, interrupted: true, blocks, interrupted: true,
}) })
this.frozenRev++ this.derivedRev++
} }
this.partial = null this.partial = null
} }
@@ -664,7 +684,7 @@ export class Session implements ObservableSnapshot<ConversationSnapshot> {
this.openCalls.delete(callId) this.openCalls.delete(callId)
this.callsRev++ this.callsRev++
// The spinner card becomes an interrupted terminal card (never vanishes mid-flow). // 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, kind: 'tool-result', seq: event.seq - 0.8 + callOffset++ * 0.01, time: event.time,
callId, callId,
call: { name: call.name, argsRaw: call.argsRaw }, call: { name: call.name, argsRaw: call.argsRaw },
@@ -672,7 +692,7 @@ export class Session implements ObservableSnapshot<ConversationSnapshot> {
content: [], isError: true, error: { name: 'Interrupted', code: 'interrupted' }, content: [], isError: true, error: { name: 'Interrupted', code: 'interrupted' },
callView: call.callView, resultView: null, callView: call.callView, resultView: null,
}) })
this.frozenRev++ this.derivedRev++
} }
return return
} }
@@ -681,15 +701,15 @@ export class Session implements ObservableSnapshot<ConversationSnapshot> {
} }
} }
/** 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 * 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 { private rebuildDerivedFromWindow(): void {
this.partial = null this.partial = null
this.openCalls.clear() this.openCalls.clear()
this.callsRev++ this.callsRev++
this.frozenNodes = [] this.derivedNodes = []
this.frozenRev++ this.derivedRev++
for (let i = 0; i < this.events.length; i++) { for (let i = 0; i < this.events.length; i++) {
const event = this.events[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. */ /* 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<ConversationSnapshot> {
private buildSnapshot(): ConversationSnapshot { private buildSnapshot(): ConversationSnapshot {
const { nodes: folded, degraded } = this.foldAdapter.nodes() const { nodes: folded, degraded } = this.foldAdapter.nodes()
// Frozen interrupted nodes ride fractional seqs: a stable merge keeps them in flow order. // 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, frozenRev) so an unchanged flow keeps its // The merged array is cached on (folded reference, derivedRev) so an unchanged flow keeps its
// reference across snapshot swaps (§A.9.4). // reference across snapshot swaps (§A.9.4).
let nodes: readonly ConversationNode[] 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 nodes = this.nodesCache.value
} else { } else {
nodes = this.frozenNodes.length === 0 nodes = this.derivedNodes.length === 0
? folded ? folded
: [...folded, ...this.frozenNodes].sort((a, b) => a.seq - b.seq) : [...folded, ...this.derivedNodes].sort((a, b) => a.seq - b.seq)
this.nodesCache = { folded, frozenRev: this.frozenRev, value: nodes } this.nodesCache = { folded, derivedRev: this.derivedRev, value: nodes }
} }
if (this.callsCache === null || this.callsCache.rev !== this.callsRev) { if (this.callsCache === null || this.callsCache.rev !== this.callsRev) {
this.callsCache = { rev: this.callsRev, value: [...this.openCalls.values()] } this.callsCache = { rev: this.callsRev, value: [...this.openCalls.values()] }
@@ -752,6 +772,37 @@ function rpcErrorMessage(error: RpcError): string {
return `${error.code}: ${error.message}` 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<string, unknown>
const failure = data.failure
if (failure === null || typeof failure !== 'object') return null
const failureData = failure as Record<string, unknown>
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 * The composerPhase judgment — the single site that knows the predicate
* (consumers switch on the result, never re-derive). Monotone per session * (consumers switch on the result, never re-derive). Monotone per session

View File

@@ -28,6 +28,22 @@ export const ev = {
at(seq, { type: 'tool/result', surfaceOp: 'append', data: { turn, step, callId, content: text(body), isError: false } }), at(seq, { type: 'tool/result', surfaceOp: 'append', data: { turn, step, callId, content: text(body), isError: false } }),
stepEnd: (seq: number, turn: number, step = 0): SessionEvent => stepEnd: (seq: number, turn: number, step = 0): SessionEvent =>
at(seq, { type: 'step/end', data: { turn, step } }), 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 => turnEnd: (seq: number, turn: number, reason: 'completed' | 'cancelled' = 'completed'): SessionEvent =>
at(seq, { type: 'turn/end', data: { turn, reason: { kind: reason } } }), at(seq, { type: 'turn/end', data: { turn, reason: { kind: reason } } }),
} }

View File

@@ -119,6 +119,72 @@ describe('live event path', () => {
expect((last as { interrupted?: true }).interrupted).toBeUndefined() 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 () => { it('freezes an unfinalized partial into an interrupted node on turn/end (cancel path)', async () => {
const { session } = await opened() const { session } = await opened()
const feed = (event: SessionEvent) => { session.handleMuxEnvelope('r' as never, { type: 'session/event', sessionId: SID, event }) } const feed = (event: SessionEvent) => { session.handleMuxEnvelope('r' as never, { type: 'session/event', sessionId: SID, event }) }

View File

@@ -26,6 +26,9 @@
{ {
"path": "../../llm/llm" "path": "../../llm/llm"
}, },
{
"path": "../../llm/llm-retry"
},
{ {
"path": "../../support/invariants" "path": "../../support/invariants"
} }

View File

@@ -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 · <path>` or `Edit · <path>` summary while retaining the shared row-to-details interaction. 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 · <path>` or `Edit · <path>` 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: '<tool>', 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). 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: '<tool>', 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. 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.

View File

@@ -45,6 +45,16 @@ type RenderToolRow = ChatViewSlotProps['renderSlot']
* chat view narrows once to the runtime snapshot the binding actually feeds. */ * chat view narrows once to the runtime snapshot the binding actually feeds. */
type UseConversation = SnapshotSelectorHook<ConversationSnapshot> type UseConversation = SnapshotSelectorHook<ConversationSnapshot>
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 /** One tool call row (result or running): dispatches through the keyed
* toolview slot with the owner payload; unregistered tools fall back to * toolview slot with the owner payload; unregistered tools fall back to
* GenericToolCard at this render site. */ * 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). */ /** 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) { export function ChatView({ useSession, useStore, renderSlot, openDetails, loadOlder }: ChatViewSlotProps) {
const nodes = useSession((s) => s.nodes) const nodes = useSession((s) => s.nodes)
const running = useSession((s) => s.running)
const runningCalls = useSession((s) => s.runningCalls) const runningCalls = useSession((s) => s.runningCalls)
const pending = useSession((s) => s.pending) const pending = useSession((s) => s.pending)
const openState = useSession((s) => s.openState) 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 selectedCallId = useStore((s) => s.selection?.callId)
const items = useMemo(() => deriveChatFlow(nodes), [nodes]) const items = useMemo(() => deriveChatFlow(nodes), [nodes])
const activeRetry = useMemo(() => activeRetrySeq(nodes, running), [nodes, running])
const listRef = useRef<HTMLDivElement | null>(null) const listRef = useRef<HTMLDivElement | null>(null)
const atBottomRef = useRef(true) 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. */ /* v8 ignore next -- tool-result never reaches here: deriveChatFlow folds them into groups. */
if (node.kind === 'tool-result') return null if (node.kind === 'tool-result') return null
return <MessageItem key={item.key} node={node} /> return (
<MessageItem
key={item.key}
node={node}
retryActive={node.kind === 'model-retry' && node.seq === activeRetry}
/>
)
} }
return ( return (

View File

@@ -32,3 +32,103 @@
.contextRow { .contextRow {
padding: 2px 0; 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;
}
}

View File

@@ -1,17 +1,18 @@
// MessageItem: the four simple node kinds — user bubble (right-aligned), // MessageItem: simple chat nodes — user bubble (right-aligned), steering
// steering (badged bubble), context injection and unknown-surface JSON rows. // (badged bubble), context injection, retry disclosure and unknown JSON rows.
// Props are frozen node slices off the snapshot cache; memo holds across // Props are frozen node slices off the snapshot cache; memo holds across
// streaming because unchanged nodes keep their references. // streaming because unchanged nodes keep their references.
import { memo } from 'react' import { memo, useEffect, useState } from 'react'
import type { import type {
ContextMessageNode, SteeringMessageNode, UnknownSurfaceNode, UserMessageNode, ContextMessageNode, ModelRetryNode, SteeringMessageNode, UnknownSurfaceNode, UserMessageNode,
} from '@deepseek-ai/dsh-client-runtime/client' } from '@deepseek-ai/dsh-client-runtime/client'
import { JsonBlock, MessageText } from '@deepseek-ai/dsh-client-ui-primitives' import { JsonBlock, MessageText } from '@deepseek-ai/dsh-client-ui-primitives'
import css from './MessageItem.module.css' import css from './MessageItem.module.css'
export interface MessageItemProps { 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[] } { 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 } 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<RetryCountdown>(() => ({
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 (
<details className={css.retryRow} data-active={active || undefined}>
<summary className={css.retrySummary}>
<span className={css.retryText} role="status">
{active ? '正在重试' : '已重试'}模型请求({node.retry}/{node.maxRetries}) · {active ? remainingSeconds : scheduledSeconds}s
</span>
</summary>
<div className={css.retryDetails}>
<div><span className={css.retryDetailLabel}>重试延迟:</span>{Math.round(node.delayMs)}ms</div>
<div><span className={css.retryDetailLabel}>失败原因:</span>{node.failure.message}</div>
</div>
</details>
)
}
export const MessageItem = memo(function MessageItem({ node, retryActive = false }: MessageItemProps) {
switch (node.kind) { switch (node.kind) {
case 'user': case 'user':
case 'steering': { case 'steering': {
@@ -46,6 +96,8 @@ export const MessageItem = memo(function MessageItem({ node }: MessageItemProps)
<JsonBlock label="上下文注入" payload={{ content: node.content, meta: node.meta }} /> <JsonBlock label="上下文注入" payload={{ content: node.content, meta: node.meta }} />
</div> </div>
) )
case 'model-retry':
return <ModelRetryItem node={node} active={retryActive} />
default: default:
return ( return (
<div className={css.contextRow}> <div className={css.contextRow}>

View File

@@ -1,7 +1,8 @@
/** /**
* Chat flow derivation: ConversationSnapshot nodes -> render items. Tool * Chat flow derivation: ConversationSnapshot nodes -> render items. Tool
* results group into consecutive-run tool groups (figma step-summary flow, * 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 * Item identity keys are stable across snapshots so the list parent can
* subscribe to keys only while rows subscribe to content. * subscribe to keys only while rows subscribe to content.
*/ */
@@ -15,7 +16,7 @@ export type ChatFlowItem =
/** /**
* Group finalized nodes into the step-summary flow. * Group finalized nodes into the step-summary flow.
* @param nodes - snapshot nodes (surface order). * @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[] { export function deriveChatFlow(nodes: readonly ConversationNode[]): ChatFlowItem[] {
const items: ChatFlowItem[] = [] const items: ChatFlowItem[] = []
@@ -28,6 +29,18 @@ export function deriveChatFlow(nodes: readonly ConversationNode[]): ChatFlowItem
} else { } else {
group.push(node) 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 { } else {
group = null group = null
items.push({ kind: 'node', key: `n${node.seq}`, node }) items.push({ kind: 'node', key: `n${node.seq}`, node })

View File

@@ -5,7 +5,7 @@
// machinery specs since the tool ring dissolved into renderSlot.) // machinery specs since the tool ring dissolved into renderSlot.)
import { afterEach, describe, expect, it, vi } from 'vitest' 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 { RpcId } from '@deepseek-ai/dsh-client-connection/client'
import type { SessionId } from '@deepseek-ai/dsh-client-runtime/client' import type { SessionId } from '@deepseek-ai/dsh-client-runtime/client'
import { PendingWait } 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 { AssistantMarkdown } from '../src/client/chat/AssistantMarkdown.tsx'
import { StatsLine, type StatsLineProps } from '../src/client/chat/StatsLine.tsx' import { StatsLine, type StatsLineProps } from '../src/client/chat/StatsLine.tsx'
afterEach(cleanup) afterEach(() => {
cleanup()
vi.useRealTimers()
})
describe('MessageItem arms', () => { describe('MessageItem arms', () => {
it('steering bubbles carry the interjection badge and non-text rest blocks', () => { 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() 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(
<MessageItem
retryActive
node={{
kind: 'model-retry',
seq: 5,
time: 10_000,
turn: 1,
step: 0,
retry: 1,
maxRetries: 2,
delayMs: 2_500.4,
failure: { code: 'TRANSPORT', message: '连接被重置' },
}}
/>,
)
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(
<MessageItem
retryActive
node={{
kind: 'model-retry',
seq: 6,
time: 12_100,
turn: 1,
step: 1,
retry: 2,
maxRetries: 2,
delayMs: 3_500.4,
failure: { code: 'TRANSPORT', message: '再次断开' },
}}
/>,
)
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(
<MessageItem node={{
kind: 'model-retry',
seq: 6,
time: 12_100,
turn: 1,
step: 1,
retry: 2,
maxRetries: 2,
delayMs: 3_500.4,
failure: { code: 'TRANSPORT', message: '再次断开' },
}}
/>,
)
expect(details?.dataset.active).toBeUndefined()
expect(view.getByRole('status').textContent).toBe('已重试模型请求(2/2) · 4s')
})
}) })
describe('small branch tails', () => { describe('small branch tails', () => {

View File

@@ -7,7 +7,7 @@ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import { Profiler } from 'react' import { Profiler } from 'react'
import { act, cleanup, fireEvent, render } from '@testing-library/react' import { act, cleanup, fireEvent, render } from '@testing-library/react'
import type { 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' } from '@deepseek-ai/dsh-client-runtime/client'
import { bindSnapshotSelector } from '@deepseek-ai/dsh-client-web-react' import { bindSnapshotSelector } from '@deepseek-ai/dsh-client-web-react'
import { createSnapshotStore, PendingWait } from '@deepseek-ai/dsh-client-runtime/client' 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 => ({ const assistant = (seq: number, text: string): AssistantMessageNode => ({
kind: 'assistant', seq, time: seq * 1_000, turn: 1, step: 1, blocks: [{ kind: 'text', text }], 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 => ({ const toolResult = (seq: number, callId: string, name = 'bash'): ToolResultNode => ({
kind: 'tool-result', seq, time: seq * 1_000, callId, kind: 'tool-result', seq, time: seq * 1_000, callId,
call: { name, argsRaw: `{"command":"cmd-${callId}","description":"run ${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(items)).toBe('n1|n2|g3|n5|g6')
expect(flowKeys(deriveChatFlow([...nodes, toolResult(7, 'd')]))).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', () => { describe('ChatView', () => {
@@ -168,6 +184,31 @@ describe('ChatView', () => {
expect(view.getByText('run a')).toBeTruthy() 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(<h.ChatView {...h.props} />)
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', () => { 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 markdown = '# Rendered\n\n- **one**\n- `two`'
const h = makeHarness({ nodes: [user(1, markdown), assistant(2, markdown)] }) const h = makeHarness({ nodes: [user(1, markdown), assistant(2, markdown)] })

View File

@@ -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. 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. 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.

View File

@@ -15,11 +15,16 @@
"types": "./lib/types/invariant.d.ts", "types": "./lib/types/invariant.d.ts",
"default": "./lib/invariant.js" "default": "./lib/invariant.js"
}, },
"./types": {
"types": "./lib/types/types.d.ts",
"default": "./lib/types/types.js"
},
"./package.json": "./package.json" "./package.json": "./package.json"
}, },
"files": [ "files": [
"lib/index.js", "lib/index.js",
"lib/invariant.js", "lib/invariant.js",
"lib/types/**/*.js",
"lib/types/**/*.d.ts", "lib/types/**/*.d.ts",
"lib/types/**/*.d.ts.map", "lib/types/**/*.d.ts.map",
"src" "src"

View File

@@ -26,6 +26,8 @@ declare module '@deepseek-ai/dsh-session' {
} }
} }
export type { LlmRetryEventData } from './types.ts'
export const name = 'llm-retry' export const name = 'llm-retry'
export const inject = ['agents'] export const inject = ['agents']

View File

@@ -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
}

View File

@@ -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 { Context } from 'cordis'
import type { Fiber } from 'cordis' import type { Fiber } from 'cordis'
import LlmService, { CallId, EMPTY_RESPONSE_CODE, LlmAdapter, LlmError } from '@deepseek-ai/dsh-llm' import LlmService, { CallId, EMPTY_RESPONSE_CODE, LlmAdapter, LlmError } from '@deepseek-ai/dsh-llm'
import type { GenerateOptions, StreamChunk } from '@deepseek-ai/dsh-llm' import type { GenerateOptions, StreamChunk } from '@deepseek-ai/dsh-llm'
import SessionStore, { SessionId } from '@deepseek-ai/dsh-session' 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 SystemPrompt from '@deepseek-ai/dsh-system-prompt'
import ToolRegistry, { defineContentToolFixture } from '@deepseek-ai/dsh-tools' import ToolRegistry, { defineContentToolFixture } from '@deepseek-ai/dsh-tools'
import AgentRegistry from '@deepseek-ai/dsh-agent' import AgentRegistry from '@deepseek-ai/dsh-agent'
@@ -15,6 +16,10 @@ import * as retry from '../src/index.ts'
type ScriptEntry = Error | Iterable<StreamChunk> | AsyncIterable<StreamChunk> type ScriptEntry = Error | Iterable<StreamChunk> | AsyncIterable<StreamChunk>
it('keeps the browser-safe retry payload identical to the session event', () => {
expectTypeOf<LlmRetryEventData>().toEqualTypeOf<SessionEventMap['llm/retry']>()
})
class ScriptedAdapter extends LlmAdapter { class ScriptedAdapter extends LlmAdapter {
readonly requests: GenerateOptions[] = [] readonly requests: GenerateOptions[] = []

6
pnpm-lock.yaml generated
View File

@@ -179,6 +179,9 @@ importers:
'@deepseek-ai/dsh-llm-deepseek': '@deepseek-ai/dsh-llm-deepseek':
specifier: workspace:^ specifier: workspace:^
version: link:../../packages/llm/llm-deepseek version: link:../../packages/llm/llm-deepseek
'@deepseek-ai/dsh-llm-retry':
specifier: workspace:^
version: link:../../packages/llm/llm-retry
'@deepseek-ai/dsh-paths': '@deepseek-ai/dsh-paths':
specifier: workspace:^ specifier: workspace:^
version: link:../../packages/util/paths version: link:../../packages/util/paths
@@ -782,6 +785,9 @@ importers:
'@deepseek-ai/dsh-llm': '@deepseek-ai/dsh-llm':
specifier: workspace:^ specifier: workspace:^
version: link:../../llm/llm version: link:../../llm/llm
'@deepseek-ai/dsh-llm-retry':
specifier: workspace:^
version: link:../../llm/llm-retry
'@deepseek-ai/dsh-session': '@deepseek-ai/dsh-session':
specifier: workspace:^ specifier: workspace:^
version: link:../../core/session version: link:../../core/session

View File

@@ -43,6 +43,7 @@
"@deepseek-ai/dsh-session/surface": ["./packages/core/session/src/surface.ts"], "@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/types": ["./packages/llm/llm/src/types.ts"],
"@deepseek-ai/dsh-llm/brand": ["./packages/llm/llm/src/brand.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-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-approval/types": ["./packages/ui/user-approval/src/types.ts"],
"@deepseek-ai/dsh-user-interaction/types": ["./packages/ui/user-interaction/src/types.ts"], "@deepseek-ai/dsh-user-interaction/types": ["./packages/ui/user-interaction/src/types.ts"],