Merge remote-tracking branch 'origin/master' into mergebot/pr998

# Conflicts:
#	packages/client/ui-conversation/README.i18n.yaml
This commit is contained in:
imccyu
2026-07-31 12:23:03 +08:00
50 changed files with 1240 additions and 102 deletions

View File

@@ -18,7 +18,10 @@ import { AssistantMarkdown } from '../src/client/chat/AssistantMarkdown.tsx'
import { StatsLine, type StatsLineProps } from '../src/client/chat/StatsLine.tsx'
import { zh } from '../src/client/locales.ts'
afterEach(cleanup)
afterEach(() => {
cleanup()
vi.useRealTimers()
})
// Mirrors the real lookup chain (conversation namespace, then common).
const t: MessageItemProps['t'] = makeTranslate(zh, commonZh)
@@ -160,6 +163,157 @@ describe('MessageItem arms', () => {
)
expect(unknownView.getByText(/未知 surface 事件:surface\/next/)).toBeTruthy()
})
it('collapses retry details behind the durable model retry status', () => {
vi.useFakeTimers()
vi.setSystemTime(10_000)
const view = render(
<MessageItem
t={t}
retryActive
node={{
kind: 'model-retry',
seq: 5,
time: 10_000,
retryState: 'scheduled',
turn: 1,
step: 0,
provider: 'mock',
mode: 'normal',
policyKey: 'mock-normal',
retry: 1,
maxRetries: 2,
delayMs: 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
t={t}
retryActive
node={{
kind: 'model-retry',
seq: 6,
time: 12_100,
retryState: 'scheduled',
turn: 2,
step: 0,
provider: 'mock',
mode: 'normal',
policyKey: 'mock-normal',
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 t={t} node={{
kind: 'model-retry',
seq: 6,
time: 12_100,
retryState: 'started',
turn: 2,
step: 0,
provider: 'mock',
mode: 'normal',
policyKey: 'mock-normal',
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')
view.rerender(
<MessageItem t={t} node={{
kind: 'model-retry',
seq: 7,
time: 12_100,
retryState: 'started',
turn: 3,
step: 0,
provider: 'mock',
mode: 'always',
policyKey: 'mock-always',
retry: 3,
delayMs: 3_500.4,
failure: { code: 'TRANSPORT', message: '继续重试' },
}}
/>,
)
expect(view.getByRole('status').textContent).toBe('已重试模型请求(3/∞) · 4s')
view.rerender(
<MessageItem t={t} 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', () => {
vi.useFakeTimers()
vi.setSystemTime(10_000)
const node = {
kind: 'model-retry',
seq: 5,
time: 10_000,
retryState: 'scheduled',
turn: 1,
step: 0,
provider: 'mock',
mode: 'normal',
policyKey: 'mock-normal',
retry: 1,
maxRetries: 2,
delayMs: 5_000,
failure: { code: 'TRANSPORT', message: '连接被重置' },
} as const
const view = render(<MessageItem t={t} node={node} />)
expect(view.getByRole('status').textContent).toBe('等待重试模型请求(1/2) · 5s')
act(() => { vi.advanceTimersByTime(4_200) })
view.rerender(<MessageItem t={t} node={node} retryActive />)
expect(view.getByRole('status').textContent).toBe('正在重试模型请求(1/2) · 1s')
})
})
describe('formatMessageClock', () => {

View File

@@ -7,8 +7,9 @@ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import { Profiler } from 'react'
import { act, cleanup, fireEvent, render } from '@testing-library/react'
import type {
AssistantMessageNode, CommandNode, ConversationNode, ConversationSnapshot, RunningToolCall, SessionId,
SessionListState, ToolResultNode, UserMessageNode, WorkspaceListState,
AssistantMessageNode, CommandNode, ConversationNode, ConversationSnapshot,
ModelRetryNode, RunningToolCall, SessionId, SessionListState, ToolResultNode,
UserMessageNode, WorkspaceListState,
} from '@deepseek-ai/dsh-client-runtime/client'
import { bindSnapshotSelector } from '@deepseek-ai/dsh-client-web-react'
import { createSnapshotStore, PendingWait } from '@deepseek-ai/dsh-client-runtime/client'
@@ -67,6 +68,13 @@ const user = (seq: number, text: string): UserMessageNode => ({
const assistant = (seq: number, text: string, turn = 1): AssistantMessageNode => ({
kind: 'assistant', seq, time: seq * 1_000, turn, step: 1, blocks: [{ kind: 'text', text }],
})
const retry = (seq: number): ModelRetryNode => ({
kind: 'model-retry', seq, time: seq * 1_000, turn: 1, step: 0,
retryState: 'scheduled',
provider: 'mock', mode: 'normal', policyKey: 'mock-normal',
retry: 1, maxRetries: 2, delayMs: 450,
failure: { code: 'TRANSPORT', message: '连接被重置' },
})
const toolResult = (seq: number, callId: string, name = 'bash'): ToolResultNode => ({
kind: 'tool-result', seq, time: seq * 1_000, callId,
call: { name, argsRaw: `{"command":"cmd-${callId}","description":"run ${callId}"}` },
@@ -155,6 +163,17 @@ describe('chat-flow derivation', () => {
expect(flowKeys(deriveChatFlow([...nodes, toolResult(7, 'd')]))).toBe('n1|n2|g3|n5|g6')
})
it('reuses one stable row for consecutive retry turns', () => {
const first = retry(2)
const second = { ...retry(3), turn: 2, retry: 2 }
const initial = deriveChatFlow([user(1, 'try'), first])
const updated = deriveChatFlow([user(1, 'try'), first, second])
expect(flowKeys(initial)).toBe('n1|n2')
expect(flowKeys(updated)).toBe('n1|n2')
expect(updated).toHaveLength(2)
expect(updated[1]?.kind === 'node' && updated[1].node).toBe(second)
})
it('skips render-nothing assistant nodes so tool runs stay one group', () => {
// A tool-call-only step message (and blank text/reasoning) renders nothing:
// it must not split the run into two groups with an empty line between.
@@ -227,6 +246,47 @@ describe('ChatView', () => {
expect(view.getByText('run a')).toBeTruthy()
})
it('animates only the latest unresolved model retry', () => {
const retryNode = retry(2)
const nextRetry = { ...retry(3), turn: 2, retry: 2 }
const context = {
kind: 'context', seq: 4, time: 4_000, content: [], source: null,
} as const satisfies ConversationNode
const h = makeHarness({ nodes: [user(1, 'try'), retryNode], running: true })
const view = render(<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, retryState: 'started' },
context,
assistant(5, 'done'),
],
running: false,
})
})
expect(disclosure?.dataset.active).toBeUndefined()
expect(view.getByRole('status').textContent).toBe('已重试模型请求(2/2) · 1s')
act(() => {
h.set({ nodes: [user(1, 'try'), { ...retry(6), retryState: 'cancelled' }], running: true })
})
expect(disclosure?.dataset.active).toBeUndefined()
expect(view.getByRole('status').textContent).toContain('重试已取消')
})
it('the expanded row Inspect pill hands the call id to inspectCall', () => {
const h = makeHarness({
nodes: [toolResult(3, 'a')],