Merge remote-tracking branch 'origin/master' into xtr/react-loop-simplification
# Conflicts: # apps/cli/tests/snapshots/queued-manual-compact/terminal.expected.txt # apps/web/tests/snapshots/queue-actions/preserved.expected.md # packages/client/runtime/README.i18n.yaml # packages/client/runtime/src/client/sessions/session.ts # packages/client/ui-conversation/README.md # packages/client/ui-conversation/README.zh.md # packages/ui/tui/README.i18n.yaml # packages/ui/tui/tests/snapshots/untrusted-controls.expected.txt
This commit is contained in:
@@ -36,12 +36,14 @@ describe('MessageItem arms', () => {
|
||||
// Same-day clock: construct "today at 14:24" so the label stays `HH:mm`.
|
||||
const now = new Date()
|
||||
const time = new Date(now.getFullYear(), now.getMonth(), now.getDate(), 14, 24).getTime()
|
||||
const onFork = vi.fn()
|
||||
render(
|
||||
<MessageItem t={t} node={{
|
||||
kind: 'user', seq: 1, time,
|
||||
content: [{ type: 'text', text: 'hello bubble' }] as never,
|
||||
source: null,
|
||||
}}
|
||||
onFork={onFork}
|
||||
/>,
|
||||
)
|
||||
expect(screen.getByText('14:24')).toBeTruthy()
|
||||
@@ -50,6 +52,8 @@ describe('MessageItem arms', () => {
|
||||
expect(screen.queryByRole('button', { name: '编辑' })).toBeNull()
|
||||
fireEvent.click(screen.getByRole('button', { name: '复制' }))
|
||||
expect(writeText).toHaveBeenCalledWith('hello bubble')
|
||||
fireEvent.click(screen.getByRole('button', { name: '在新对话中分支' }))
|
||||
expect(onFork).toHaveBeenCalledWith(1)
|
||||
})
|
||||
|
||||
it('user copy falls back to execCommand when clipboard.writeText is unavailable', () => {
|
||||
@@ -74,6 +78,30 @@ describe('MessageItem arms', () => {
|
||||
expect(exec).toHaveBeenCalledWith('copy')
|
||||
})
|
||||
|
||||
it('keeps an unavailable branch focusable and explains why without sending a fork', () => {
|
||||
const onFork = vi.fn()
|
||||
render(
|
||||
<MessageItem t={t} node={{
|
||||
kind: 'user', seq: 1, time: 1_000,
|
||||
content: [{ type: 'text', text: 'open turn' }] as never,
|
||||
source: null,
|
||||
}}
|
||||
onFork={onFork}
|
||||
forkUnavailable
|
||||
/>,
|
||||
)
|
||||
const branch = screen.getByRole('button', { name: '在新对话中分支' }) as HTMLButtonElement
|
||||
expect(branch.disabled).toBe(false)
|
||||
expect(branch.getAttribute('aria-disabled')).toBe('true')
|
||||
const reasonId = branch.getAttribute('aria-describedby')
|
||||
expect(reasonId).not.toBeNull()
|
||||
expect(document.getElementById(reasonId!)?.textContent).toBe('仅可从已完成轮次的最后一条消息分支')
|
||||
fireEvent.click(branch)
|
||||
expect(onFork).not.toHaveBeenCalled()
|
||||
fireEvent.focus(branch)
|
||||
expect(screen.getByRole('tooltip').textContent).toBe('仅可从已完成轮次的最后一条消息分支')
|
||||
})
|
||||
|
||||
it('user copy stays quiet when execCommand throws or is absent', () => {
|
||||
Object.defineProperty(navigator, 'clipboard', {
|
||||
configurable: true,
|
||||
@@ -396,12 +424,15 @@ describe('small branch tails', () => {
|
||||
})
|
||||
const now = new Date()
|
||||
const time = new Date(now.getFullYear(), now.getMonth(), now.getDate(), 14, 24).getTime()
|
||||
const onFork = vi.fn()
|
||||
const settled = render(
|
||||
<AssistantMarkdown
|
||||
t={t}
|
||||
blocks={[{ kind: 'text', text: 'answer body' }, { kind: 'reasoning', text: 'hidden' }]}
|
||||
streaming={false}
|
||||
time={time}
|
||||
seq={3}
|
||||
onFork={onFork}
|
||||
/>,
|
||||
)
|
||||
expect(settled.getByText('14:24')).toBeTruthy()
|
||||
@@ -409,6 +440,8 @@ describe('small branch tails', () => {
|
||||
expect(settled.getByRole('button', { name: '在新对话中分支' })).toBeTruthy()
|
||||
fireEvent.click(settled.getByRole('button', { name: '复制' }))
|
||||
expect(writeText).toHaveBeenCalledWith('answer body')
|
||||
fireEvent.click(settled.getByRole('button', { name: '在新对话中分支' }))
|
||||
expect(onFork).toHaveBeenCalledWith(3)
|
||||
settled.unmount()
|
||||
|
||||
const thinkOnly = render(
|
||||
|
||||
@@ -67,7 +67,7 @@ function snapshotWith(
|
||||
runningCalls: RunningToolCall[] = [],
|
||||
): ConversationSnapshot {
|
||||
return {
|
||||
sessionId: SID, nodes, partial: null, runningCalls, codeDispatches,
|
||||
sessionId: SID, nodes, turnEnds: new Map(), partial: null, runningCalls, codeDispatches,
|
||||
pending: [], queue: [], running: runningCalls.length > 0, composerPhase: 'active', removed: false,
|
||||
openState: 'open', openError: null,
|
||||
hasMore: false, loadingOlder: false, promptError: null, blank: false, subagent: null, lastAgentError: null,
|
||||
|
||||
@@ -32,7 +32,7 @@ const assistant = (seq: number, turn: number, usage?: unknown): AssistantMessage
|
||||
|
||||
function snapshotBase(): ConversationSnapshot {
|
||||
return {
|
||||
sessionId: SID, nodes: [], partial: null, runningCalls: [], codeDispatches: new Map(),
|
||||
sessionId: SID, nodes: [], turnEnds: new Map(), partial: null, runningCalls: [], codeDispatches: new Map(),
|
||||
pending: [], queue: [], running: false, composerPhase: 'active', removed: false, openState: 'open', openError: null,
|
||||
hasMore: false, loadingOlder: false, promptError: null, blank: false, subagent: null, lastAgentError: null,
|
||||
}
|
||||
|
||||
@@ -20,7 +20,7 @@ import { zh as commonZh } from '@deepseek-ai/dsh-client-locale/src/locales/zh.ts
|
||||
import { createChatStore } from '../src/client/stores.ts'
|
||||
import { ChatView } from '../src/client/chat/ChatView.tsx'
|
||||
import { zh } from '../src/client/locales.ts'
|
||||
import { assistantActionsSeqs, deriveChatFlow, flowKeys } from '../src/client/chat/chat-flow.ts'
|
||||
import { assistantActionsSeqs, deriveChatFlow, flowKeys, messageBranchSeqs } from '../src/client/chat/chat-flow.ts'
|
||||
|
||||
afterEach(cleanup)
|
||||
// Keyless create() persists under the bare declared key; clear between cases
|
||||
@@ -33,7 +33,7 @@ const SID = 's1' as SessionId
|
||||
|
||||
function snapshotBase(): ConversationSnapshot {
|
||||
return {
|
||||
sessionId: SID, nodes: [], partial: null, runningCalls: [], codeDispatches: new Map(),
|
||||
sessionId: SID, nodes: [], turnEnds: new Map(), partial: null, runningCalls: [], codeDispatches: new Map(),
|
||||
pending: [], queue: [], running: false, composerPhase: 'active', removed: false, openState: 'open', openError: null,
|
||||
hasMore: false, loadingOlder: false, promptError: null, blank: false, subagent: null, lastAgentError: null,
|
||||
}
|
||||
@@ -211,6 +211,25 @@ describe('chat-flow derivation', () => {
|
||||
])
|
||||
expect([...seqs].sort((a, b) => a - b)).toEqual([5, 7])
|
||||
})
|
||||
|
||||
it('messageBranchSeqs keeps only message rows at completed transcript tails', () => {
|
||||
const interruptedThink: AssistantMessageNode = {
|
||||
kind: 'assistant', seq: 4.1, time: 4_100, turn: 1, step: 2,
|
||||
blocks: [{ kind: 'reasoning', text: 'bad path' }], interrupted: true,
|
||||
}
|
||||
const nodes: ConversationNode[] = [
|
||||
user(1, 'first'),
|
||||
assistant(2, 'answer before tools'),
|
||||
toolResult(3, 'a'),
|
||||
interruptedThink,
|
||||
user(6, 'second'),
|
||||
assistant(7, 'clean tail', 2),
|
||||
user(10, 'user-only tail'),
|
||||
user(13, 'steering tail'),
|
||||
]
|
||||
const seqs = messageBranchSeqs(nodes, new Map([[1, 5], [2, 8], [3, 11], [4, 14]]))
|
||||
expect([...seqs]).toEqual([7, 10, 13])
|
||||
})
|
||||
})
|
||||
|
||||
describe('ChatView', () => {
|
||||
@@ -301,8 +320,18 @@ describe('ChatView', () => {
|
||||
expect(view.getAllByText('interrupt now')).toHaveLength(1)
|
||||
expect(view.container.querySelector('[data-pending-steering]')).toBeNull()
|
||||
expect(view.getAllByRole('button', { name: '复制' })).toHaveLength(2)
|
||||
const durableBubble = view.getByText('interrupt now').closest('[class*="userRow"]') as HTMLElement
|
||||
const unavailable = within(durableBubble).getByRole('button', { name: '在新对话中分支' })
|
||||
expect(unavailable.getAttribute('aria-disabled')).toBe('true')
|
||||
fireEvent.click(unavailable)
|
||||
expect(h.forkAt).not.toHaveBeenCalled()
|
||||
|
||||
act(() => {
|
||||
h.set({ running: false, turnEnds: new Map([[1, 3]]) })
|
||||
})
|
||||
const branchButtons = view.getAllByRole('button', { name: '在新对话中分支' })
|
||||
expect(branchButtons).toHaveLength(2)
|
||||
expect(branchButtons.map(button => button.getAttribute('aria-disabled'))).toEqual(['true', null])
|
||||
fireEvent.click(branchButtons[1]!)
|
||||
expect(h.forkAt).toHaveBeenCalledWith(2)
|
||||
})
|
||||
@@ -402,21 +431,47 @@ describe('ChatView', () => {
|
||||
user(5, 'next'),
|
||||
assistant(6, 'second turn', 2),
|
||||
],
|
||||
turnEnds: new Map([[1, 4], [2, 6]]),
|
||||
})
|
||||
const view = render(<h.ChatView {...h.props} />)
|
||||
// 2 user + 2 turn-tail assistants; mid-turn text at seq 2 stays chrome-free.
|
||||
// Every message footer keeps branch visible; only completed assistant tails enable it.
|
||||
expect(view.getAllByRole('button', { name: '复制' })).toHaveLength(4)
|
||||
expect(view.getAllByRole('button', { name: '在新对话中分支' })).toHaveLength(4)
|
||||
const branchButtons = view.getAllByRole('button', { name: '在新对话中分支' })
|
||||
expect(branchButtons).toHaveLength(4)
|
||||
expect(branchButtons.map(button => button.getAttribute('aria-disabled'))).toEqual(['true', null, 'true', null])
|
||||
})
|
||||
|
||||
it('forks from both user and finalized assistant message actions at their event seq', () => {
|
||||
const h = makeHarness({ nodes: [user(1, 'question'), assistant(2, 'answer')] })
|
||||
it('enables fork only on the finalized assistant at the completed transcript tail', () => {
|
||||
const h = makeHarness({
|
||||
nodes: [user(1, 'question'), assistant(2, 'answer')],
|
||||
turnEnds: new Map([[1, 3]]),
|
||||
})
|
||||
const view = render(<h.ChatView {...h.props} />)
|
||||
const buttons = view.getAllByRole('button', { name: '在新对话中分支' })
|
||||
expect(buttons).toHaveLength(2)
|
||||
expect(buttons.map(button => button.getAttribute('aria-disabled'))).toEqual(['true', null])
|
||||
fireEvent.click(buttons[0]!)
|
||||
fireEvent.click(buttons[1]!)
|
||||
expect(h.forkAt.mock.calls).toEqual([[1], [2]])
|
||||
expect(h.forkAt.mock.calls).toEqual([[2]])
|
||||
})
|
||||
|
||||
it('keeps branch visible but unavailable when tool and interrupted Think follow the response', () => {
|
||||
const interruptedThink: AssistantMessageNode = {
|
||||
kind: 'assistant', seq: 4.1, time: 4_100, turn: 1, step: 2,
|
||||
blocks: [{ kind: 'reasoning', text: 'bad path' }], interrupted: true,
|
||||
}
|
||||
const h = makeHarness({
|
||||
nodes: [user(1, 'question'), assistant(2, 'answer'), toolResult(3, 'a'), interruptedThink],
|
||||
turnEnds: new Map([[1, 5]]),
|
||||
})
|
||||
const view = render(<h.ChatView {...h.props} />)
|
||||
expect(view.getAllByRole('button', { name: '复制' })).toHaveLength(2)
|
||||
const buttons = view.getAllByRole('button', { name: '在新对话中分支' })
|
||||
expect(buttons).toHaveLength(2)
|
||||
expect(buttons.every(button => button.getAttribute('aria-disabled') === 'true')).toBe(true)
|
||||
fireEvent.click(buttons[0]!)
|
||||
fireEvent.click(buttons[1]!)
|
||||
expect(h.forkAt).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('renders assistant Markdown across history, streaming, final, and interrupted states while user text stays literal', () => {
|
||||
|
||||
@@ -335,7 +335,7 @@ describe('DetailsPanel diff Output section', () => {
|
||||
|
||||
function snapshot(over: Partial<ConversationSnapshot> = {}): ConversationSnapshot {
|
||||
return {
|
||||
sessionId: SID, nodes: [], partial: null, runningCalls: [], codeDispatches: new Map(),
|
||||
sessionId: SID, nodes: [], turnEnds: new Map(), partial: null, runningCalls: [], codeDispatches: new Map(),
|
||||
pending: [], queue: [], running: false, composerPhase: 'active', removed: false,
|
||||
openState: 'open', openError: null, hasMore: false, loadingOlder: false,
|
||||
promptError: null, blank: false, subagent: null, lastAgentError: null, ...over,
|
||||
|
||||
@@ -24,7 +24,7 @@ const SID = 's1' as SessionId
|
||||
|
||||
function snapshotBase(): ConversationSnapshot {
|
||||
return {
|
||||
sessionId: SID, nodes: [], partial: null, runningCalls: [], codeDispatches: new Map(),
|
||||
sessionId: SID, nodes: [], turnEnds: new Map(), partial: null, runningCalls: [], codeDispatches: new Map(),
|
||||
pending: [], queue: [], running: false, composerPhase: 'active', removed: false, openState: 'open', openError: null,
|
||||
hasMore: false, loadingOlder: false, promptError: null, blank: false, subagent: null, lastAgentError: null,
|
||||
}
|
||||
|
||||
@@ -23,7 +23,7 @@ const SID = 's1' as SessionId
|
||||
|
||||
function snapshotOf(overrides: Partial<ConversationSnapshot> = {}): ConversationSnapshot {
|
||||
return {
|
||||
sessionId: SID, nodes: [], partial: null, runningCalls: [], codeDispatches: new Map(),
|
||||
sessionId: SID, nodes: [], turnEnds: new Map(), partial: null, runningCalls: [], codeDispatches: new Map(),
|
||||
pending: [], queue: [], running: false, composerPhase: 'active', removed: false,
|
||||
openState: 'open', openError: null, hasMore: false, loadingOlder: false,
|
||||
promptError: null, blank: false, subagent: null, lastAgentError: null,
|
||||
|
||||
@@ -26,7 +26,7 @@ const SID = 's1' as SessionId
|
||||
/** Standard-props InputBar mount over a real shell (the composer-bar entry shape). */
|
||||
function mountBar(shell: SessionInputShell, over?: { running?: boolean; disabled?: boolean }) {
|
||||
const session = createSnapshotStore<ConversationSnapshot>({
|
||||
sessionId: SID, nodes: [], partial: null, runningCalls: [], codeDispatches: new Map(),
|
||||
sessionId: SID, nodes: [], turnEnds: new Map(), partial: null, runningCalls: [], codeDispatches: new Map(),
|
||||
pending: [], queue: [], running: over?.running ?? false, composerPhase: 'active',
|
||||
removed: over?.disabled ?? false, openState: 'open', openError: null, hasMore: false,
|
||||
loadingOlder: false, promptError: null, blank: false, subagent: null, lastAgentError: null,
|
||||
|
||||
@@ -112,7 +112,7 @@ async function scopedBench(register?: (slash: SlashService) => void) {
|
||||
actx.on('slash/input-consume-token', req => shell.consumeToken(req.guard) ? true : undefined)
|
||||
const wiring = shell
|
||||
const sessionStore = createSnapshotStore<ConversationSnapshot>({
|
||||
sessionId, nodes: [], partial: null, runningCalls: [], codeDispatches: new Map(),
|
||||
sessionId, nodes: [], turnEnds: new Map(), partial: null, runningCalls: [], codeDispatches: new Map(),
|
||||
pending: [], queue: [], running: false, composerPhase: 'active', removed: false,
|
||||
openState: 'open', openError: null, hasMore: false, loadingOlder: false,
|
||||
promptError: null, blank: false, subagent: null, lastAgentError: null,
|
||||
|
||||
@@ -32,7 +32,7 @@ function row(id: string, text: string | null, preview = text ?? '[image]'): Queu
|
||||
|
||||
function snapshotWith(queue: QueuedMessage[]): ConversationSnapshot {
|
||||
return {
|
||||
sessionId: SID, nodes: [], partial: null, runningCalls: [], codeDispatches: new Map(),
|
||||
sessionId: SID, nodes: [], turnEnds: new Map(), partial: null, runningCalls: [], codeDispatches: new Map(),
|
||||
pending: [], queue, running: true, composerPhase: 'active', removed: false, openState: 'open', openError: null,
|
||||
hasMore: false, loadingOlder: false, promptError: null, blank: false, subagent: null, lastAgentError: null,
|
||||
}
|
||||
|
||||
@@ -283,7 +283,7 @@ describe('DetailsPanel Output section (read)', () => {
|
||||
|
||||
function snapshot(over: Partial<ConversationSnapshot> = {}): ConversationSnapshot {
|
||||
return {
|
||||
sessionId: SID, nodes: [], partial: null, runningCalls: [], codeDispatches: new Map(),
|
||||
sessionId: SID, nodes: [], turnEnds: new Map(), partial: null, runningCalls: [], codeDispatches: new Map(),
|
||||
pending: [], queue: [], running: false, composerPhase: 'active', removed: false,
|
||||
openState: 'open', openError: null, hasMore: false, loadingOlder: false,
|
||||
promptError: null, blank: false, subagent: null, lastAgentError: null, ...over,
|
||||
|
||||
@@ -397,7 +397,7 @@ describe('DetailsPanel Output section (search)', () => {
|
||||
|
||||
function snapshot(over: Partial<ConversationSnapshot> = {}): ConversationSnapshot {
|
||||
return {
|
||||
sessionId: SID, nodes: [], partial: null, runningCalls: [], codeDispatches: new Map(),
|
||||
sessionId: SID, nodes: [], turnEnds: new Map(), partial: null, runningCalls: [], codeDispatches: new Map(),
|
||||
pending: [], queue: [], running: false, composerPhase: 'active', removed: false,
|
||||
openState: 'open', openError: null, hasMore: false, loadingOlder: false,
|
||||
promptError: null, blank: false, subagent: null, lastAgentError: null, ...over,
|
||||
|
||||
@@ -68,7 +68,7 @@ const workspaceState = (items: readonly WorkspaceView[]): WorkspaceListState =>
|
||||
|
||||
function conversationSnapshot(overrides: Partial<ConversationSnapshot> = {}): ConversationSnapshot {
|
||||
return {
|
||||
sessionId: SID, nodes: [], partial: null, runningCalls: [], codeDispatches: new Map(),
|
||||
sessionId: SID, nodes: [], turnEnds: new Map(), partial: null, runningCalls: [], codeDispatches: new Map(),
|
||||
pending: [], queue: [], running: false, composerPhase: 'active', removed: false,
|
||||
openState: 'open', openError: null, hasMore: false, loadingOlder: false,
|
||||
promptError: null, blank: false, subagent: null, lastAgentError: null,
|
||||
|
||||
@@ -414,6 +414,28 @@ describe('BashRow terminal card', () => {
|
||||
}))} />)
|
||||
expect(view.getByText('List files')).toBeTruthy()
|
||||
expect(view.queryByText(/a\.ts/)).toBeNull()
|
||||
expect(view.container.querySelector('[data-sample="bash"]')?.getAttribute('role')).toBeNull()
|
||||
})
|
||||
|
||||
it('expands a generic execution error to its original args and full output', () => {
|
||||
const view = render(<BashRow {...rowProps(settled({
|
||||
content: [{ type: 'text', text: 'Error: command aborted' }],
|
||||
isError: true,
|
||||
callView: { card: 'generic', title: 'ls -la', kind: 'execute' },
|
||||
resultView: { card: 'generic' },
|
||||
}))} />)
|
||||
const row = view.container.querySelector('[data-sample="bash"]')!
|
||||
expect(row.getAttribute('role')).toBe('button')
|
||||
expect(row.getAttribute('aria-expanded')).toBe('false')
|
||||
expect(view.queryByText(/"command": "ls -la"/)).toBeNull()
|
||||
|
||||
fireEvent.click(row)
|
||||
|
||||
expect(row.getAttribute('aria-expanded')).toBe('true')
|
||||
expect(view.getByText('IN')).toBeTruthy()
|
||||
expect(view.getByText('OUT')).toBeTruthy()
|
||||
expect(view.getByText(/"command": "ls -la"/)).toBeTruthy()
|
||||
expect(view.container.querySelector('[data-error]')?.textContent).toBe('Error: command aborted')
|
||||
})
|
||||
})
|
||||
|
||||
@@ -455,7 +477,7 @@ describe('DetailsPanel Output section', () => {
|
||||
|
||||
function snapshot(over: Partial<ConversationSnapshot> = {}): ConversationSnapshot {
|
||||
return {
|
||||
sessionId: SID, nodes: [], partial: null, runningCalls: [], codeDispatches: new Map(),
|
||||
sessionId: SID, nodes: [], turnEnds: new Map(), partial: null, runningCalls: [], codeDispatches: new Map(),
|
||||
pending: [], queue: [], running: false, composerPhase: 'active', removed: false,
|
||||
openState: 'open', openError: null, hasMore: false, loadingOlder: false,
|
||||
promptError: null, blank: false, subagent: null, lastAgentError: null, ...over,
|
||||
|
||||
@@ -232,7 +232,7 @@ describe('DetailsPanel web Output section', () => {
|
||||
|
||||
function snapshot(over: Partial<ConversationSnapshot> = {}): ConversationSnapshot {
|
||||
return {
|
||||
sessionId: SID, nodes: [], partial: null, runningCalls: [], codeDispatches: new Map(),
|
||||
sessionId: SID, nodes: [], turnEnds: new Map(), partial: null, runningCalls: [], codeDispatches: new Map(),
|
||||
pending: [], queue: [], running: false, composerPhase: 'active', removed: false,
|
||||
openState: 'open', openError: null, hasMore: false, loadingOlder: false,
|
||||
promptError: null, blank: false, subagent: null, lastAgentError: null, ...over,
|
||||
|
||||
Reference in New Issue
Block a user