Merge remote-tracking branch 'origin/master' into worktree/web-model-request-retry

# Conflicts:
#	.agents/notes/implemented/architecture/2026-06-21-bounded-llm-request-recovery.i18n.yaml
#	.agents/notes/implemented/architecture/2026-06-21-bounded-llm-request-recovery.md
#	.agents/notes/implemented/architecture/2026-06-21-bounded-llm-request-recovery.zh.md
#	apps/web/tests/session-title.snapshot.ts
#	docs/config-catalog.md
#	packages/client/runtime/README.i18n.yaml
#	packages/client/runtime/README.md
#	packages/client/runtime/README.zh.md
#	packages/client/ui-conversation/README.i18n.yaml
#	packages/client/ui-conversation/src/client/chat/ChatView.tsx
#	packages/client/ui-conversation/tests/chat-view.spec.tsx
#	packages/llm/llm-retry/README.i18n.yaml
#	packages/llm/llm-retry/README.md
#	packages/llm/llm-retry/README.zh.md
This commit is contained in:
Yichen Jiang
2026-07-28 11:39:42 +08:00
1336 changed files with 41643 additions and 20948 deletions
@@ -7,7 +7,9 @@ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import { Profiler } from 'react'
import { act, cleanup, fireEvent, render } from '@testing-library/react'
import type {
AssistantMessageNode, ConversationNode, ConversationSnapshot, ModelRetryNode, RunningToolCall, SessionId, SessionListState, ToolResultNode, UserMessageNode, WorkspaceListState,
AssistantMessageNode, ConversationNode, ConversationSnapshot, ModelRetryNode,
RunningToolCall, SessionId, SessionListState, ToolResultNode, UserMessageNode,
WorkspaceListState,
} from '@deepseek-ai/dsh-client-runtime/client'
import { bindSnapshotSelector } from '@deepseek-ai/dsh-client-web-react'
import { createSnapshotStore, PendingWait } from '@deepseek-ai/dsh-client-runtime/client'
@@ -39,7 +41,7 @@ function makeSource(init?: Partial<ConversationSnapshot>) {
let snap: ConversationSnapshot = { ...snapshotBase(), ...init }
const subs = new Set<() => void>()
return {
set(next: Partial<ConversationSnapshot>) {
set: (next: Partial<ConversationSnapshot>) => {
snap = { ...snap, ...next }
for (const fn of [...subs]) fn()
},
@@ -61,6 +63,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,
provider: 'mock', mode: 'normal', policyKey: 'mock-normal',
retry: 1, maxRetries: 2, delayMs: 450,
failure: { code: 'TRANSPORT', message: '连接被重置' },
})
@@ -109,8 +112,8 @@ function makeHarness(init?: Partial<ConversationSnapshot>) {
useSession: bindSnapshotSelector(source),
useSessions: emptySessions(),
useWorkspaces: emptyWorkspaces(),
useInput: (() => { throw new Error('unused') }) as never,
inputActions: { setDraft: () => {}, submit: () => {} } as never,
useInput: (() => { throw new Error('unused') }),
inputActions: { setDraft: () => {}, submit: () => {} },
useStore: bindSnapshotSelector(chat),
actions: chat.actions,
renderSlot,
@@ -129,16 +132,16 @@ describe('chat-flow derivation', () => {
assistant(5, 'found'), toolResult(6, 'c'),
]
const items = deriveChatFlow(nodes)
expect(items.map((i) => i.kind)).toEqual(['node', 'node', 'tool-group', 'node', 'tool-group'])
expect(items.map(i => i.kind)).toEqual(['node', 'node', 'tool-group', 'node', 'tool-group'])
const group = items[2]!
expect(group.kind === 'tool-group' && group.results.map((r) => r.callId)).toEqual(['a', 'b'])
expect(group.kind === 'tool-group' && group.results.map(r => r.callId)).toEqual(['a', 'b'])
expect(flowKeys(items)).toBe('n1|n2|g3|n5|g6')
expect(flowKeys(deriveChatFlow([...nodes, toolResult(7, 'd')]))).toBe('n1|n2|g3|n5|g6')
})
it('reuses one stable row for consecutive retries in the same turn', () => {
it('reuses one stable row for consecutive retry turns', () => {
const first = retry(2)
const second = { ...retry(3), step: 1, retry: 2 }
const second = { ...retry(3), turn: 2, retry: 2 }
const initial = deriveChatFlow([user(1, 'try'), first])
const updated = deriveChatFlow([user(1, 'try'), first, second])
expect(flowKeys(initial)).toBe('n1|n2')
@@ -171,7 +174,7 @@ describe('ChatView', () => {
fireEvent.scroll(scroller)
fireEvent.click(view.getByText('加载更早'))
Object.defineProperty(scroller, 'scrollHeight', { value: 1300, writable: true })
act(() => h.set({ nodes: [assistant(2, 'older'), user(9, 'late')] }))
act(() => { h.set({ nodes: [assistant(2, 'older'), user(9, 'late')] }) })
expect(scroller.scrollTop).toBe(550) // 50 + (1300 - 800)
})
@@ -188,7 +191,7 @@ describe('ChatView', () => {
it('animates only the latest unresolved model retry', () => {
const retryNode = retry(2)
const nextRetry = { ...retry(3), step: 1, retry: 2 }
const nextRetry = { ...retry(3), turn: 2, retry: 2 }
const context = {
kind: 'context', seq: 4, time: 4_000, content: [], source: null,
} as const satisfies ConversationNode
@@ -198,16 +201,22 @@ describe('ChatView', () => {
expect(disclosure?.dataset.active).toBe('true')
expect(view.getByRole('status').textContent).toBe('正在重试模型请求(1/2) · 1s')
act(() => h.set({ nodes: [user(1, 'try'), retryNode, nextRetry] }))
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')] }))
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 }))
act(() => {
h.set({ nodes: [user(1, 'try'), retry(6)], running: false })
})
expect(disclosure?.dataset.active).toBeUndefined()
})
@@ -281,10 +290,10 @@ describe('ChatView', () => {
// Count renderSlot invocations: the memo boundary holds when CallRow does
// not re-render, so the row's renderSlot call count freezes during chunks.
let rowRenders = 0
h.props.renderSlot = (((_key: string, _owner: object) => {
h.props.renderSlot = ((_key: string, _owner: object) => {
rowRenders += 1
return <div data-testid="counting-row" />
}) as unknown as ChatViewSlotProps['renderSlot'])
})
const view = render(<h.ChatView {...h.props} />)
expect(view.getByTestId('counting-row')).toBeTruthy()
const afterMount = rowRenders
@@ -311,7 +320,7 @@ describe('ChatView', () => {
fireEvent.click(view.getByText('run a'))
expect(h.openDetails).toHaveBeenCalledWith({ turnSeq: 3, callId: 'a', toolName: 'bash' })
expect(view.container.querySelector('[data-selected]')).toBeNull()
act(() => h.setSelection({ turnSeq: 3, callId: 'a', toolName: 'bash' }))
act(() => { h.setSelection({ turnSeq: 3, callId: 'a', toolName: 'bash' }) })
expect(view.container.querySelector('[data-selected]')).not.toBeNull()
})
@@ -325,10 +334,10 @@ describe('ChatView', () => {
it('dispatches each tool row through the keyed slot with the tool name as entryKey', () => {
const h = makeHarness({ nodes: [toolResult(3, 'a')] })
const calls: { key: string; entryKey?: string }[] = []
h.props.renderSlot = (((key: string, _owner: object, opts?: { entryKey?: string; fallback?: React.ReactNode }) => {
h.props.renderSlot = ((key: string, _owner: object, opts?: { entryKey?: string; fallback?: React.ReactNode }) => {
calls.push({ key, ...(opts?.entryKey !== undefined ? { entryKey: opts.entryKey } : {}) })
return opts?.fallback ?? null
}) as unknown as ChatViewSlotProps['renderSlot'])
})
render(<h.ChatView {...h.props} />)
// Keyed dispatch: slot name is the declared hole, entryKey the wire tool
// name, and the fallback (GenericToolCard) renders on an empty ledger.
@@ -347,10 +356,10 @@ describe('ChatView', () => {
// Arm the paging anchor, then deliver an older page (head seq decreases).
fireEvent.click(view.getByText('加载更早'))
Object.defineProperty(scroller, 'scrollHeight', { value: 1600, writable: true })
act(() => h.set({ nodes: [user(1, 'old'), assistant(2, 'b'), user(5, 'later'), assistant(6, 'a')] }))
act(() => { h.set({ nodes: [user(1, 'old'), assistant(2, 'b'), user(5, 'later'), assistant(6, 'a')] }) })
expect(scroller.scrollTop).toBe(600) // 0 + (1600 - 1000)
// A new trailing user bubble (own words) force-scrolls to the bottom.
act(() => h.set({ nodes: [user(1, 'old'), assistant(2, 'b'), user(5, 'later'), assistant(6, 'a'), user(9, 'mine')] }))
act(() => { h.set({ nodes: [user(1, 'old'), assistant(2, 'b'), user(5, 'later'), assistant(6, 'a'), user(9, 'mine')] }) })
expect(scroller.scrollTop).toBe(1600)
})
@@ -365,7 +374,7 @@ describe('ChatView', () => {
const backButton = view.getByLabelText('回到底部')
expect(backButton).toBeTruthy()
// Streaming growth must NOT drag a scrolled-away reader down.
act(() => h.set({ partial: { turn: 1, step: 1, blocks: [{ kind: 'text', text: 'grow' }] } }))
act(() => { h.set({ partial: { turn: 1, step: 1, blocks: [{ kind: 'text', text: 'grow' }] } }) })
expect(scroller.scrollTop).toBe(100)
fireEvent.click(backButton)
expect(scroller.scrollTop).toBe(1000)
@@ -378,7 +387,7 @@ describe('ChatView', () => {
const view = render(<h.ChatView {...h.props} />)
fireEvent.click(view.getByText('加载更早'))
expect(h.loadOlder).toHaveBeenCalledTimes(1)
act(() => h.set({ loadingOlder: true }))
act(() => { h.set({ loadingOlder: true }) })
expect(view.getByText('加载中…')).toBeTruthy()
})