Merge remote-tracking branch 'origin/master' into worktree/web-multimodal-image-input
# Conflicts: # docs/architecture.i18n.yaml # docs/architecture.md # docs/architecture.zh.md # docs/config-catalog.md # docs/core-data-structures/core.i18n.yaml # docs/core-data-structures/llm-streaming.i18n.yaml # docs/module-graph.md # examples/acp-agent/tests/snapshots/cordis-inspect-jsdoc/session.jsonl # packages/README.i18n.yaml # packages/client/connection/src/client/fixture.ts # packages/client/connection/src/index.ts # packages/client/runtime/README.i18n.yaml # packages/client/runtime/README.md # packages/client/runtime/README.zh.md # packages/client/runtime/src/client/sessions/conversation.ts # packages/client/ui-conversation/README.i18n.yaml # packages/client/ui-conversation/src/client/apply.ts # packages/client/ui-conversation/src/client/chat/ChatView.tsx # packages/client/ui-conversation/src/client/chat/MessageItem.tsx # packages/client/ui-conversation/src/client/contract/slots.ts # packages/client/ui-trajectory/tests/views.spec.tsx # packages/compact/compact-basic/README.i18n.yaml # packages/cordis/tool-cordis/src/api-catalog.ts # packages/host/apiproxy/src/api-proxy.ts # packages/host/apiproxy/src/api/index.ts # packages/host/apiproxy/src/api/sessions.ts # packages/host/apiproxy/src/index.ts # packages/host/apiproxy/tests/fetch-carrier.spec.ts # packages/llm/llm-deepseek/src/adapter.ts # packages/llm/llm-deepseek/tests/adapter.spec.ts # packages/llm/llm-deepseek/tests/serialize.spec.ts # packages/llm/llm-pi-ai/README.i18n.yaml # packages/llm/llm-pi-ai/src/adapter.ts # packages/llm/llm-pi-ai/src/index.ts # packages/llm/llm-pi-ai/tests/adapter.spec.ts # packages/llm/llm/src/types.ts # packages/ui/tui/README.i18n.yaml # packages/ui/tui/src/index.ts # packages/ui/tui/tests/tui.spec.ts
This commit is contained in:
@@ -36,10 +36,12 @@ const SCOPE_TAG: symbol = (() => {
|
||||
const spy = new Proxy(new Context(), {
|
||||
get(target, prop, receiver) {
|
||||
recorded.push(prop)
|
||||
// Reflect.get is typed any; the probe only records property names.
|
||||
// eslint-disable-next-line @typescript-eslint/no-unsafe-return
|
||||
return Reflect.get(target, prop, receiver)
|
||||
},
|
||||
})
|
||||
void scopeOf(spy as Context)
|
||||
void scopeOf(spy)
|
||||
const symbol = recorded.find((p): p is symbol => typeof p === 'symbol')
|
||||
if (symbol === undefined) throw new Error('scopeOf probe recorded no symbol read')
|
||||
return symbol
|
||||
@@ -73,14 +75,15 @@ async function bench() {
|
||||
const mint = (id: SessionId): Context => {
|
||||
let scoped = scopes.get(id)
|
||||
if (scoped === undefined) {
|
||||
scoped = ctx.plugin(() => {}).ctx.extend({ [SCOPE_TAG]: id }) as Context
|
||||
scoped = ctx.plugin(() => {}).ctx.extend({ [SCOPE_TAG]: id })
|
||||
scopes.set(id, scoped)
|
||||
}
|
||||
return scoped
|
||||
}
|
||||
type TestProvider = {
|
||||
resolve(binding: { sessionId: SessionId; session: typeof sessionFake; ctx: Context }): {
|
||||
hooks?: Record<string, unknown>; props?: Record<string, unknown>
|
||||
hooks?: Record<string, unknown>
|
||||
props?: Record<string, unknown>
|
||||
}
|
||||
}
|
||||
const providers: TestProvider[] = []
|
||||
@@ -158,10 +161,12 @@ async function bench() {
|
||||
const inputSurface = (id: SessionId) => {
|
||||
const contribution = providers[0]!.resolve(sessionsFake.binding(id))
|
||||
const state = contribution.hooks!['input'] as {
|
||||
getSnapshot(): { draft: string }; subscribe(fn: () => void): () => void
|
||||
getSnapshot: () => { draft: string }
|
||||
subscribe: (fn: () => void) => () => void
|
||||
}
|
||||
const actions = contribution.props!['inputActions'] as {
|
||||
setDraft(text: string): void; submit(mode?: 'queue' | 'steer'): void
|
||||
setDraft: (text: string) => void
|
||||
submit: (mode?: 'queue' | 'steer') => void
|
||||
}
|
||||
return { state, actions }
|
||||
}
|
||||
@@ -234,11 +239,11 @@ describe('conversation slot inject surface', () => {
|
||||
const injectFn = entry.inject as unknown as (sessionId: SessionId) => ComposerBarInjected
|
||||
// Unknown session: sessions.scope answers nothing.
|
||||
;(b.sessionsFake.scope as unknown) = () => undefined
|
||||
expect(() => injectFn(ROOT).stop()).toThrow(/resolved no scope/)
|
||||
expect(() => { injectFn(ROOT).stop() }).toThrow(/resolved no scope/)
|
||||
// A scope minted outside the service tree: no conversation service on it.
|
||||
const foreign = new Context()
|
||||
;(b.sessionsFake.scope as unknown) = () => foreign.plugin(() => {}).ctx.extend({})
|
||||
expect(() => injectFn(ROOT).stop()).toThrow(/unavailable through the session scope/)
|
||||
expect(() => { injectFn(ROOT).stop() }).toThrow(/unavailable through the session scope/)
|
||||
})
|
||||
|
||||
it('openDetails (chat view face) writes the selection through the store actions and opens the panel', async () => {
|
||||
@@ -263,7 +268,7 @@ describe('conversation slot inject surface', () => {
|
||||
// no draft movement, plain re-open.
|
||||
const { state, actions } = b.inputSurface(ROOT)
|
||||
actions.setDraft('carry me')
|
||||
resident.selectWorkspace('workspace-1' as never)
|
||||
void resident.selectWorkspace('workspace-1' as never)
|
||||
await vi.waitFor(() => { expect(b.sessionsFake.open).toHaveBeenCalledTimes(2) })
|
||||
expect(b.workspacesFake.connectWorkspace).toHaveBeenCalledWith('workspace-1')
|
||||
expect(state.getSnapshot().draft).toBe('carry me')
|
||||
@@ -271,7 +276,7 @@ describe('conversation slot inject surface', () => {
|
||||
// new session's machine receives the text, then navigation lands there.
|
||||
const OTHER = 'other-1' as SessionId
|
||||
b.workspacesFake.connectWorkspace.mockResolvedValueOnce(OTHER)
|
||||
resident.selectWorkspace('workspace-2' as never)
|
||||
void resident.selectWorkspace('workspace-2' as never)
|
||||
await vi.waitFor(() => { expect(b.sessionsFake.open).toHaveBeenCalledWith(OTHER) })
|
||||
expect(state.getSnapshot().draft).toBe('')
|
||||
expect(b.inputSurface(OTHER).state.getSnapshot().draft).toBe('carry me')
|
||||
|
||||
@@ -31,7 +31,7 @@ async function bench() {
|
||||
},
|
||||
current: undefined,
|
||||
phase: 'ready',
|
||||
} as SessionListState)
|
||||
})
|
||||
const sessionsFake = {
|
||||
list: listStore,
|
||||
binding: vi.fn(),
|
||||
@@ -83,7 +83,7 @@ describe('apply wiring', () => {
|
||||
const b = await bench()
|
||||
await b.fiber.await()
|
||||
const entries = b.slots.entries('conversation.view')
|
||||
expect(entries.map((e) => e.options.id)).toEqual(['chat'])
|
||||
expect(entries.map(e => e.options.id)).toEqual(['chat'])
|
||||
expect(entries[0]?.options.label).toBe('Chat')
|
||||
expect(entries[0]?.options.order).toBe(0)
|
||||
// Declaring is claiming: the chat entry's registration put the hole on
|
||||
@@ -111,13 +111,13 @@ describe('apply wiring', () => {
|
||||
expect(b.slots.spec('conversation.hero.workspace')).toEqual({ kind: 'single', scope: 'root' })
|
||||
})
|
||||
|
||||
it('mounts the bash sample as a keyed entry through the load-order seam', async () => {
|
||||
it('mounts the bash sample and the todo row as keyed entries through the load-order seam', async () => {
|
||||
const b = await bench()
|
||||
await b.fiber.await()
|
||||
// The sample plugin's inject: ['slots', 'conversation'] resolved — the
|
||||
// Both registrant plugins' inject: ['slots', 'conversation'] resolved — the
|
||||
// service being present implies the chat entry declared the hole first.
|
||||
const entries = b.slots.entries('conversation.chat.toolview')
|
||||
expect(entries.map((e) => e.options.key)).toEqual(['bash'])
|
||||
expect(entries.map(e => e.options.key)).toEqual(['bash', 'todo_write'])
|
||||
})
|
||||
|
||||
it('plugin fiber disposal collects every registration (unload cascade, ring and hole included)', async () => {
|
||||
|
||||
@@ -1,11 +1,12 @@
|
||||
// @vitest-environment jsdom
|
||||
// Remaining chat branch tails: MessageItem context/unknown/steering arms,
|
||||
// StatsLine no-cache join, PendingCard reason strip, AssistantMarkdown
|
||||
// single-line reasoning. (Tool-row dispatch tails live with the keyed-slot
|
||||
// machinery specs since the tool ring dissolved into renderSlot.)
|
||||
// user IconActions, StatsLine no-cache join, PendingCard reason strip,
|
||||
// AssistantMarkdown single-line reasoning. (Tool-row dispatch tails live
|
||||
// with the keyed-slot machinery specs since the tool ring dissolved into
|
||||
// renderSlot.)
|
||||
|
||||
import { afterEach, describe, expect, it, vi } from 'vitest'
|
||||
import { cleanup, render } from '@testing-library/react'
|
||||
import { cleanup, fireEvent, render, screen } from '@testing-library/react'
|
||||
import { RpcId } from '@deepseek-ai/dsh-client-connection/client'
|
||||
import type { SessionId } from '@deepseek-ai/dsh-client-runtime/client'
|
||||
import { PendingWait } from '@deepseek-ai/dsh-client-runtime/client'
|
||||
@@ -18,7 +19,75 @@ import { StatsLine, type StatsLineProps } from '../src/client/chat/StatsLine.tsx
|
||||
afterEach(cleanup)
|
||||
|
||||
describe('MessageItem arms', () => {
|
||||
it('steering bubbles carry the interjection badge and non-text rest blocks', () => {
|
||||
it('user bubbles expose copy / branch / edit actions; copy writes the text', () => {
|
||||
const writeText = vi.fn().mockResolvedValue(undefined)
|
||||
Object.defineProperty(navigator, 'clipboard', {
|
||||
configurable: true,
|
||||
value: { writeText },
|
||||
})
|
||||
render(
|
||||
<MessageItem node={{
|
||||
kind: 'user', seq: 1,
|
||||
content: [{ type: 'text', text: 'hello bubble' }] as never,
|
||||
} as never}
|
||||
/>,
|
||||
)
|
||||
expect(screen.getByRole('button', { name: '复制' })).toBeTruthy()
|
||||
expect(screen.getByRole('button', { name: '在新对话中分支' })).toBeTruthy()
|
||||
expect(screen.getByRole('button', { name: '编辑' })).toBeTruthy()
|
||||
fireEvent.click(screen.getByRole('button', { name: '复制' }))
|
||||
expect(writeText).toHaveBeenCalledWith('hello bubble')
|
||||
})
|
||||
|
||||
it('user copy falls back to execCommand when clipboard.writeText is unavailable', () => {
|
||||
Object.defineProperty(navigator, 'clipboard', {
|
||||
configurable: true,
|
||||
value: undefined,
|
||||
})
|
||||
const exec = vi.fn().mockReturnValue(true)
|
||||
Object.defineProperty(document, 'execCommand', {
|
||||
configurable: true,
|
||||
value: exec,
|
||||
})
|
||||
render(
|
||||
<MessageItem node={{
|
||||
kind: 'user', seq: 1,
|
||||
content: [{ type: 'text', text: 'fallback body' }] as never,
|
||||
} as never}
|
||||
/>,
|
||||
)
|
||||
fireEvent.click(screen.getByRole('button', { name: '复制' }))
|
||||
expect(exec).toHaveBeenCalledWith('copy')
|
||||
})
|
||||
|
||||
it('user copy stays quiet when execCommand throws or is absent', () => {
|
||||
Object.defineProperty(navigator, 'clipboard', {
|
||||
configurable: true,
|
||||
value: undefined,
|
||||
})
|
||||
Object.defineProperty(document, 'execCommand', {
|
||||
configurable: true,
|
||||
value: () => {
|
||||
throw new Error('denied')
|
||||
},
|
||||
})
|
||||
render(
|
||||
<MessageItem node={{
|
||||
kind: 'user', seq: 1,
|
||||
content: [{ type: 'text', text: 'quiet' }] as never,
|
||||
} as never}
|
||||
/>,
|
||||
)
|
||||
fireEvent.click(screen.getByRole('button', { name: '复制' }))
|
||||
|
||||
Object.defineProperty(document, 'execCommand', {
|
||||
configurable: true,
|
||||
value: undefined,
|
||||
})
|
||||
fireEvent.click(screen.getByRole('button', { name: '复制' }))
|
||||
})
|
||||
|
||||
it('steering bubbles carry the interjection badge and non-text rest blocks, without user actions', () => {
|
||||
const view = render(
|
||||
<MessageItem node={{
|
||||
kind: 'steering', seq: 2, turn: 1, source: null,
|
||||
@@ -29,11 +98,12 @@ describe('MessageItem arms', () => {
|
||||
expect(view.getByText('插话')).toBeTruthy()
|
||||
expect(view.getByText('steer!')).toBeTruthy()
|
||||
expect(view.getByText(/附加内容块/)).toBeTruthy()
|
||||
expect(view.queryByRole('button', { name: '复制' })).toBeNull()
|
||||
})
|
||||
|
||||
it('context and unknown nodes render their JSON rows', () => {
|
||||
const ctxView = render(
|
||||
<MessageItem node={{ kind: 'context', seq: 3, content: [], source: null, meta: { k: 1 } } as never} />,
|
||||
<MessageItem node={{ kind: 'context', seq: 3, content: [], source: null } as never} />,
|
||||
)
|
||||
expect(ctxView.getByText(/上下文注入/)).toBeTruthy()
|
||||
const unknownView = render(
|
||||
|
||||
@@ -56,10 +56,10 @@ function snapshotWith(
|
||||
): ConversationSnapshot {
|
||||
return {
|
||||
sessionId: SID, nodes, foldDegraded: false, partial: null, runningCalls, codeDispatches,
|
||||
pending: [], queue: [], running: runningCalls.length > 0, composerPhase: 'active', removed: false,
|
||||
pending: [], queue: [], todos: [], running: runningCalls.length > 0, composerPhase: 'active', removed: false,
|
||||
openState: 'open', openError: null,
|
||||
hasMore: false, loadingOlder: false, promptError: null, blank: false, lastAgentError: null,
|
||||
} as ConversationSnapshot
|
||||
}
|
||||
}
|
||||
|
||||
/** Test-owned AppFrame role: declares and renders the resident conversation area. */
|
||||
@@ -156,16 +156,39 @@ describe('run_code sub-calls through the real chat machinery', () => {
|
||||
expect(view.getByText('List the notes directory')).toBeTruthy()
|
||||
|
||||
// Nested rows are ALWAYS visible (no parent expand needed): the bash
|
||||
// sub-call landed in the bash sample plugin's keyed registration — the
|
||||
// exact component a native top-level bash row uses — and the unregistered
|
||||
// sub-call landed in the bash sample plugin's keyed registration — Bash ·
|
||||
// description chrome, same as a top-level bash row — and the unregistered
|
||||
// sub-tool fell back to GenericToolCard at the same render site.
|
||||
const nest = view.container.querySelector('[data-subcalls]')
|
||||
expect(nest).not.toBeNull()
|
||||
expect(nest!.querySelector('[data-sample="bash-global"]')).not.toBeNull()
|
||||
expect(view.getByText('Bash')).toBeTruthy()
|
||||
expect(view.getByText('List notes')).toBeTruthy()
|
||||
expect(view.getByText('Tool call')).toBeTruthy()
|
||||
})
|
||||
|
||||
it('renders Cordis sub-calls with lifecycle titles over the generic variants', async () => {
|
||||
const parent = 'call-cordis'
|
||||
const code = 'return { name: "audit", apply(ctx) {} }'
|
||||
const dispatches = new Map([[parent, [
|
||||
subCall(11, parent, 1, 'cordis_inspect', { what: 'temporary' }, '## Temporary Plugins'),
|
||||
subCall(12, parent, 2, 'cordis_mount', { code }, 'Temporary Plugin dyn-2 is running'),
|
||||
subCall(13, parent, 3, 'cordis_unmount', { id: 'dyn-2' }, 'Temporary Plugin dyn-2 was unmounted and removed.'),
|
||||
]]])
|
||||
const b = await bench(snapshotWith([codeResult(10, parent)], dispatches))
|
||||
const view = mountApp(b.slots)
|
||||
const nest = view.container.querySelector('[data-subcalls]')!
|
||||
|
||||
expect(nest.querySelector('[data-tool="cordis_inspect"]')?.textContent).toContain('Inspect')
|
||||
const mounted = nest.querySelector('[data-variant="code"]')
|
||||
expect(mounted?.textContent).toContain(`Mount temporary Plugin${code}`)
|
||||
expect(nest.querySelector('[data-tool="cordis_unmount"]')?.textContent)
|
||||
.toContain('Unmount temporary Plugindyn-2')
|
||||
|
||||
fireEvent.click(mounted!.querySelector('button[aria-expanded]')!)
|
||||
expect(mounted!.querySelector('pre.shiki')?.textContent).toBe(code)
|
||||
})
|
||||
|
||||
it('expanding the code row reveals the program body verbatim (shiki-tokenized)', async () => {
|
||||
const parent = 'call-64'
|
||||
const b = await bench(snapshotWith([codeResult(10, parent)], new Map()))
|
||||
|
||||
@@ -27,7 +27,7 @@ const assistant = (seq: number, turn: number, usage?: unknown): AssistantMessage
|
||||
function snapshotBase(): ConversationSnapshot {
|
||||
return {
|
||||
sessionId: SID, nodes: [], foldDegraded: false, partial: null, runningCalls: [], codeDispatches: new Map(),
|
||||
pending: [], queue: [], running: false, composerPhase: 'active', removed: false, openState: 'open', openError: null,
|
||||
pending: [], queue: [], todos: [], running: false, composerPhase: 'active', removed: false, openState: 'open', openError: null,
|
||||
hasMore: false, loadingOlder: false, promptError: null, blank: false, lastAgentError: null,
|
||||
}
|
||||
}
|
||||
@@ -36,7 +36,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()
|
||||
},
|
||||
@@ -100,9 +100,9 @@ describe('StatsLine', () => {
|
||||
render(<Counting {...props(source)} />)
|
||||
const before = renders
|
||||
// Chunk frames swap partial only; nodes keeps its reference (object-layer contract).
|
||||
act(() => set({ partial: { turn: 1, step: 2, blocks: [{ kind: 'text', text: 'a' }] } }))
|
||||
act(() => set({ partial: { turn: 1, step: 2, blocks: [{ kind: 'text', text: 'ab' }] } }))
|
||||
act(() => set({ running: true }))
|
||||
act(() => { set({ partial: { turn: 1, step: 2, blocks: [{ kind: 'text', text: 'a' }] } }) })
|
||||
act(() => { set({ partial: { turn: 1, step: 2, blocks: [{ kind: 'text', text: 'ab' }] } }) })
|
||||
act(() => { set({ running: true }) })
|
||||
expect(renders).toBe(before)
|
||||
})
|
||||
})
|
||||
@@ -128,7 +128,7 @@ describe('bash sample row', () => {
|
||||
},
|
||||
current: undefined,
|
||||
phase: 'ready',
|
||||
} as SessionListState)
|
||||
})
|
||||
}
|
||||
|
||||
const rowProps = (sessionId: SessionId, over?: {
|
||||
@@ -169,17 +169,19 @@ describe('bash sample row', () => {
|
||||
expect(view.container.querySelector('[data-sample="bash-scoped"]')).not.toBeNull()
|
||||
})
|
||||
|
||||
it('summarizes the command and hands clicks to openDetails on both arms', () => {
|
||||
it('summarizes as Bash · description and hands clicks to openDetails on both arms', () => {
|
||||
const openGlobal = vi.fn()
|
||||
const global = render(<BashRow {...rowProps(ROOT, { openDetails: openGlobal })} />)
|
||||
// Two renders share document.body: query inside each container.
|
||||
const globalRow = global.container.querySelector('[data-sample="bash-global"]')!
|
||||
expect(globalRow.textContent).toContain('Bash')
|
||||
expect(globalRow.textContent).toContain('Build')
|
||||
fireEvent.click(globalRow)
|
||||
expect(openGlobal).toHaveBeenCalledTimes(1)
|
||||
const openScoped = vi.fn()
|
||||
const scoped = render(<BashRow {...rowProps(CHILD, { openDetails: openScoped })} />)
|
||||
const scopedRow = scoped.container.querySelector('[data-sample="bash-scoped"]')!
|
||||
expect(scopedRow.textContent).toContain('Bash')
|
||||
expect(scopedRow.textContent).toContain('Build')
|
||||
fireEvent.click(scopedRow)
|
||||
expect(openScoped).toHaveBeenCalledTimes(1)
|
||||
|
||||
@@ -31,6 +31,9 @@ describe('tool-call-model', () => {
|
||||
expect(classifyTool('grep')).toBe('search')
|
||||
expect(classifyTool('write')).toBe('write')
|
||||
expect(classifyTool('edit')).toBe('edit')
|
||||
expect(classifyTool('cordis_inspect')).toBe('read')
|
||||
expect(classifyTool('cordis_mount')).toBe('code')
|
||||
expect(classifyTool('cordis_unmount')).toBe('others')
|
||||
expect(classifyTool('todo_write')).toBe('others')
|
||||
})
|
||||
|
||||
@@ -67,6 +70,33 @@ describe('tool-call-model', () => {
|
||||
expect(toolRowModel('bash', running({ argsRaw: '' })).body).toBeNull()
|
||||
expect(toolRowModel('bash', result({ call: null })).body).toBeNull()
|
||||
})
|
||||
|
||||
it('gives Cordis lifecycle tools action titles over their generic variants', () => {
|
||||
expect(toolRowModel('cordis_inspect', running({
|
||||
name: 'cordis_inspect',
|
||||
argsRaw: '{"what":"api","name":"tools"}',
|
||||
}))).toMatchObject({
|
||||
variant: 'read',
|
||||
title: 'Inspect',
|
||||
summary: 'api',
|
||||
})
|
||||
expect(toolRowModel('cordis_mount', running({
|
||||
name: 'cordis_mount',
|
||||
argsRaw: '{"code":"return { name: \\"audit\\", apply(ctx) {} }"}',
|
||||
}))).toMatchObject({
|
||||
variant: 'code',
|
||||
title: 'Mount temporary Plugin',
|
||||
summary: 'return { name: "audit", apply(ctx) {} }',
|
||||
body: 'return { name: "audit", apply(ctx) {} }',
|
||||
})
|
||||
expect(toolRowModel('cordis_unmount', result({
|
||||
call: { name: 'cordis_unmount', argsRaw: '{"id":"dyn-2"}' },
|
||||
}))).toMatchObject({
|
||||
variant: 'others',
|
||||
title: 'Unmount temporary Plugin',
|
||||
summary: 'dyn-2',
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
describe('ToolRow', () => {
|
||||
|
||||
@@ -12,7 +12,7 @@
|
||||
|
||||
import { Context } from 'cordis'
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { act, cleanup, render } from '@testing-library/react'
|
||||
import { act, cleanup, fireEvent, render } from '@testing-library/react'
|
||||
import { createSnapshotStore, SlotsService } from '@deepseek-ai/dsh-client-runtime/client'
|
||||
import type {
|
||||
ConversationSnapshot, SessionId, SessionListState, ToolResultNode, WorkspaceListState,
|
||||
@@ -40,9 +40,9 @@ const toolResult = (seq: number, callId: string, name: string, args = '{"command
|
||||
function snapshotWith(nodes: ToolResultNode[]): ConversationSnapshot {
|
||||
return {
|
||||
sessionId: SID, nodes, foldDegraded: false, partial: null, runningCalls: [], codeDispatches: new Map(),
|
||||
pending: [], queue: [], running: false, composerPhase: 'active', removed: false, openState: 'open', openError: null,
|
||||
pending: [], queue: [], todos: [], running: false, composerPhase: 'active', removed: false, openState: 'open', openError: null,
|
||||
hasMore: false, loadingOlder: false, promptError: null, blank: false, lastAgentError: null,
|
||||
} as ConversationSnapshot
|
||||
}
|
||||
}
|
||||
|
||||
/** Test-owned AppFrame role: declares and renders the resident conversation area. */
|
||||
@@ -108,6 +108,10 @@ async function bench(nodes: ToolResultNode[]) {
|
||||
return info
|
||||
},
|
||||
maybeProvideInfo(id: string | undefined) {
|
||||
// `this` inside an object-literal method is any under strict lint; the
|
||||
// fake resolves through its own provideInfo above.
|
||||
/* eslint-disable-next-line @typescript-eslint/no-unsafe-return,
|
||||
@typescript-eslint/no-unsafe-call, @typescript-eslint/no-unsafe-member-access */
|
||||
return (id === undefined ? undefined : this.provideInfo(id)) ?? { hooks: {}, props: {} }
|
||||
},
|
||||
provide: (d: { resolve: (typeof providers)[number] }) => { providers.push(d.resolve); return () => {} },
|
||||
@@ -156,11 +160,31 @@ describe('keyed toolview hole through the real machinery', () => {
|
||||
// bash: the sample plugin's keyed registration took the row (root
|
||||
// session → global arm, decided inside the component off useSessions).
|
||||
expect(view.container.querySelector('[data-sample="bash-global"]')).not.toBeNull()
|
||||
expect(view.getByText('Bash')).toBeTruthy()
|
||||
expect(view.getByText('Build')).toBeTruthy()
|
||||
// mystery: no registration under that key → render-site fallback.
|
||||
expect(view.getByText('Tool call')).toBeTruthy()
|
||||
})
|
||||
|
||||
it('renders top-level Cordis calls with lifecycle titles over the generic variants', async () => {
|
||||
const code = 'return { name: "audit", apply(ctx) {} }'
|
||||
const b = await bench([
|
||||
toolResult(3, 'cordis-1', 'cordis_inspect', '{"what":"api","name":"tools"}'),
|
||||
toolResult(4, 'cordis-2', 'cordis_mount', JSON.stringify({ code })),
|
||||
toolResult(5, 'cordis-3', 'cordis_unmount', '{"id":"dyn-2"}'),
|
||||
])
|
||||
const view = mountApp(b.slots)
|
||||
|
||||
expect(view.container.querySelector('[data-tool="cordis_inspect"]')?.textContent).toContain('Inspect')
|
||||
const mounted = view.container.querySelector('[data-variant="code"]')
|
||||
expect(mounted?.textContent).toContain(`Mount temporary Plugin${code}`)
|
||||
expect(view.container.querySelector('[data-tool="cordis_unmount"]')?.textContent)
|
||||
.toContain('Unmount temporary Plugindyn-2')
|
||||
|
||||
fireEvent.click(mounted!.querySelector('button[aria-expanded]')!)
|
||||
expect(mounted!.querySelector('pre.shiki')?.textContent).toBe(code)
|
||||
})
|
||||
|
||||
it('row clicks travel owner openDetails → chat inject → layout orchestration', async () => {
|
||||
const b = await bench([toolResult(3, 'c1', 'bash')])
|
||||
const view = mountApp(b.slots)
|
||||
@@ -251,7 +275,7 @@ describe('registrant load-order seam', () => {
|
||||
children: {
|
||||
'conversation': { kind: 'single', scope: 'session-maybe' },
|
||||
'details': { kind: 'single', scope: 'session' },
|
||||
},
|
||||
},
|
||||
}, AppRoot)
|
||||
|
||||
// Third-party posture, mounted BEFORE ui-conversation: real fiber inject
|
||||
|
||||
@@ -7,7 +7,8 @@ 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, RunningToolCall, SessionId, SessionListState, ToolResultNode, UserMessageNode, WorkspaceListState,
|
||||
AssistantMessageNode, ConversationNode, ConversationSnapshot, 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'
|
||||
@@ -29,7 +30,7 @@ const SID = 's1' as SessionId
|
||||
function snapshotBase(): ConversationSnapshot {
|
||||
return {
|
||||
sessionId: SID, nodes: [], foldDegraded: false, partial: null, runningCalls: [], codeDispatches: new Map(),
|
||||
pending: [], queue: [], running: false, composerPhase: 'active', removed: false, openState: 'open', openError: null,
|
||||
pending: [], queue: [], todos: [], running: false, composerPhase: 'active', removed: false, openState: 'open', openError: null,
|
||||
hasMore: false, loadingOlder: false, promptError: null, blank: false, lastAgentError: null,
|
||||
}
|
||||
}
|
||||
@@ -39,7 +40,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()
|
||||
},
|
||||
@@ -104,8 +105,14 @@ 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: () => {},
|
||||
addImages: () => {},
|
||||
removeImage: () => {},
|
||||
pruneImages: () => {},
|
||||
submit: () => {},
|
||||
},
|
||||
useStore: bindSnapshotSelector(chat),
|
||||
actions: chat.actions,
|
||||
renderSlot,
|
||||
@@ -125,9 +132,9 @@ 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')
|
||||
})
|
||||
@@ -156,7 +163,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)
|
||||
})
|
||||
|
||||
@@ -241,10 +248,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
|
||||
@@ -265,13 +272,13 @@ describe('ChatView', () => {
|
||||
expect(view.getByText(/"command": "cmd-a"/)).toBeTruthy()
|
||||
})
|
||||
|
||||
it('clicking a tool row opens details with callId and toolName; selection paints the outline', () => {
|
||||
it('clicking a tool row opens details with callId and toolName; selection marks data-selected', () => {
|
||||
const h = makeHarness({ nodes: [toolResult(3, 'a')] })
|
||||
const view = render(<h.ChatView {...h.props} />)
|
||||
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()
|
||||
})
|
||||
|
||||
@@ -285,10 +292,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.
|
||||
@@ -307,10 +314,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)
|
||||
})
|
||||
|
||||
@@ -325,7 +332,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)
|
||||
@@ -338,7 +345,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()
|
||||
})
|
||||
|
||||
|
||||
@@ -1,13 +1,13 @@
|
||||
// @vitest-environment jsdom
|
||||
// Branch tails the acceptance specs do not reach: ToolRow stopped-state dot,
|
||||
// PendingCard question arm, bash sample error pill, the node-half empty
|
||||
// PendingCard question arm, bash sample state dots, the node-half empty
|
||||
// apply, and AssistantMarkdown reasoning/unknown block arms.
|
||||
|
||||
import { afterEach, describe, expect, it, vi } from 'vitest'
|
||||
import { cleanup, render } from '@testing-library/react'
|
||||
import { createSnapshotStore } from '@deepseek-ai/dsh-client-runtime/client'
|
||||
import { bindSnapshotSelector } from '@deepseek-ai/dsh-client-web-react'
|
||||
import type { SessionId, SessionListState, ToolResultNode } from '@deepseek-ai/dsh-client-runtime/client'
|
||||
import type { RunningToolCall, SessionId, SessionListState, ToolResultNode } from '@deepseek-ai/dsh-client-runtime/client'
|
||||
import { PendingWait } from '@deepseek-ai/dsh-client-runtime/client'
|
||||
import { RpcId } from '@deepseek-ai/dsh-client-connection/client'
|
||||
import type { ToolRowOwnerProps, ToolRowProps } from '@deepseek-ai/dsh-client-ui-conversation/client'
|
||||
@@ -22,7 +22,7 @@ afterEach(cleanup)
|
||||
|
||||
describe('tails', () => {
|
||||
it('node-half apply is an intentional no-op', () => {
|
||||
expect(nodeApply()).toBeUndefined()
|
||||
expect(() => { nodeApply() }).not.toThrow()
|
||||
})
|
||||
|
||||
it('ToolRow stopped state renders the warning dot in the leading slot', () => {
|
||||
@@ -76,27 +76,48 @@ describe('tails', () => {
|
||||
expect(view.container.querySelector('[data-state="ok"]')).not.toBeNull()
|
||||
})
|
||||
|
||||
it('BashRow shows the failed pill on error results (root session arm)', () => {
|
||||
const errorResult: ToolResultNode = {
|
||||
kind: 'tool-result', seq: 1, time: 1_000, callId: 'c1',
|
||||
call: { name: 'bash', argsRaw: '{"command":"boom"}' },
|
||||
callTime: 500,
|
||||
content: [], isError: true, callView: null, resultView: null,
|
||||
}
|
||||
// Root session (no parentId): the global arm renders, error pill visible.
|
||||
it('BashRow shows StateDot chrome for running/error/stopped (root session arm)', () => {
|
||||
const sid = 'root-1' as SessionId
|
||||
const list = createSnapshotStore<SessionListState>({
|
||||
ids: [sid],
|
||||
byId: { [sid]: { id: sid, title: 'r', displayTitle: 'r', running: false, blank: false, updatedAt: 0 } },
|
||||
current: undefined,
|
||||
phase: 'ready',
|
||||
} as SessionListState)
|
||||
const props = {
|
||||
callId: 'c1', toolName: 'bash', block: errorResult, openDetails: vi.fn(),
|
||||
})
|
||||
const props = (block: RunningToolCall | ToolResultNode) => ({
|
||||
callId: 'c1', toolName: 'bash', block, openDetails: vi.fn(),
|
||||
sessionId: sid, useSessions: bindSnapshotSelector(list),
|
||||
} as unknown as ToolRowProps
|
||||
const view = render(<BashRow {...props} />)
|
||||
expect(view.container.querySelector('[data-sample="bash-global"]')).not.toBeNull()
|
||||
expect(view.getByText('failed')).toBeTruthy()
|
||||
} as unknown as ToolRowProps)
|
||||
|
||||
const running: RunningToolCall = {
|
||||
callId: 'c1', name: 'bash', argsRaw: '{"command":"ls","description":"List"}',
|
||||
turn: 1, step: 1, time: 1_000, callView: null,
|
||||
}
|
||||
const errorResult: ToolResultNode = {
|
||||
kind: 'tool-result', seq: 1, time: 1_000, callId: 'c1',
|
||||
call: { name: 'bash', argsRaw: '{"command":"boom"}' },
|
||||
callTime: 500,
|
||||
content: [], isError: true, callView: null, resultView: null,
|
||||
}
|
||||
const stoppedResult: ToolResultNode = {
|
||||
...errorResult,
|
||||
error: { name: 'E', code: 'interrupted' },
|
||||
}
|
||||
|
||||
const runningView = render(<BashRow {...props(running)} />)
|
||||
expect(runningView.container.querySelector('[data-state="running"]')).not.toBeNull()
|
||||
expect(runningView.getByText('Bash')).toBeTruthy()
|
||||
expect(runningView.getByText('List')).toBeTruthy()
|
||||
runningView.unmount()
|
||||
|
||||
const errorView = render(<BashRow {...props(errorResult)} />)
|
||||
expect(errorView.container.querySelector('[data-sample="bash-global"]')).not.toBeNull()
|
||||
expect(errorView.container.querySelector('[data-state="error"]')).not.toBeNull()
|
||||
expect(errorView.getByText('失败')).toBeTruthy()
|
||||
errorView.unmount()
|
||||
|
||||
const stoppedView = render(<BashRow {...props(stoppedResult)} />)
|
||||
expect(stoppedView.container.querySelector('[data-state="stopped"]')).not.toBeNull()
|
||||
expect(stoppedView.getByText('已停止')).toBeTruthy()
|
||||
})
|
||||
})
|
||||
|
||||
@@ -19,9 +19,9 @@ const SID = 's1' as SessionId
|
||||
function snapshotBase(): ConversationSnapshot {
|
||||
return {
|
||||
sessionId: SID, nodes: [], foldDegraded: false, partial: null, runningCalls: [], codeDispatches: new Map(),
|
||||
pending: [], queue: [], running: false, composerPhase: 'active', removed: false, openState: 'open', openError: null,
|
||||
pending: [], queue: [], todos: [], running: false, composerPhase: 'active', removed: false, openState: 'open', openError: null,
|
||||
hasMore: false, loadingOlder: false, promptError: null, blank: false, lastAgentError: null,
|
||||
} as ConversationSnapshot
|
||||
}
|
||||
}
|
||||
|
||||
describe('render branch tails', () => {
|
||||
@@ -73,11 +73,17 @@ describe('render branch tails', () => {
|
||||
const view = render(
|
||||
<DetailsPanel
|
||||
sessionId={SID}
|
||||
useSession={bindSnapshotSelector({ getSnapshot: () => snap, subscribe: () => () => {} }) as unknown as UseSession<ConversationSnapshot>}
|
||||
useSession={bindSnapshotSelector({ getSnapshot: () => snap, subscribe: () => () => {} })}
|
||||
useSessions={bindSnapshotSelector(emptyList)}
|
||||
useWorkspaces={bindSnapshotSelector(emptyWorkspaces)}
|
||||
useInput={(() => { throw new Error('unused') }) as never}
|
||||
inputActions={{ setDraft: () => {}, submit: () => {} } as never}
|
||||
useInput={(() => { throw new Error('unused') })}
|
||||
inputActions={{
|
||||
setDraft: () => {},
|
||||
addImages: () => {},
|
||||
removeImage: () => {},
|
||||
pruneImages: () => {},
|
||||
submit: () => {},
|
||||
}}
|
||||
useStore={bindSnapshotSelector(chat)}
|
||||
actions={chat.actions}
|
||||
closeDetails={vi.fn()}
|
||||
@@ -108,11 +114,17 @@ describe('render branch tails', () => {
|
||||
const view = render(
|
||||
<DetailsPanel
|
||||
sessionId={SID}
|
||||
useSession={bindSnapshotSelector({ getSnapshot: () => snap, subscribe: () => () => {} }) as unknown as UseSession<ConversationSnapshot>}
|
||||
useSession={bindSnapshotSelector({ getSnapshot: () => snap, subscribe: () => () => {} })}
|
||||
useSessions={bindSnapshotSelector(emptyList)}
|
||||
useWorkspaces={bindSnapshotSelector(emptyWorkspaces)}
|
||||
useInput={(() => { throw new Error('unused') }) as never}
|
||||
inputActions={{ setDraft: () => {}, submit: () => {} } as never}
|
||||
useInput={(() => { throw new Error('unused') })}
|
||||
inputActions={{
|
||||
setDraft: () => {},
|
||||
addImages: () => {},
|
||||
removeImage: () => {},
|
||||
pruneImages: () => {},
|
||||
submit: () => {},
|
||||
}}
|
||||
useStore={bindSnapshotSelector(chat)}
|
||||
actions={chat.actions}
|
||||
closeDetails={vi.fn()}
|
||||
|
||||
@@ -22,7 +22,7 @@ const SID = 's1' as SessionId
|
||||
function snapshotOf(overrides: Partial<ConversationSnapshot> = {}): ConversationSnapshot {
|
||||
return {
|
||||
sessionId: SID, nodes: [], foldDegraded: false, partial: null, runningCalls: [], codeDispatches: new Map(),
|
||||
pending: [], queue: [], running: false, composerPhase: 'active', removed: false,
|
||||
pending: [], queue: [], todos: [], running: false, composerPhase: 'active', removed: false,
|
||||
openState: 'open', openError: null, hasMore: false, loadingOlder: false,
|
||||
promptError: null, blank: false, lastAgentError: null,
|
||||
...overrides,
|
||||
@@ -84,17 +84,17 @@ function bench(over?: BenchOptions) {
|
||||
useSession: bindSnapshotSelector(session),
|
||||
useSessions: bindSnapshotSelector(createSnapshotStore({
|
||||
ids: [], byId: {}, current: undefined, phase: 'ready',
|
||||
})) as InputBarProps['useSessions'],
|
||||
})),
|
||||
useWorkspaces: bindSnapshotSelector(createSnapshotStore({
|
||||
items: [], state: 'idle', phase: 'ready', error: null,
|
||||
baselinesReady: true, recentWorkspaceId: undefined,
|
||||
})) as InputBarProps['useWorkspaces'],
|
||||
})),
|
||||
useInput: bindSnapshotSelector(shell.state),
|
||||
inputActions: shell.actions,
|
||||
keyboard: shell,
|
||||
addImages: over?.addImages ?? (() => null),
|
||||
removeImage,
|
||||
draftImages: ids => ids.flatMap(id => {
|
||||
draftImages: ids => ids.flatMap((id) => {
|
||||
const attachment = over?.attachments?.find(candidate => candidate.id === id)
|
||||
return attachment === undefined ? [] : [attachment]
|
||||
}),
|
||||
@@ -223,7 +223,7 @@ describe('running and lock semantics (queue cut 1)', () => {
|
||||
const { textarea, wiring } = bench()
|
||||
fireEvent.change(textarea, { target: { value: 'typed' } })
|
||||
expect(wiring.state.getSnapshot().draft).toBe('typed')
|
||||
expect((textarea as HTMLTextAreaElement).value).toBe('typed')
|
||||
expect((textarea).value).toBe('typed')
|
||||
})
|
||||
|
||||
it('disabled state shows the unavailable placeholder; custom placeholder wins', () => {
|
||||
@@ -472,10 +472,10 @@ describe('placeholder chrome and control seats', () => {
|
||||
expect(view.getByTestId('plan-entry')).toBeTruthy()
|
||||
expect(view.getByTestId('model-entry')).toBeTruthy()
|
||||
// The bar hands its chrome disable state to the filling entry.
|
||||
expect(slotCalls.every(c => (c.owner as { locked: boolean }).locked === true)).toBe(true)
|
||||
expect(slotCalls.every(c => (c.owner as { locked: boolean }).locked)).toBe(true)
|
||||
cleanup()
|
||||
const live = bench({ running: true })
|
||||
expect(live.slotCalls.every(c => (c.owner as { locked: boolean }).locked === false)).toBe(true)
|
||||
expect(live.slotCalls.every(c => !(c.owner as { locked: boolean }).locked)).toBe(true)
|
||||
})
|
||||
|
||||
it('disabled locks the Access placeholder and attach control (running does not)', () => {
|
||||
|
||||
@@ -24,7 +24,7 @@ const SID = 's1' as SessionId
|
||||
function mountBar(shell: SessionInputShell, over?: { running?: boolean; disabled?: boolean }) {
|
||||
const session = createSnapshotStore<ConversationSnapshot>({
|
||||
sessionId: SID, nodes: [], foldDegraded: false, partial: null, runningCalls: [], codeDispatches: new Map(),
|
||||
pending: [], queue: [], running: over?.running ?? false, composerPhase: 'active',
|
||||
pending: [], queue: [], todos: [], running: over?.running ?? false, composerPhase: 'active',
|
||||
removed: over?.disabled ?? false, openState: 'open', openError: null, hasMore: false,
|
||||
loadingOlder: false, promptError: null, blank: false, lastAgentError: null,
|
||||
})
|
||||
@@ -34,11 +34,11 @@ function mountBar(shell: SessionInputShell, over?: { running?: boolean; disabled
|
||||
useSession: bindSnapshotSelector(session),
|
||||
useSessions: bindSnapshotSelector(createSnapshotStore({
|
||||
ids: [], byId: {}, current: undefined, phase: 'ready',
|
||||
})) as InputBarProps['useSessions'],
|
||||
})),
|
||||
useWorkspaces: bindSnapshotSelector(createSnapshotStore({
|
||||
items: [], state: 'idle', phase: 'ready', error: null,
|
||||
baselinesReady: true, recentWorkspaceId: undefined,
|
||||
})) as InputBarProps['useWorkspaces'],
|
||||
})),
|
||||
useInput: bindSnapshotSelector(shell.state),
|
||||
inputActions: shell.actions,
|
||||
keyboard: shell,
|
||||
@@ -91,7 +91,7 @@ describe('matrix row: claimed', () => {
|
||||
expect(shell.snapshot.claim).toEqual({ token: '/goal ', hint: '目标' })
|
||||
expect(view.container.querySelector('[data-decoration="token"]')?.textContent).toBe('/goal ')
|
||||
expect(view.container.querySelector('[data-decoration="hint"]')?.textContent).toBe('目标')
|
||||
expect((textarea as HTMLTextAreaElement).readOnly).toBe(false)
|
||||
expect((textarea).readOnly).toBe(false)
|
||||
// Free editing beyond the token: hint drops, claim holds.
|
||||
fireEvent.change(textarea, { target: { value: '/goal 发布版本' } })
|
||||
expect(shell.snapshot.phase).toBe('claimed')
|
||||
@@ -107,7 +107,7 @@ describe('matrix row: claimed', () => {
|
||||
expect(sink).not.toHaveBeenCalled()
|
||||
await vi.waitFor(() => { expect(submit).toHaveBeenCalledWith('发布', SCTX) })
|
||||
// Commit: draft cleared, notice surfaced, back to plain.
|
||||
await vi.waitFor(() => { expect((textarea as HTMLTextAreaElement).value).toBe('') })
|
||||
await vi.waitFor(() => { expect((textarea).value).toBe('') })
|
||||
expect(view.getByText('完成')).toBeTruthy()
|
||||
})
|
||||
|
||||
@@ -129,7 +129,7 @@ describe('matrix row: submitting', () => {
|
||||
fireEvent.keyDown(textarea, { key: 'Enter' })
|
||||
expect(shell.snapshot.phase).toBe('submitting')
|
||||
expect(shell.snapshot.claim).toBeDefined()
|
||||
expect((textarea as HTMLTextAreaElement).readOnly).toBe(true)
|
||||
expect((textarea).readOnly).toBe(true)
|
||||
expect(view.container.querySelector('[data-input-pending]')).not.toBeNull()
|
||||
// Enter is dead inside the lock (submit dispatch is microtask-deferred).
|
||||
await vi.waitFor(() => { expect(submit).toHaveBeenCalledTimes(1) })
|
||||
@@ -148,7 +148,7 @@ describe('matrix row: submitting', () => {
|
||||
await vi.waitFor(() => { expect(submit).toHaveBeenCalled() })
|
||||
act(() => { rejectSubmit(new Error('执行失败')) })
|
||||
await vi.waitFor(() => { expect(first.shell.snapshot.phase).toBe('claimed') })
|
||||
expect((first.textarea as HTMLTextAreaElement).value).toBe('/goal ')
|
||||
expect((first.textarea).value).toBe('/goal ')
|
||||
expect(first.view.getByText('执行失败')).toBeTruthy()
|
||||
cleanup()
|
||||
// Drift: typing during flight wins; no restore, plain, notice only.
|
||||
@@ -160,7 +160,7 @@ describe('matrix row: submitting', () => {
|
||||
act(() => { second.shell.setDraft('用户飞行中打的新稿') })
|
||||
act(() => { rejectSubmit(new Error('晚到失败')) })
|
||||
await vi.waitFor(() => { expect(second.shell.snapshot.phase).toBe('plain') })
|
||||
expect((second.textarea as HTMLTextAreaElement).value).toBe('用户飞行中打的新稿')
|
||||
expect((second.textarea).value).toBe('用户飞行中打的新稿')
|
||||
expect(second.view.getByText('晚到失败')).toBeTruthy()
|
||||
})
|
||||
})
|
||||
@@ -168,14 +168,14 @@ describe('matrix row: submitting', () => {
|
||||
describe('matrix row: locked (session disabled)', () => {
|
||||
it('disables the textarea and chrome; the machine currency is untouched', () => {
|
||||
const { view, textarea, shell } = bench({ disabled: true })
|
||||
expect((textarea as HTMLTextAreaElement).disabled).toBe(true)
|
||||
expect((textarea).disabled).toBe(true)
|
||||
expect((view.getByLabelText('Add attachment') as HTMLButtonElement).disabled).toBe(true)
|
||||
expect(shell.snapshot.phase).toBe('plain')
|
||||
})
|
||||
|
||||
it('running does NOT lock (queue cut 1): typing and enter-queue stay live', () => {
|
||||
const { textarea, sink } = bench({ running: true })
|
||||
expect((textarea as HTMLTextAreaElement).disabled).toBe(false)
|
||||
expect((textarea).disabled).toBe(false)
|
||||
fireEvent.change(textarea, { target: { value: '排队' } })
|
||||
fireEvent.keyDown(textarea, { key: 'Enter' })
|
||||
expect(sink).toHaveBeenCalledWith('排队', 'queue', [])
|
||||
|
||||
@@ -12,7 +12,6 @@ import { Context } from 'cordis'
|
||||
import { afterEach, describe, expect, it, vi } from 'vitest'
|
||||
import { act, cleanup, fireEvent, render } from '@testing-library/react'
|
||||
import { SessionsService } from '@deepseek-ai/dsh-client-runtime/client'
|
||||
import type { ClientContext } from '@deepseek-ai/dsh-client-runtime/client'
|
||||
import { SlashService } from '@deepseek-ai/dsh-client-ui-slash/client'
|
||||
import type { ClientSessionContext, CommandClaim, PickOutcome, SubmitOutcome } from '@deepseek-ai/dsh-client-ui-slash/client'
|
||||
import { FakeApiClient, ok } from '../../runtime/tests/fake-api.ts'
|
||||
@@ -100,7 +99,7 @@ async function scopedBench(register?: (slash: SlashService) => void) {
|
||||
await ctx.plugin(SlashService).await()
|
||||
const slash = ctx.get('slash') as SlashService
|
||||
register?.(slash)
|
||||
const actx = sessions.scope(sessionId)! as ClientContext
|
||||
const actx = sessions.scope(sessionId)!
|
||||
const controller = slash.sessionOf(actx)
|
||||
const sink = vi.fn()
|
||||
const shell = new SessionInputShell({ actx, slash: () => controller, defaultSink: sink })
|
||||
@@ -111,7 +110,7 @@ async function scopedBench(register?: (slash: SlashService) => void) {
|
||||
const wiring = shell
|
||||
const sessionStore = createSnapshotStore<ConversationSnapshot>({
|
||||
sessionId, nodes: [], foldDegraded: false, partial: null, runningCalls: [], codeDispatches: new Map(),
|
||||
pending: [], queue: [], running: false, composerPhase: 'active', removed: false,
|
||||
pending: [], queue: [], todos: [], running: false, composerPhase: 'active', removed: false,
|
||||
openState: 'open', openError: null, hasMore: false, loadingOlder: false,
|
||||
promptError: null, blank: false, lastAgentError: null,
|
||||
})
|
||||
@@ -121,11 +120,11 @@ async function scopedBench(register?: (slash: SlashService) => void) {
|
||||
useSession: bindSnapshotSelector(sessionStore),
|
||||
useSessions: bindSnapshotSelector(createSnapshotStore({
|
||||
ids: [], byId: {}, current: undefined, phase: 'ready',
|
||||
})) as InputBarProps['useSessions'],
|
||||
})),
|
||||
useWorkspaces: bindSnapshotSelector(createSnapshotStore({
|
||||
items: [], state: 'idle', phase: 'ready', error: null,
|
||||
baselinesReady: true, recentWorkspaceId: undefined,
|
||||
})) as InputBarProps['useWorkspaces'],
|
||||
})),
|
||||
useInput: bindSnapshotSelector(shell.state),
|
||||
inputActions: shell.actions,
|
||||
keyboard: shell,
|
||||
@@ -137,7 +136,7 @@ async function scopedBench(register?: (slash: SlashService) => void) {
|
||||
variant: 'composer',
|
||||
}
|
||||
const view = render(<InputBar {...barProps} />)
|
||||
const textarea = view.container.querySelector('textarea')! as HTMLTextAreaElement
|
||||
const textarea = view.container.querySelector('textarea')!
|
||||
const type = (text: string): void => {
|
||||
fireEvent.change(textarea, { target: { value: text } })
|
||||
}
|
||||
@@ -148,7 +147,7 @@ async function bench(executeImpl?: (line: string) => Promise<SubmitOutcome>) {
|
||||
const execute = vi.fn(executeImpl ?? ((line: string) =>
|
||||
Promise.resolve({ kind: 'success' as const, text: `已执行 ${line}` })))
|
||||
const { source, executed } = commandSource(COMMANDS, execute)
|
||||
const base = await scopedBench((slash) => { slash.registerSource(source as never) })
|
||||
const base = await scopedBench((slash) => { slash.registerSource(source) })
|
||||
return { ...base, execute, executed }
|
||||
}
|
||||
|
||||
|
||||
@@ -19,7 +19,7 @@ const SID = 's1' as SessionId
|
||||
function snapshotWith(queue: QueuedMessage[]): ConversationSnapshot {
|
||||
return {
|
||||
sessionId: SID, nodes: [], foldDegraded: false, partial: null, runningCalls: [], codeDispatches: new Map(),
|
||||
pending: [], queue, running: true, composerPhase: 'active', removed: false, openState: 'open', openError: null,
|
||||
pending: [], queue, todos: [], running: true, composerPhase: 'active', removed: false, openState: 'open', openError: null,
|
||||
hasMore: false, loadingOlder: false, promptError: null, blank: false, lastAgentError: null,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
// hero (blank session) and active phases — same textarea DOM node, machine-
|
||||
// owned draft, and the hero workspace picker (switching = retargetWorkspace).
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { cleanup, fireEvent, render } from '@testing-library/react'
|
||||
import { act, cleanup, fireEvent, render } from '@testing-library/react'
|
||||
import { bindSnapshotSelector } from '@deepseek-ai/dsh-client-web-react'
|
||||
import { createSnapshotStore } from '@deepseek-ai/dsh-client-runtime/client'
|
||||
import type {
|
||||
@@ -48,14 +48,18 @@ const workspaceState = (items: readonly WorkspaceView[]): WorkspaceListState =>
|
||||
function conversationSnapshot(overrides: Partial<ConversationSnapshot> = {}): ConversationSnapshot {
|
||||
return {
|
||||
sessionId: SID, nodes: [], foldDegraded: false, partial: null, runningCalls: [], codeDispatches: new Map(),
|
||||
pending: [], queue: [], running: false, composerPhase: 'active', removed: false,
|
||||
pending: [], queue: [], todos: [], running: false, composerPhase: 'active', removed: false,
|
||||
openState: 'open', openError: null, hasMore: false, loadingOlder: false,
|
||||
promptError: null, blank: false, lastAgentError: null,
|
||||
...overrides,
|
||||
}
|
||||
}
|
||||
|
||||
function mount(snapshot: ConversationSnapshot, workspaceRows: WorkspaceView[] = [{ ...workspace('one'), sessionIds: [SID] }]) {
|
||||
function mount(
|
||||
snapshot: ConversationSnapshot,
|
||||
workspaceRows: WorkspaceView[] = [{ ...workspace('one'), sessionIds: [SID] }],
|
||||
retargetWorkspace = vi.fn(async (_workspaceId: WorkspaceId) => {}),
|
||||
) {
|
||||
const root = sid('root')
|
||||
const sessions = createSnapshotStore<SessionListState>({
|
||||
ids: [root, SID],
|
||||
@@ -76,7 +80,6 @@ function mount(snapshot: ConversationSnapshot, workspaceRows: WorkspaceView[] =
|
||||
const inputActions = wiring.actions
|
||||
const stop = vi.fn()
|
||||
const open = vi.fn()
|
||||
const retargetWorkspace = vi.fn()
|
||||
const slotCalls: string[] = []
|
||||
let pickerOwner: unknown
|
||||
const renderSlot = ((key: string, owner: object, opts?: { only?: string }) => {
|
||||
@@ -162,7 +165,13 @@ describe('ConversationRoot resident composer', () => {
|
||||
})
|
||||
|
||||
it('hero phase: same textarea, hero chrome, no header, picker switches the workspace', () => {
|
||||
const b = mount(conversationSnapshot({ composerPhase: 'blank', blank: true }))
|
||||
const b = mount(
|
||||
conversationSnapshot({ composerPhase: 'blank', blank: true }),
|
||||
[
|
||||
{ ...workspace('one'), sessionIds: [SID] },
|
||||
{ ...workspace('second'), title: 'Selected Folder' },
|
||||
],
|
||||
)
|
||||
// Hero chrome present, view ring absent.
|
||||
expect(b.view.getByText("Let's start building")).toBeTruthy()
|
||||
expect(b.view.queryByTestId('view-chat')).toBeNull()
|
||||
@@ -177,8 +186,9 @@ describe('ConversationRoot resident composer', () => {
|
||||
fireEvent.click(b.view.getByRole('button', { name: 'Choose workspace' }))
|
||||
const owner = b.pickerOwner() as { open: boolean; onPick(id: WorkspaceId): void }
|
||||
expect(owner.open).toBe(true)
|
||||
owner.onPick(wid('second'))
|
||||
act(() => { owner.onPick(wid('second')) })
|
||||
expect(b.retargetWorkspace).toHaveBeenCalledWith(wid('second'))
|
||||
expect(b.view.getByText('Selected Folder')).toBeTruthy()
|
||||
})
|
||||
|
||||
it('textarea DOM identity survives the hero → active flip', () => {
|
||||
@@ -195,6 +205,24 @@ describe('ConversationRoot resident composer', () => {
|
||||
expect(b.view.getByTestId('view-chat')).toBeTruthy()
|
||||
})
|
||||
|
||||
it('rolls the pending workspace label back when switching fails', async () => {
|
||||
const selectWorkspace = vi.fn(async () => { throw new Error('connect failed') })
|
||||
const b = mount(
|
||||
conversationSnapshot({ composerPhase: 'blank', blank: true }),
|
||||
[
|
||||
{ ...workspace('one'), sessionIds: [SID] },
|
||||
{ ...workspace('second'), title: 'Selected Folder' },
|
||||
],
|
||||
selectWorkspace,
|
||||
)
|
||||
fireEvent.click(b.view.getByRole('button', { name: 'Choose workspace' }))
|
||||
const owner = b.pickerOwner() as { onPick(id: WorkspaceId): void }
|
||||
await act(async () => { owner.onPick(wid('second')); await Promise.resolve() })
|
||||
expect(selectWorkspace).toHaveBeenCalledWith(wid('second'))
|
||||
expect(b.view.queryByText('Selected Folder')).toBeNull()
|
||||
expect(b.view.getByText('one')).toBeTruthy()
|
||||
})
|
||||
|
||||
it('blank session keeps the interactive picker chip (workspace switchable until the first message)', () => {
|
||||
const b = mount(conversationSnapshot({ composerPhase: 'blank', blank: true }))
|
||||
const chip = b.view.getByRole('button', { name: 'Choose workspace' })
|
||||
|
||||
188
packages/client/ui-conversation/tests/todo-panel.spec.tsx
Normal file
188
packages/client/ui-conversation/tests/todo-panel.spec.tsx
Normal file
@@ -0,0 +1,188 @@
|
||||
// @vitest-environment jsdom
|
||||
/**
|
||||
* Todo display acceptance: the TodoPanel plan strip (empty-hidden, status
|
||||
* rows, collapse), its TodoDock adapter (selects the plan off the session
|
||||
* snapshot and follows changes), and the todo_write toolview row (progress
|
||||
* summary from args, generic fallback on malformed JSON, error badge,
|
||||
* keyboard activation).
|
||||
*/
|
||||
import { act, cleanup, fireEvent, render, screen } from '@testing-library/react'
|
||||
import { afterEach, describe, expect, it, vi } from 'vitest'
|
||||
import { bindSnapshotSelector } from '@deepseek-ai/dsh-client-web-react'
|
||||
import { createSnapshotStore } from '@deepseek-ai/dsh-client-runtime/client'
|
||||
import type { TodoItem, ToolResultNode } from '@deepseek-ai/dsh-client-runtime/client'
|
||||
import type { ToolRowProps } from '@deepseek-ai/dsh-client-ui-conversation/client'
|
||||
// Export discipline: packages/client/AGENTS.md.
|
||||
import { TodoRow, todoToolview } from '../src/client/toolviews/todo-row.tsx'
|
||||
import type { TodoDockProps } from '../src/client/skeleton/TodoPanel.tsx'
|
||||
import { TodoDock, TodoPanel, todoDockEntry } from '../src/client/skeleton/TodoPanel.tsx'
|
||||
|
||||
afterEach(cleanup)
|
||||
|
||||
const LIST: TodoItem[] = [
|
||||
{ content: '搭骨架', status: 'completed' },
|
||||
{ content: '写组件', status: 'in_progress' },
|
||||
{ content: '补测试', status: 'pending' },
|
||||
]
|
||||
|
||||
describe('TodoPanel', () => {
|
||||
it('renders nothing while the list is empty', () => {
|
||||
const { container } = render(<TodoPanel todos={[]} />)
|
||||
expect(container.innerHTML).toBe('')
|
||||
})
|
||||
|
||||
it('shows progress, one row per item with its status glyph', () => {
|
||||
render(<TodoPanel todos={LIST} />)
|
||||
expect(screen.getByTestId('todo-panel')).toBeTruthy()
|
||||
expect(screen.getByText('To-dos')).toBeTruthy()
|
||||
expect(screen.getByText('1/3 tasks · 1 in progress')).toBeTruthy()
|
||||
const items = screen.getAllByRole('listitem')
|
||||
expect(items.map(li => li.getAttribute('data-status'))).toEqual(['completed', 'in_progress', 'pending'])
|
||||
expect(screen.getByText('搭骨架')).toBeTruthy()
|
||||
expect(screen.getByText('写组件')).toBeTruthy()
|
||||
// Each status row carries an SVG glyph (not a text bullet).
|
||||
expect(items.every(li => li.querySelector('svg') !== null)).toBe(true)
|
||||
})
|
||||
|
||||
it('collapse hides the list; expand restores; header keeps the count summary', () => {
|
||||
render(<TodoPanel todos={LIST} />)
|
||||
const header = screen.getByRole('button', { expanded: true })
|
||||
fireEvent.click(header)
|
||||
expect(screen.queryByRole('list')).toBeNull()
|
||||
// Collapsed header is title + progress only (no in-progress content hint).
|
||||
expect(screen.getByText('1/3 tasks · 1 in progress')).toBeTruthy()
|
||||
expect(screen.queryByText('写组件')).toBeNull()
|
||||
fireEvent.click(screen.getByRole('button', { expanded: false }))
|
||||
expect(screen.getAllByRole('listitem')).toHaveLength(3)
|
||||
})
|
||||
|
||||
it('collapsed header still shows zero in-progress when nothing is active', () => {
|
||||
render(<TodoPanel todos={[{ content: '都完了', status: 'completed' }]} />)
|
||||
fireEvent.click(screen.getByRole('button', { expanded: true }))
|
||||
expect(screen.queryByText('都完了')).toBeNull()
|
||||
expect(screen.getByText('1/1 tasks · 0 in progress')).toBeTruthy()
|
||||
})
|
||||
})
|
||||
|
||||
/** Dock props stub: the adapter reads useSession only; the rest of the owner share is unused. */
|
||||
function dockProps(store: ReturnType<typeof createSnapshotStore<{ todos: readonly TodoItem[] }>>): TodoDockProps {
|
||||
return { useSession: bindSnapshotSelector(store) } as unknown as TodoDockProps
|
||||
}
|
||||
|
||||
describe('TodoDock', () => {
|
||||
it('selects the plan off the session snapshot and follows later writes', () => {
|
||||
const store = createSnapshotStore<{ todos: readonly TodoItem[] }>({ todos: [] })
|
||||
render(<TodoDock {...dockProps(store)} />)
|
||||
expect(screen.queryByTestId('todo-panel')).toBeNull()
|
||||
act(() => { store.set({ todos: LIST }) })
|
||||
expect(screen.getByText('1/3 tasks · 1 in progress')).toBeTruthy()
|
||||
// A rollback to the empty list retires the strip (the panel owns no data).
|
||||
act(() => { store.set({ todos: [] }) })
|
||||
expect(screen.queryByTestId('todo-panel')).toBeNull()
|
||||
})
|
||||
|
||||
it('ships the registrant plugin shape (list entry above the queue rows)', () => {
|
||||
expect(todoDockEntry.name).toBe('conversation-todo-dock')
|
||||
expect(todoDockEntry.inject).toEqual(['slots', 'conversation'])
|
||||
const register = vi.fn()
|
||||
todoDockEntry.apply({ slots: { register } } as never)
|
||||
expect(register).toHaveBeenCalledWith({ name: 'conversation.input.dock', id: 'todo', order: -1 }, TodoDock)
|
||||
})
|
||||
})
|
||||
|
||||
const resultNode = (argsRaw: string, over?: Partial<ToolResultNode>): ToolResultNode => ({
|
||||
kind: 'tool-result', seq: 10, time: 2_000, callTime: 1_000, callId: 'c1',
|
||||
call: { name: 'todo_write', argsRaw },
|
||||
content: [], isError: false, callView: null, resultView: null, ...over,
|
||||
})
|
||||
|
||||
function rowProps(block: unknown, openDetails = vi.fn()): ToolRowProps {
|
||||
return {
|
||||
callId: 'c1', toolName: 'todo_write', block,
|
||||
openDetails,
|
||||
sessionId: 's1',
|
||||
useSessions: () => undefined,
|
||||
} as unknown as ToolRowProps
|
||||
}
|
||||
|
||||
describe('TodoRow', () => {
|
||||
const ARGS = JSON.stringify({ todos: LIST })
|
||||
|
||||
it('summarizes counts and the active item from the call args', () => {
|
||||
render(<TodoRow {...rowProps(resultNode(ARGS))} />)
|
||||
expect(screen.getByText('更新任务清单')).toBeTruthy()
|
||||
expect(screen.getByText('1/3 已完成 · 写组件')).toBeTruthy()
|
||||
})
|
||||
|
||||
it('omits the active clause when no item is in progress and reads running-call args', () => {
|
||||
const args = JSON.stringify({ todos: [{ content: 'x', status: 'completed' }] })
|
||||
render(<TodoRow {...rowProps({ callId: 'c1', name: 'todo_write', argsRaw: args, turn: 1, step: 1, time: 1_000, callView: null })} />)
|
||||
expect(screen.getByText('1/1 已完成')).toBeTruthy()
|
||||
})
|
||||
|
||||
it('keeps the non-ok execution states visible: running dot, interrupted marker', () => {
|
||||
// A running call (no result yet) shows the ongoing dot, never the ok badge.
|
||||
const args = JSON.stringify({ todos: LIST })
|
||||
const running = render(<TodoRow {...rowProps({ callId: 'c1', name: 'todo_write', argsRaw: args, turn: 1, step: 1, time: 1_000, callView: null })} />)
|
||||
expect(running.container.querySelector('[data-state="running"]')).not.toBeNull()
|
||||
expect(running.container.querySelector('[data-state="running"] svg')).not.toBeNull()
|
||||
running.unmount()
|
||||
// A cancelled call wrote no todo/write: the row must not read as a completed update.
|
||||
const stopped = render(<TodoRow {...rowProps(resultNode(args, { isError: true, error: { name: 'Interrupted', code: 'interrupted' } }))} />)
|
||||
expect(stopped.container.querySelector('[data-state="stopped"]')).not.toBeNull()
|
||||
expect(stopped.getByText('已中断')).toBeTruthy()
|
||||
})
|
||||
|
||||
it('falls back to the generic summary on malformed args and flags errors', () => {
|
||||
render(<TodoRow {...rowProps(resultNode('not json', { isError: true }))} />)
|
||||
expect(screen.getByText('failed')).toBeTruthy()
|
||||
// Generic others summary: "<tool> · <raw>".
|
||||
expect(screen.getByText('todo_write · not json')).toBeTruthy()
|
||||
})
|
||||
|
||||
it('falls back when parsed args carry no todos array, and click opens details', () => {
|
||||
const openDetails = vi.fn()
|
||||
render(<TodoRow {...rowProps(resultNode('{"other":1}'), openDetails)} />)
|
||||
expect(screen.getByText('todo_write · {"other":1}')).toBeTruthy()
|
||||
fireEvent.click(screen.getByText('更新任务清单'))
|
||||
expect(openDetails).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
|
||||
it('opens details from the keyboard on Enter and Space, ignoring other keys', () => {
|
||||
const openDetails = vi.fn()
|
||||
render(<TodoRow {...rowProps(resultNode(ARGS), openDetails)} />)
|
||||
const row = screen.getByRole('button')
|
||||
expect(row.getAttribute('tabindex')).toBe('0')
|
||||
fireEvent.keyDown(row, { key: 'Enter' })
|
||||
fireEvent.keyDown(row, { key: ' ' })
|
||||
expect(openDetails).toHaveBeenCalledTimes(2)
|
||||
// Space must not also scroll the flow: the handler claims the event.
|
||||
expect(fireEvent.keyDown(row, { key: ' ' })).toBe(false)
|
||||
fireEvent.keyDown(row, { key: 'a' })
|
||||
fireEvent.keyDown(row, { key: 'ArrowDown' })
|
||||
expect(openDetails).toHaveBeenCalledTimes(3)
|
||||
})
|
||||
|
||||
it.each([
|
||||
{ label: 'null root', argsRaw: 'null' },
|
||||
{ label: 'non-object root', argsRaw: '42' },
|
||||
{ label: 'null items', argsRaw: '{"todos":[null]}' },
|
||||
])('falls back to the generic summary on valid JSON with an invalid shape ($label)', ({ argsRaw }) => {
|
||||
render(<TodoRow {...rowProps(resultNode(argsRaw))} />)
|
||||
// No throw, and the generic others summary carries the raw args verbatim.
|
||||
expect(screen.getByText(`todo_write · ${argsRaw}`)).toBeTruthy()
|
||||
})
|
||||
|
||||
it('window-truncated result (call head lost) falls back to the callId summary', () => {
|
||||
render(<TodoRow {...rowProps(resultNode('', { call: null }))} />)
|
||||
expect(screen.getByText('todo_write · c1')).toBeTruthy()
|
||||
})
|
||||
|
||||
it('todoToolview is a plain registrant riding the conversation load-order seam', () => {
|
||||
expect(todoToolview.name).toBe('todo-toolview')
|
||||
expect(todoToolview.inject).toEqual(['slots', 'conversation'])
|
||||
const register = vi.fn()
|
||||
todoToolview.apply({ slots: { register } } as never)
|
||||
expect(register).toHaveBeenCalledWith({ name: 'conversation.chat.toolview', key: 'todo_write' }, TodoRow)
|
||||
})
|
||||
})
|
||||
Reference in New Issue
Block a user