fix(web): address model retry review feedback

This commit is contained in:
Yichen Jiang
2026-07-29 14:23:03 +08:00
parent 602f594c4b
commit 49131c47c5
28 changed files with 301 additions and 61 deletions

View File

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

View File

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

View File

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

View File

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

View File

@@ -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 节点。
## 会话模型选择

View File

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

View File

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

View File

@@ -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<ModelRetryNode['retryState'], 'scheduled'>,
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<string, unknown>
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
}
/**

View File

@@ -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' } } }),

View File

@@ -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' }] })
})

View File

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

View File

@@ -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: '<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).

View File

@@ -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 scopekey 空间在运行时开放);其渲染点逐行通过 `entryKey: toolName` 分发,并以 `GenericToolCard` 作为调用点 `fallback`。owner 载荷是统一的 `ToolRowOwnerProps``callId``toolName``block``openFile``ToolRowProps` 则预先将其与 Session 标准工具包组合。注册方只是普通插件:`ctx.slots.register({ name: 'conversation.chat.toolview', key: '<tool>', inject? }, Row)`,以 `inject: ['slots', 'conversation']` 作为加载顺序 seamapply 在聊天注册后挂载 ConversationService因此服务存在即可保证 slot 已声明Session 区分在组件内部完成(`useSessions` 读取 `parentId`bash 示例是第三方姿态的范例。Trajectory/waterfall 工具视图 slot 共享此形状并随各自的渲染点落地RendersCheck 会拒绝没有任何渲染方的声明)。

View File

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

View File

@@ -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<RetryCountdown>(() => ({
@@ -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 (
<details className={css.retryRow} data-active={active || undefined}>
<summary className={css.retrySummary}>
<span className={css.retryText} role="status">
{active ? '正在重试' : '已重试'}{node.retry}/{maximum} · {active ? remainingSeconds : scheduledSeconds}s
{label}{node.retry}/{maximum} · {active ? remainingSeconds : scheduledSeconds}s
</span>
</summary>
<div className={css.retryDetails}>

View File

@@ -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(
<MessageItem node={{
kind: 'model-retry',
seq: 8,
time: 12_100,
retryState: 'cancelled',
turn: 4,
step: 0,
provider: 'mock',
mode: 'normal',
policyKey: 'mock-normal',
retry: 1,
maxRetries: 2,
delayMs: 3_500.4,
failure: { code: 'TRANSPORT', message: '用户取消' },
}}
/>,
)
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(<MessageItem node={node} />)
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(<MessageItem node={node} retryActive />)

View File

@@ -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', () => {

View File

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

View File

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

View File

@@ -2,7 +2,7 @@
[English](README.md) | 中文
轨迹轮次列表 chrome吸顶 TurnMessage·Step 分组/步骤单元格)及 Waterfall 占位符;这是纯消费方最小插件范例(向会话的 `'conversation.view'` slot 环注册两个视图标签页,不提供服务,也不声明 Context 合并。契约api-contracts v3 §8。
轨迹轮次列表 chrome吸顶 TurnMessage·Step 分组/步骤单元格)及 Waterfall 占位符;这是纯消费方最小插件范例(向会话的 `'conversation.view'` slot 环注册两个视图标签页,不提供服务,也不声明 Context 合并)。携带轮次的会话节点(包括没有 assistant 输出的模型重试提示)会锚定自身的轨迹区段,而不会继承前一个轮次。契约api-contracts v3 §8。
## 模型体验

View File

@@ -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'
}
/**

View File

@@ -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', () => {