Merge remote-tracking branch 'origin/master' into worktree/web-multimodal-image-input

# Conflicts:
#	.agents/notes/implemented/architecture/2026-07-05-reconstructable-requests.i18n.yaml
#	docs/config-catalog.md
#	docs/cordis-catalog/events.md
#	docs/cordis-catalog/services.md
#	docs/core-data-structures/core.md
#	docs/core-data-structures/core.zh.md
#	docs/core-data-structures/llm-streaming.i18n.yaml
#	docs/core-data-structures/llm-streaming.md
#	docs/core-data-structures/llm-streaming.zh.md
#	docs/event-producer-consumer.md
#	docs/module-graph.md
#	docs/subsystems/attachment.i18n.yaml
#	docs/subsystems/attachment.md
#	docs/subsystems/attachment.zh.md
#	docs/subsystems/core.i18n.yaml
#	examples/acp-agent/tests/snapshots/cordis-inspect-jsdoc/session.jsonl
#	packages/README.i18n.yaml
#	packages/README.md
#	packages/README.zh.md
#	packages/client/runtime/package.json
#	packages/client/ui-conversation/package.json
#	packages/client/ui-conversation/src/client/chat/AssistantMarkdown.tsx
#	packages/client/ui-conversation/src/client/chat/ChatView.tsx
#	packages/client/ui-conversation/src/client/chat/MessageItem.tsx
#	packages/client/ui-conversation/src/client/index.ts
#	packages/client/ui-conversation/tests/input-bar.spec.tsx
#	packages/compact/compact-basic/README.i18n.yaml
#	packages/compact/compact-basic/README.md
#	packages/compact/compact-basic/README.zh.md
#	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/api-proxy-models.spec.ts
#	packages/self-modification/tool-cordis/src/api-catalog.ts
#	pnpm-lock.yaml
#	scripts/type-equiv.manifest.json
This commit is contained in:
Yichen Jiang
2026-08-09 23:33:35 +08:00
3271 changed files with 69578 additions and 24953 deletions

View File

@@ -24,11 +24,11 @@ const resultNode = (argsRaw: string, resultText: string | null, over?: Partial<T
kind: 'tool-result', seq: 10, time: 2_000, callTime: 1_000, callId: 'c1',
call: { name: 'ask_user_question', argsRaw },
content: resultText === null ? [] : [{ type: 'text', text: resultText }],
isError: false, callView: null, resultView: null, ...over,
isError: false, callView: null, resultView: null, subCalls: [], ...over,
})
const runningCall = (argsRaw: string) =>
({ callId: 'c1', name: 'ask_user_question', argsRaw, turn: 1, step: 1, time: 1_000, callView: null })
({ callId: 'c1', name: 'ask_user_question', argsRaw, turn: 1, step: 1, time: 1_000, callView: null, subCalls: [] })
// Standard locale seat stub mirroring the real ns → common → key chain.
const t = makeTranslate(zh, commonZh)

View File

@@ -8,6 +8,7 @@ import type { PropsRenderSlots } from '@deepseek-ai/dsh-client-ui-slots'
import { SlotTestRuntime, usePinnedBrowserLanguages } from '@deepseek-ai/dsh-client-test-runtime'
import { apply as applyConversation, inject as injectConversation } from '@deepseek-ai/dsh-client-ui-conversation/client'
import { apply as applyTool, inject as injectTool } from '../src/client/apply.ts'
import { toolChatSnapshot } from './tool-details-render.tsx'
// The service reads its initial locale from the browser; these specs assert
// the shipped Chinese copy, so they state the browser they assume.
@@ -40,7 +41,7 @@ const todoResult = (seq: number): ToolResultNode => ({
kind: 'tool-result', seq, time: seq * 1_000, callId: `todo-${seq}`,
call: { name: 'todo_write', argsRaw: JSON.stringify({ todos: TODOS }) },
callTime: seq * 1_000 - 500,
content: [], isError: false, callView: null, resultView: null,
content: [], isError: false, callView: null, resultView: null, subCalls: [],
})
const bashResult = (seq: number, callId: string, over?: Partial<ToolResultNode>): ToolResultNode => ({
@@ -50,6 +51,7 @@ const bashResult = (seq: number, callId: string, over?: Partial<ToolResultNode>)
content: [{ type: 'text', text: 'total 2\ndemo.txt\n' }], isError: false,
callView: { card: 'terminal', title: 'ls -la', description: 'List files' },
resultView: { card: 'terminal', output: 'total 2\ndemo.txt\n', exitCode: 0 },
subCalls: [],
...over,
})
@@ -73,7 +75,7 @@ async function bench(nodes: ToolResultNode[]) {
await runtime.sessions.add({
id: SID,
summary: { title: 'S', displayTitle: 'S', cwd: '/proj' },
snapshot: { nodes },
snapshot: { nodes, chat: toolChatSnapshot(nodes) },
session: {
loadOlder: vi.fn<ISession['loadOlder']>(),
prompt: vi.fn<ISession['prompt']>(async () => ({ ok: true, value: { accepted: true } })),

View File

@@ -11,16 +11,19 @@
import { Context } from 'cordis'
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import { cleanup, fireEvent, render } from '@testing-library/react'
import { createSnapshotStore, SlotsService } from '@deepseek-ai/dsh-client-runtime/client'
import {
ConversationEventRegistry, ConversationViewRegistry, createSnapshotStore, SlotsService,
} from '@deepseek-ai/dsh-client-runtime/client'
import type {
CodeSubCall, ConversationSnapshot, RunningToolCall, SessionId, SessionListState,
ToolResultNode, WorkspaceListState,
ConversationSnapshot, RunningToolCall, SessionId, SessionListState,
ToolCallBlock, ToolResultNode, WorkspaceListState,
} from '@deepseek-ai/dsh-client-runtime/client'
import { createSlotRenderer } from '@deepseek-ai/dsh-client-web-react'
import { LocaleService } from '@deepseek-ai/dsh-client-locale/client'
import type { PropsRenderSlots } from '@deepseek-ai/dsh-client-ui-slots'
import { apply as applyConversation, inject as injectConversation } from '@deepseek-ai/dsh-client-ui-conversation/client'
import { apply as applyTool, inject as injectTool } from '../src/client/apply.ts'
import { toolChatSnapshot } from './tool-details-render.tsx'
const SID = 's1' as SessionId
@@ -48,27 +51,36 @@ const codeResult = (seq: number, callId: string): ToolResultNode => ({
call: { name: 'run_code', argsRaw: RUN_CODE_ARGS },
callTime: seq * 1_000 - 500,
content: [{ type: 'text', text: 'demo.txt' }], isError: false, callView: null, resultView: null,
subCalls: [],
})
const runningCode = (callId: string): RunningToolCall => ({
callId, name: 'run_code', argsRaw: RUN_CODE_ARGS, turn: 9, step: 0, time: 9_000, callView: null,
subCalls: [],
})
const subCall = (seq: number, parent: string, n: number, name: string, args: object, resultText: string, isError = false): CodeSubCall => ({
const subCall = (
seq: number, parent: string, n: number, name: string, args: object, resultText: string, isError = false,
): ToolCallBlock => ({
kind: 'tool-result', seq, time: seq * 1_000,
callId: `${parent}:code:${n}`,
call: { name, argsRaw: JSON.stringify(args) },
callTime: seq * 1_000,
content: [{ type: 'text', text: resultText }], isError, callView: null, resultView: null,
subCalls: [],
})
function snapshotWith(
nodes: ToolResultNode[],
codeDispatches: ReadonlyMap<string, readonly CodeSubCall[]>,
subCalls: readonly ToolCallBlock[],
runningCalls: RunningToolCall[] = [],
): ConversationSnapshot {
const nestedNodes = nodes.map(node => ({ ...node, subCalls }))
const nestedRunningCalls = runningCalls.map(call => ({ ...call, subCalls }))
return {
sessionId: SID, nodes, turnTimings: new Map(), turnEnds: new Map(), partial: null, runningCalls, codeDispatches,
sessionId: SID, chat: toolChatSnapshot(nestedNodes, nestedRunningCalls),
nodes: nestedNodes, turnTimings: new Map(), turnEnds: new Map(), partial: null,
runningCalls: nestedRunningCalls,
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,
@@ -81,11 +93,16 @@ function AppRoot({ renderSlot }: AppRootProps) {
return <>{renderSlot('conversation', {})}</>
}
/** Same real-stack bench as the toolview-slot spec: SlotsService + renderer + both owning package applies; fakes only at service seams. */
/**
* Same real-stack bench as the toolview-slot spec: SlotsService + renderer +
* both owning package applies; fakes only at service boundaries.
*/
async function bench(snapshot: ConversationSnapshot) {
const ctx = new Context()
const slotsFiber = ctx.plugin(SlotsService)
await slotsFiber.await()
await ctx.plugin(ConversationEventRegistry).await()
await ctx.plugin(ConversationViewRegistry).await()
const slots = ctx.get('slots') as SlotsService
const session = createSnapshotStore<ConversationSnapshot>(snapshot)
@@ -165,11 +182,11 @@ function mountApp(slots: SlotsService) {
describe('run_code sub-calls through the real chat machinery', () => {
it('renders the code-variant parent row with the description summary and nested sub-rows', async () => {
const parent = 'call-64'
const dispatches = new Map([[parent, [
const subCalls = [
subCall(11, parent, 1, 'bash', { command: 'ls notes', description: 'List notes' }, 'demo.txt'),
subCall(12, parent, 2, 'mystery', { n: 1 }, 'ok'),
]]])
const b = await bench(snapshotWith([codeResult(10, parent)], dispatches))
]
const b = await bench(snapshotWith([codeResult(10, parent)], subCalls))
const view = mountApp(b.slots)
// Parent row: the code variant with the model-authored description.
@@ -193,12 +210,12 @@ describe('run_code sub-calls through the real chat machinery', () => {
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, [
const subCalls = [
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 b = await bench(snapshotWith([codeResult(10, parent)], subCalls))
const view = mountApp(b.slots)
const nest = view.container.querySelector('[data-subcalls]')!
@@ -214,7 +231,7 @@ describe('run_code sub-calls through the real chat machinery', () => {
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()))
const b = await bench(snapshotWith([codeResult(10, parent)], []))
const view = mountApp(b.slots)
// The code row is expandable via the whole summary row (body = the program).
const toggle = view.container.querySelector('[data-variant="code"] [data-expandable]')
@@ -230,10 +247,10 @@ describe('run_code sub-calls through the real chat machinery', () => {
it('an isError sub-call renders the error state dot exactly like a failed native row', async () => {
const parent = 'call-64'
const dispatches = new Map([[parent, [
const subCalls = [
subCall(11, parent, 1, 'mystery', { n: 1 }, 'Error: boom', true),
]]])
const b = await bench(snapshotWith([codeResult(10, parent)], dispatches))
]
const b = await bench(snapshotWith([codeResult(10, parent)], subCalls))
const view = mountApp(b.slots)
const nested = view.container.querySelector('[data-subcalls] [data-variant][data-state="error"]')
expect(nested).not.toBeNull()
@@ -241,11 +258,11 @@ describe('run_code sub-calls through the real chat machinery', () => {
it('a file sub-row click opens the host path; bash sub-rows do not open details', async () => {
const parent = 'call-64'
const dispatches = new Map([[parent, [
const subCalls = [
subCall(11, parent, 1, 'read', { path: 'notes/demo.txt' }, 'ok'),
subCall(12, parent, 2, 'bash', { command: 'ls notes', description: 'List notes' }, 'demo.txt'),
]]])
const b = await bench(snapshotWith([codeResult(10, parent)], dispatches))
]
const b = await bench(snapshotWith([codeResult(10, parent)], subCalls))
const view = mountApp(b.slots)
view.getByText('notes/demo.txt').click()
expect(b.layout.openDetails).not.toHaveBeenCalled()
@@ -258,10 +275,10 @@ describe('run_code sub-calls through the real chat machinery', () => {
it('a RUNNING run_code call nests its so-far dispatches under the spinner row', async () => {
const parent = 'call-live'
const dispatches = new Map([[parent, [
const subCalls = [
subCall(21, parent, 1, 'bash', { command: 'ls notes', description: 'List notes' }, 'demo.txt'),
]]])
const b = await bench(snapshotWith([], dispatches, [runningCode(parent)]))
]
const b = await bench(snapshotWith([], subCalls, [runningCode(parent)]))
const view = mountApp(b.slots)
const running = view.container.querySelector('[data-variant="code"][data-state="running"]')
expect(running).not.toBeNull()
@@ -272,12 +289,11 @@ describe('run_code sub-calls through the real chat machinery', () => {
it('a started-but-unsettled sub-call renders the running state exactly like a native in-flight row', async () => {
const parent = 'call-live'
const runningSub: CodeSubCall = {
const runningSub: ToolCallBlock = {
callId: `${parent}:code:1`, name: 'grep', argsRaw: '{"pattern":"todo"}',
turn: 0, step: 0, time: 21_000, callView: null,
turn: 0, step: 0, time: 21_000, callView: null, subCalls: [],
}
const dispatches = new Map([[parent, [runningSub]]])
const b = await bench(snapshotWith([], dispatches, [runningCode(parent)]))
const b = await bench(snapshotWith([], [runningSub], [runningCode(parent)]))
const view = mountApp(b.slots)
// The nested row derives 'running' from the RunningToolCall shape — the
// same data-state chrome (row sweep) a native in-flight row wears.
@@ -291,9 +307,9 @@ describe('run_code sub-calls through the real chat machinery', () => {
kind: 'tool-result', seq: 10, time: 10_000, callId: parent,
call: { name: 'mystery', argsRaw: '{"n":1}' },
callTime: 9_500,
content: [], isError: false, callView: null, resultView: null,
content: [], isError: false, callView: null, resultView: null, subCalls: [],
}
const b = await bench(snapshotWith([plain], new Map()))
const b = await bench(snapshotWith([plain], []))
const view = mountApp(b.slots)
expect(view.container.querySelector('[data-subcalls]')).toBeNull()
})

View File

@@ -57,7 +57,7 @@ describe('Tool presentation tails', () => {
kind: 'tool-result', seq: 2, time: 2_000, callId: 'c5',
call: { name: 'todo_write', argsRaw: '{"note":"x"}' },
callTime: 1_000,
content: [], isError: false, callView: null, resultView: null,
content: [], isError: false, callView: null, resultView: null, subCalls: [],
}
const props: GenericToolCardProps = {
callId: 'c5', toolName: 'todo_write', block: settled, openFile: vi.fn(), t,
@@ -72,7 +72,7 @@ describe('Tool presentation tails', () => {
kind: 'tool-result', seq: 3, time: 3_000, callId: 'c1',
call: { name: 'bash', argsRaw: '{"command":"make build","description":"Build"}' },
callTime: 2_000,
content: [], isError: false, callView: null, resultView: null,
content: [], isError: false, callView: null, resultView: null, subCalls: [],
}
const view = render(<BashRow {...bashProps(settled)} />)
const row = view.container.querySelector('[data-sample="bash"]')!
@@ -84,13 +84,13 @@ describe('Tool presentation tails', () => {
it('BashRow carries data-state for running and StateDots for error/stopped', () => {
const running: RunningToolCall = {
callId: 'c1', name: 'bash', argsRaw: '{"command":"ls","description":"List"}',
turn: 1, step: 1, time: 1_000, callView: null,
turn: 1, step: 1, time: 1_000, callView: null, subCalls: [],
}
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,
content: [], isError: true, callView: null, resultView: null, subCalls: [],
}
const stoppedResult: ToolResultNode = {
...errorResult,

View File

@@ -20,7 +20,7 @@ import { createChatStore } from '@deepseek-ai/dsh-client-ui-conversation/src/cli
import { GenericToolCard, type GenericToolCardProps } from '../src/client/tool/toolviews/GenericToolCard.tsx'
import { DetailsPanel } from '@deepseek-ai/dsh-client-ui-conversation/src/client/skeleton/DetailsPanel.tsx'
import { FileMutationRow, fileMutationToolview } from '../src/client/tool/toolviews/file-mutation-row.tsx'
import { renderToolDetails, SessionProviderStub } from './tool-details-render.tsx'
import { renderToolDetails, SessionProviderStub, toolChatSnapshot } from './tool-details-render.tsx'
import { zh } from '@deepseek-ai/dsh-client-ui-conversation/src/client/locales.ts'
afterEach(cleanup)
@@ -48,7 +48,7 @@ const resultDiff = (over?: Partial<Extract<ToolResultView, { card: 'diff' }>>):
const running = (over?: Partial<RunningToolCall>): RunningToolCall => ({
callId: 'c1', name: 'edit', argsRaw: ARGS,
turn: 1, step: 1, time: 1_000, callView: callDiff(), ...over,
turn: 1, step: 1, time: 1_000, callView: callDiff(), subCalls: [], ...over,
})
const settled = (over?: Partial<ToolResultNode>): ToolResultNode => ({
@@ -56,7 +56,7 @@ const settled = (over?: Partial<ToolResultNode>): ToolResultNode => ({
call: { name: 'edit', argsRaw: ARGS },
callTime: 1_000,
content: [{ type: 'text', text: 'The file notes/demo.txt has been updated successfully.' }], isError: false,
callView: callDiff(), resultView: resultDiff(), ...over,
callView: callDiff(), resultView: resultDiff(), subCalls: [], ...over,
})
describe('diffCardModel', () => {
@@ -348,8 +348,11 @@ describe('DetailsPanel diff Output section', () => {
}
function snapshot(over: Partial<ConversationSnapshot> = {}): ConversationSnapshot {
const nodes = over.nodes ?? []
const runningCalls = over.runningCalls ?? []
return {
sessionId: SID, nodes: [], turnTimings: new Map(), turnEnds: new Map(), partial: null, runningCalls: [], codeDispatches: new Map(),
sessionId: SID, chat: over.chat ?? toolChatSnapshot(nodes, runningCalls),
nodes: [], turnTimings: new Map(), turnEnds: new Map(), partial: null, runningCalls: [],
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,

View File

@@ -24,7 +24,7 @@ import { GenericToolCard, type GenericToolCardProps } from '../src/client/tool/t
import { zh } from '@deepseek-ai/dsh-client-ui-conversation/src/client/locales.ts'
import { DetailsPanel } from '@deepseek-ai/dsh-client-ui-conversation/src/client/skeleton/DetailsPanel.tsx'
import { ReadRow, readToolview } from '../src/client/tool/toolviews/read-row.tsx'
import { renderToolDetails, SessionProviderStub } from './tool-details-render.tsx'
import { renderToolDetails, SessionProviderStub, toolChatSnapshot } from './tool-details-render.tsx'
afterEach(cleanup)
@@ -59,7 +59,7 @@ const resultRead = (over?: Partial<Extract<ToolResultView, { card: 'read' }>>):
const running = (over?: Partial<RunningToolCall>): RunningToolCall => ({
callId: 'c1', name: 'read', argsRaw: ARGS,
turn: 1, step: 1, time: 1_000, callView: { card: 'generic', title: 'Read src/a.ts', kind: 'read' }, ...over,
turn: 1, step: 1, time: 1_000, callView: { card: 'generic', title: 'Read src/a.ts', kind: 'read' }, subCalls: [], ...over,
})
const settled = (over?: Partial<ToolResultNode>): ToolResultNode => ({
@@ -67,7 +67,7 @@ const settled = (over?: Partial<ToolResultNode>): ToolResultNode => ({
call: { name: 'read', argsRaw: ARGS },
callTime: 1_000,
content: [{ type: 'text', text: '41: export const a = 1' }], isError: false,
callView: { card: 'generic', title: 'Read src/a.ts', kind: 'read' }, resultView: resultRead(), ...over,
callView: { card: 'generic', title: 'Read src/a.ts', kind: 'read' }, resultView: resultRead(), subCalls: [], ...over,
})
describe('readCardModel', () => {
@@ -294,8 +294,11 @@ describe('DetailsPanel Output section (read)', () => {
}
function snapshot(over: Partial<ConversationSnapshot> = {}): ConversationSnapshot {
const nodes = over.nodes ?? []
const runningCalls = over.runningCalls ?? []
return {
sessionId: SID, nodes: [], turnTimings: new Map(), turnEnds: new Map(), partial: null, runningCalls: [], codeDispatches: new Map(),
sessionId: SID, chat: over.chat ?? toolChatSnapshot(nodes, runningCalls),
nodes: [], turnTimings: new Map(), turnEnds: new Map(), partial: null, runningCalls: [],
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,

View File

@@ -23,7 +23,7 @@ import { createChatStore } from '@deepseek-ai/dsh-client-ui-conversation/src/cli
import { GenericToolCard, type GenericToolCardProps } from '../src/client/tool/toolviews/GenericToolCard.tsx'
import { DetailsPanel } from '@deepseek-ai/dsh-client-ui-conversation/src/client/skeleton/DetailsPanel.tsx'
import { SearchRow, searchToolview } from '../src/client/tool/toolviews/search-row.tsx'
import { renderToolDetails, SessionProviderStub } from './tool-details-render.tsx'
import { renderToolDetails, SessionProviderStub, toolChatSnapshot } from './tool-details-render.tsx'
/** SearchRow now composes ToolRow, so its props include the locale `t` seat. */
type SearchRowProps = Parameters<typeof SearchRow>[0]
@@ -65,7 +65,7 @@ const resultPaths = (over?: Partial<Extract<ToolResultView, { card: 'search'; sh
const runningGrep = (over?: Partial<RunningToolCall>): RunningToolCall => ({
callId: 'c1', name: 'grep', argsRaw: GREP_ARGS,
turn: 1, step: 1, time: 1_000, callView: { card: 'generic', title: 'Grep foo', kind: 'search' }, ...over,
turn: 1, step: 1, time: 1_000, callView: { card: 'generic', title: 'Grep foo', kind: 'search' }, subCalls: [], ...over,
})
const settledGrep = (over?: Partial<ToolResultNode>): ToolResultNode => ({
@@ -73,7 +73,7 @@ const settledGrep = (over?: Partial<ToolResultNode>): ToolResultNode => ({
call: { name: 'grep', argsRaw: GREP_ARGS },
callTime: 1_000,
content: [{ type: 'text', text: 'a.ts\n Line 12: const foo = 1' }], isError: false,
callView: { card: 'generic', title: 'Grep foo', kind: 'search' }, resultView: resultMatches(), ...over,
callView: { card: 'generic', title: 'Grep foo', kind: 'search' }, resultView: resultMatches(), subCalls: [], ...over,
})
const settledGlob = (over?: Partial<ToolResultNode>): ToolResultNode => ({
@@ -81,7 +81,7 @@ const settledGlob = (over?: Partial<ToolResultNode>): ToolResultNode => ({
call: { name: 'glob', argsRaw: GLOB_ARGS },
callTime: 1_000,
content: [{ type: 'text', text: 'src/a.ts\nsrc/b.ts' }], isError: false,
callView: { card: 'generic', title: 'Glob **/*.ts', kind: 'search' }, resultView: resultPaths(), ...over,
callView: { card: 'generic', title: 'Glob **/*.ts', kind: 'search' }, resultView: resultPaths(), subCalls: [], ...over,
})
describe('searchCardModel', () => {
@@ -410,8 +410,11 @@ describe('DetailsPanel Output section (search)', () => {
}
function snapshot(over: Partial<ConversationSnapshot> = {}): ConversationSnapshot {
const nodes = over.nodes ?? []
const runningCalls = over.runningCalls ?? []
return {
sessionId: SID, nodes: [], turnTimings: new Map(), turnEnds: new Map(), partial: null, runningCalls: [], codeDispatches: new Map(),
sessionId: SID, chat: over.chat ?? toolChatSnapshot(nodes, runningCalls),
nodes: [], turnTimings: new Map(), turnEnds: new Map(), partial: null, runningCalls: [],
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,

View File

@@ -20,7 +20,7 @@ import { createChatStore } from '@deepseek-ai/dsh-client-ui-conversation/src/cli
import { GenericToolCard, type GenericToolCardProps } from '../src/client/tool/toolviews/GenericToolCard.tsx'
import { DetailsPanel } from '@deepseek-ai/dsh-client-ui-conversation/src/client/skeleton/DetailsPanel.tsx'
import { BashRow } from '../src/client/tool/toolviews/bash-sample.tsx'
import { renderToolDetails, SessionProviderStub } from './tool-details-render.tsx'
import { renderToolDetails, SessionProviderStub, toolChatSnapshot } from './tool-details-render.tsx'
import { zh } from '@deepseek-ai/dsh-client-ui-conversation/src/client/locales.ts'
type BashRowProps = Parameters<typeof BashRow>[0]
@@ -58,7 +58,7 @@ const resultTerminal = (over?: Partial<Extract<ToolResultView, { card: 'terminal
const running = (over?: Partial<RunningToolCall>): RunningToolCall => ({
callId: 'c1', name: 'bash', argsRaw: ARGS,
turn: 1, step: 1, time: 1_000, callView: callTerminal(), ...over,
turn: 1, step: 1, time: 1_000, callView: callTerminal(), subCalls: [], ...over,
})
const settled = (over?: Partial<ToolResultNode>): ToolResultNode => ({
@@ -66,7 +66,7 @@ const settled = (over?: Partial<ToolResultNode>): ToolResultNode => ({
call: { name: 'bash', argsRaw: ARGS },
callTime: 1_000,
content: [{ type: 'text', text: 'a.ts b.ts\nc.ts d.ts\n' }], isError: false,
callView: callTerminal(), resultView: resultTerminal(), ...over,
callView: callTerminal(), resultView: resultTerminal(), subCalls: [], ...over,
})
describe('terminalCardModel', () => {
@@ -479,8 +479,11 @@ describe('DetailsPanel Output section', () => {
}
function snapshot(over: Partial<ConversationSnapshot> = {}): ConversationSnapshot {
const nodes = over.nodes ?? []
const runningCalls = over.runningCalls ?? []
return {
sessionId: SID, nodes: [], turnTimings: new Map(), turnEnds: new Map(), partial: null, runningCalls: [], codeDispatches: new Map(),
sessionId: SID, chat: over.chat ?? toolChatSnapshot(nodes, runningCalls),
nodes: [], turnTimings: new Map(), turnEnds: new Map(), partial: null, runningCalls: [],
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,
@@ -568,15 +571,17 @@ describe('DetailsPanel Output section', () => {
// pins the resolution path with views injected directly, and the arm below
// pins what the shipped path actually shows today.
it('a run_code sub-dispatch resolves to its own terminal card once views reach it', () => {
const child = settled({ callId: 'c1' })
const view = mount(snapshot({
codeDispatches: new Map([['p1', [settled({ callId: 'c1' })]]]),
runningCalls: [running({ callId: 'p1', subCalls: [child] })],
}), target)
expect(view.getByText('a.ts b.ts', RAW)).toBeTruthy()
})
it('a sub-dispatch as the wire actually delivers it (no views) keeps the flattened form', () => {
const child = settled({ callId: 'c1', callView: null, resultView: null })
const view = mount(snapshot({
codeDispatches: new Map([['p1', [settled({ callId: 'c1', callView: null, resultView: null })]]]),
runningCalls: [running({ callId: 'p1', subCalls: [child] })],
}), target)
// No terminal card: the generic path renders the result text in the Output
// section's <pre> (the Input section has its own, hence the scoping).
@@ -588,7 +593,10 @@ describe('DetailsPanel Output section', () => {
it('a running run_code sub-dispatch resolves through the running material', () => {
const view = mount(snapshot({
// The leading non-matching sub-call exercises the scan's skip.
codeDispatches: new Map([['p1', [running({ callId: 'other' }), running()]]]),
runningCalls: [running({
callId: 'p1',
subCalls: [running({ callId: 'other' }), running()],
})],
}), target)
expect(view.getByText('ls -la')).toBeTruthy()
})

View File

@@ -61,7 +61,7 @@ describe('planSummary', () => {
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,
content: [], isError: false, callView: null, resultView: null, subCalls: [], ...over,
})
function rowProps(block: unknown): TodoRowProps {
@@ -106,7 +106,7 @@ describe('TodoRow', () => {
it('keeps non-ok execution states visible through the shared row states', () => {
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 })} />)
const running = render(<TodoRow {...rowProps({ callId: 'c1', name: 'todo_write', argsRaw: args, turn: 1, step: 1, time: 1_000, callView: null, subCalls: [] })} />)
expect(running.container.querySelector('[data-state="running"]')).not.toBeNull()
expect(running.container.querySelector('[data-state="running"] svg')).not.toBeNull()
running.unmount()

View File

@@ -2,7 +2,7 @@
/** ToolCallTree-owned root/subcall markers and selection projection. */
import { afterEach, describe, expect, it, vi } from 'vitest'
import { cleanup, render } from '@testing-library/react'
import type { CodeSubCall, ConversationSnapshot, ToolResultNode } from '@deepseek-ai/dsh-client-runtime/client'
import type { ConversationSnapshot, ToolResultNode } from '@deepseek-ai/dsh-client-runtime/client'
import { makeTranslate } from '@deepseek-ai/dsh-client-test-runtime'
import { zh as commonZh } from '@deepseek-ai/dsh-client-locale/src/locales/zh.ts'
import type { ToolTreeProps } from '../src/client/contract/slots.ts'
@@ -15,27 +15,35 @@ const t: ToolTreeProps['t'] = makeTranslate(zh, commonZh)
const root = (callId: string, call: ToolResultNode['call']): ToolResultNode => ({
kind: 'tool-result', seq: 3, time: 3_000, callId, call, callTime: 2_000,
content: [], isError: false, callView: null, resultView: null,
content: [], isError: false, callView: null, resultView: null, subCalls: [],
})
function props(
block: ToolResultNode,
codeDispatches: ReadonlyMap<string, readonly CodeSubCall[]> = new Map(),
selectedCallId?: string,
): ToolTreeProps {
const snapshot = { codeDispatches } as ConversationSnapshot
const snapshot = {} as ConversationSnapshot
const useSession = ((selector: (value: ConversationSnapshot) => unknown) => selector(snapshot)) as ToolTreeProps['useSession']
const renderSlot = ((_key: string, _owner: object, options?: { fallback?: React.ReactNode }) =>
options?.fallback ?? null) as unknown as ToolTreeProps['renderSlot']
return {
useSession,
renderSlot,
callId: block.callId,
toolName: block.call?.name ?? '',
block,
node: {
key: `tool:${block.callId}`,
kind: 'tool-call',
id: block.callId,
target: 'chat',
anchorSeq: block.seq,
location: { kind: 'session' },
visibility: 'visible',
data: { root: block },
},
selectedCallId,
openFile: vi.fn(),
inspectCall: vi.fn(),
forkAt: vi.fn(),
fileMentions: vi.fn(),
t,
} as unknown as ToolTreeProps
}
@@ -43,7 +51,7 @@ function props(
describe('ToolCallTree', () => {
it('owns the root marker, generic fallback, and selected state for a window-truncated call', () => {
const block = root('w1', null)
const view = render(<ToolCallTree {...props(block, new Map(), 'w1')} />)
const view = render(<ToolCallTree {...props(block, 'w1')} />)
const row = view.container.querySelector('[data-chat-call-id="w1"]')
expect(row?.getAttribute('data-chat-anchor-key')).toBe('call:w1')
expect(row?.getAttribute('data-selected')).toBe('true')
@@ -51,15 +59,23 @@ describe('ToolCallTree', () => {
expect(view.getByText('w1')).toBeTruthy()
})
it('marks a selected subcall without selecting its root', () => {
const block = root('parent', { name: 'run_code', argsRaw: '{"code":"return 1"}' })
const child: CodeSubCall = root('parent:code:1', { name: 'read', argsRaw: '{"path":"a.ts"}' })
const view = render(
<ToolCallTree {...props(block, new Map([['parent', [child]]]), child.callId)} />,
)
expect(view.container.querySelector('[data-subcalls]')?.parentElement)
.toBe(view.container.querySelector('[data-chat-call-id="parent"]'))
it('recursively renders a selected leaf without selecting its ancestors', () => {
const leaf = root('parent:code:1:code:1', { name: 'read', argsRaw: '{"path":"a.ts"}' })
const child = {
...root('parent:code:1', { name: 'run_code', argsRaw: '{"code":"return 1"}' }),
subCalls: [leaf],
}
const block = {
...root('parent', { name: 'run_code', argsRaw: '{"code":"return 1"}' }),
subCalls: [child],
}
const view = render(<ToolCallTree {...props(block, leaf.callId)} />)
const nests = view.container.querySelectorAll('[data-subcalls]')
expect(nests[0]?.parentElement).toBe(view.container.querySelector('[data-chat-call-id="parent"]'))
expect(nests[1]?.parentElement).toBe(view.container.querySelector('[data-chat-call-id="parent:code:1"]'))
expect(view.container.querySelector('[data-chat-call-id="parent"]')?.hasAttribute('data-selected')).toBe(false)
expect(view.container.querySelector('[data-chat-call-id="parent:code:1"]')?.getAttribute('data-selected')).toBe('true')
expect(view.container.querySelector('[data-chat-call-id="parent:code:1"]')?.hasAttribute('data-selected')).toBe(false)
expect(view.container.querySelector('[data-chat-call-id="parent:code:1:code:1"]')?.getAttribute('data-selected')).toBe('true')
expect(nests).toHaveLength(2)
})
})

View File

@@ -1,5 +1,7 @@
/** Test adapter for the production conversation.details.tool registration. */
import type { SessionId } from '@deepseek-ai/dsh-client-runtime/client'
import type {
ChatConversationViewNode, ChatSnapshot, ConversationNode, RunningToolCall, SessionId,
} from '@deepseek-ai/dsh-client-runtime/client'
import type { SessionProviderComponent, TranslateNS } from '@deepseek-ai/dsh-client-ui-slots'
import type { DetailsSlotProps, DetailsToolOwnerProps } from '@deepseek-ai/dsh-client-ui-conversation/src/client/contract/slots.ts'
import { ToolDetails } from '../src/client/tool/ToolDetails.tsx'
@@ -7,6 +9,45 @@ import { ToolDetails } from '../src/client/tool/ToolDetails.tsx'
/** Framework session-area seat used by direct DetailsPanel tests. */
export const SessionProviderStub: SessionProviderComponent = ({ children }) => children('s1' as SessionId)
/** Build the canonical Chat slice consumed by Tool rows and details tests. */
export function toolChatSnapshot(
settled: readonly ConversationNode[] = [],
running: readonly RunningToolCall[] = [],
): ChatSnapshot {
const roots = [...settled.filter(node => node.kind === 'tool-result'), ...running]
const nodes: ChatConversationViewNode[] = roots.map(root => ({
key: `tool:${root.callId}`,
kind: 'tool-call',
id: root.callId,
target: 'chat',
anchorSeq: 'kind' in root ? root.seq : Number.MAX_SAFE_INTEGER,
location: { kind: 'session' },
visibility: 'visible',
data: { root },
}))
const byKey = new Map(nodes.map(node => [node.key, node]))
const empty: readonly string[] = []
return {
order: nodes.map(node => node.key),
nodes: {
get: key => byKey.get(key),
values: () => nodes,
},
locations: {
getTurn: () => empty,
getStep: () => empty,
},
timeline: { turnOrder: [], turns: new Map() },
legacy: {
nodes: settled,
runningCalls: running,
partial: null,
turnTimings: new Map(),
turnEnds: new Map(),
},
}
}
/**
* Bind ui-tool's details renderer to the conversation slot callback shape.
* @param t - conversation locale seat used by Tool cards.
@@ -16,7 +57,7 @@ export function renderToolDetails(t: TranslateNS<'conversation'>): DetailsSlotPr
return (_key, owner) => {
// PropsRenderSlots keeps its key generic even for this one-key share;
// recover the concrete owner selected by the adapter's fixed slot.
const details = owner as DetailsToolOwnerProps
const details = owner as unknown as DetailsToolOwnerProps
return <ToolDetails block={details.block} cwd={details.cwd} t={t} />
}
}

View File

@@ -20,14 +20,14 @@ const t: GenericToolCardProps['t'] = makeTranslate(zh, commonZh)
const running = (over?: Partial<RunningToolCall>): RunningToolCall => ({
callId: 'c1', name: 'bash', argsRaw: '{"command":"ls -la","description":"List files"}',
turn: 1, step: 1, time: 1_000, callView: null, ...over,
turn: 1, step: 1, time: 1_000, callView: null, subCalls: [], ...over,
})
const result = (over?: Partial<ToolResultNode>): ToolResultNode => ({
kind: 'tool-result', seq: 10, time: 2_000, callId: 'c1',
call: { name: 'bash', argsRaw: '{"command":"ls -la","description":"List files"}' },
callTime: 1_000,
content: [], isError: false, callView: null, resultView: null, ...over,
content: [], isError: false, callView: null, resultView: null, subCalls: [], ...over,
})
describe('tool-call-model', () => {
@@ -71,8 +71,8 @@ describe('tool-call-model', () => {
expect(toolRowModel('read', running({ name: 'read', argsRaw: '{"path":"/tmp/x.ts"}' })).summary).toBe('/tmp/x.ts')
expect(toolRowModel('write', running({ name: 'write', argsRaw: '{"file_path":"src/x.ts"}' })).summary).toBe('src/x.ts')
expect(toolRowModel('edit', running({ name: 'edit', argsRaw: '{"file_path":"src/x.ts"}' })).summary).toBe('src/x.ts')
// Others rows prefix the real tool name into the summary slot (figma-flows
// ruling: static "Tool call" title, name rides the mutable summary).
// Other rows prefix the real tool name into the summary slot (figma
// flows: static "Tool call" title, the name rides the mutable summary).
expect(toolRowModel('x', running({ argsRaw: '{"n":1}' })).summary).toBe('x · {"n":1}')
expect(toolRowModel('x', running({ argsRaw: 'not json' })).summary).toBe('x · not json')
expect(toolRowModel('x', running({ argsRaw: '' })).summary).toBe('x · c1')

View File

@@ -18,6 +18,7 @@ import { LocaleService } from '@deepseek-ai/dsh-client-locale/client'
import { apply as applyConversation, inject as injectConversation } from '@deepseek-ai/dsh-client-ui-conversation/client'
import { apply as applyTool, inject as injectTool } from '@deepseek-ai/dsh-client-ui-tool/client'
import type { ToolCallViewProps } from '@deepseek-ai/dsh-client-ui-tool/client'
import { toolChatSnapshot } from './tool-details-render.tsx'
const SID = 's1' as SessionId
@@ -42,7 +43,7 @@ const toolResult = (seq: number, callId: string, name: string, args = '{"command
kind: 'tool-result', seq, time: seq * 1_000, callId,
call: { name, argsRaw: args },
callTime: seq * 1_000 - 500,
content: [], isError: false, callView: null, resultView: null,
content: [], isError: false, callView: null, resultView: null, subCalls: [],
})
/** Test-owned AppFrame role: declares and renders the resident conversation area. */
@@ -58,7 +59,7 @@ const LAYOUT_CHILDREN = {
/**
* Real-stack bench: SlotTestRuntime with the session/layout doubles at the
* service seams only (external boundaries), the package apply on its own
* service boundaries only, the package apply on its own
* fiber, and the test AppFrame occupying 'root'.
*/
async function bench(nodes: ToolResultNode[]) {
@@ -71,7 +72,7 @@ async function bench(nodes: ToolResultNode[]) {
await runtime.sessions.add({
id: SID,
summary: { title: 'S', displayTitle: 'S' },
snapshot: { nodes },
snapshot: { nodes, chat: toolChatSnapshot(nodes) },
session: {
loadOlder: vi.fn<ISession['loadOlder']>(),
prompt: vi.fn<ISession['prompt']>(async () => ({ ok: true, value: { accepted: true } })),

View File

@@ -23,7 +23,7 @@ import { createChatStore } from '@deepseek-ai/dsh-client-ui-conversation/src/cli
import { GenericToolCard } from '../src/client/tool/toolviews/GenericToolCard.tsx'
import { DetailsPanel } from '@deepseek-ai/dsh-client-ui-conversation/src/client/skeleton/DetailsPanel.tsx'
import { WebRow, webToolview } from '../src/client/tool/toolviews/web-row.tsx'
import { renderToolDetails, SessionProviderStub } from './tool-details-render.tsx'
import { renderToolDetails, SessionProviderStub, toolChatSnapshot } from './tool-details-render.tsx'
import { makeTranslate } from '@deepseek-ai/dsh-client-test-runtime'
import { zh as commonZh } from '@deepseek-ai/dsh-client-locale/src/locales/zh.ts'
import { zh } from '@deepseek-ai/dsh-client-ui-conversation/src/client/locales.ts'
@@ -56,7 +56,7 @@ const resultFetch = (over?: Partial<Extract<ToolResultView, { card: 'web'; kind:
const runningSearch = (over?: Partial<RunningToolCall>): RunningToolCall => ({
callId: 'c1', name: 'web_search', argsRaw: SEARCH_ARGS,
turn: 1, step: 1, time: 1_000, callView: { card: 'generic', title: 'Search', kind: 'search' }, ...over,
turn: 1, step: 1, time: 1_000, callView: { card: 'generic', title: 'Search', kind: 'search' }, subCalls: [], ...over,
})
const settledSearch = (over?: Partial<ToolResultNode>): ToolResultNode => ({
@@ -64,7 +64,7 @@ const settledSearch = (over?: Partial<ToolResultNode>): ToolResultNode => ({
call: { name: 'web_search', argsRaw: SEARCH_ARGS },
callTime: 1_000,
content: [{ type: 'text', text: 'search text' }], isError: false,
callView: { card: 'generic', title: 'Search', kind: 'search' }, resultView: resultSearch(), ...over,
callView: { card: 'generic', title: 'Search', kind: 'search' }, resultView: resultSearch(), subCalls: [], ...over,
})
const settledFetch = (over?: Partial<ToolResultNode>): ToolResultNode => ({
@@ -72,7 +72,7 @@ const settledFetch = (over?: Partial<ToolResultNode>): ToolResultNode => ({
call: { name: 'web_fetch', argsRaw: FETCH_ARGS },
callTime: 1_000,
content: [{ type: 'text', text: 'fetch body' }], isError: false,
callView: { card: 'generic', title: 'Fetch', kind: 'fetch' }, resultView: resultFetch(), ...over,
callView: { card: 'generic', title: 'Fetch', kind: 'fetch' }, resultView: resultFetch(), subCalls: [], ...over,
})
describe('webCardModel', () => {
@@ -240,8 +240,11 @@ describe('DetailsPanel web Output section', () => {
}
function snapshot(over: Partial<ConversationSnapshot> = {}): ConversationSnapshot {
const nodes = over.nodes ?? []
const runningCalls = over.runningCalls ?? []
return {
sessionId: SID, nodes: [], turnTimings: new Map(), turnEnds: new Map(), partial: null, runningCalls: [], codeDispatches: new Map(),
sessionId: SID, chat: over.chat ?? toolChatSnapshot(nodes, runningCalls),
nodes: [], turnTimings: new Map(), turnEnds: new Map(), partial: null, runningCalls: [],
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,