Merge remote-tracking branch 'origin/master' into mergebot/pr998
# Conflicts: # packages/client/ui-conversation/README.i18n.yaml
This commit is contained in:
@@ -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', () => {
|
||||
|
||||
@@ -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')],
|
||||
|
||||
Reference in New Issue
Block a user