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:
@@ -2,5 +2,5 @@
|
||||
# side as of the last confirmed-consistent state. Both languages carry equal authority;
|
||||
# after editing either side, bring the other along and re-record with:
|
||||
# pnpm run verify-translation-pairing --write packages/client/ui-tool/README.md
|
||||
README.md: bf6213ebfacd8f7963463b2c443524c631c28bcd
|
||||
README.zh.md: 06a46a525eead005375bcf67794a1ceecde678bc
|
||||
README.md: 8255aee4292ea821962477a7108b40cf3b937981
|
||||
README.zh.md: 2bc5859f592efb6ac4b67157be09a3c09afb691c
|
||||
|
||||
@@ -2,15 +2,15 @@
|
||||
|
||||
English | [中文](README.zh.md)
|
||||
|
||||
Client Tool presentation plugin. `ui-conversation` supplies one ordered root call through `conversation.chat.tool`; this package renders that root and its Code Dispatch children, then dispatches every atomic call through the keyed `tool.call.toolview` slot. Unregistered Tool names use the generic card.
|
||||
Client Tool presentation plugin. `ui-conversation` dispatches each ordered `tool-call` Conversation Node through the matching key of `conversation.chat.node`; this package renders its root and Code Dispatch children, then dispatches every atomic call through the keyed `tool.call.toolview` slot. Unregistered Tool names use the generic card.
|
||||
|
||||
Business UI packages register only their wire Tool names and atomic views. They do not pair Session events, rebuild the transcript, or own root/subcall topology. The Runtime remains authoritative for call/result pairing, lifecycle, and `codeDispatches`; the conversation view remains authoritative for ChatFlow placement.
|
||||
Business UI packages register only their wire Tool names and atomic views. They do not pair Session events, rebuild the transcript, or own root/subcall topology. The Runtime remains authoritative for call/result pairing, lifecycle, and recursive `subCalls` projection; the conversation view remains authoritative for ChatFlow placement.
|
||||
|
||||
## Rendering contract
|
||||
|
||||
`ToolCallTree` receives one root `ToolCallBlock`, selection state, the session `cwd`, and Host callbacks for opening files and inspecting calls. Through its standard session slot props it selects the Runtime-projected `codeDispatches[rootCallId]` array, then sends the root and every child through the same atomic dispatch path. The Runtime currently exposes only one Code Dispatch child level, so the renderer preserves that shape instead of inventing recursive data.
|
||||
`ToolCallTree` receives one root `ToolCallBlock` that already contains recursive `subCalls`, selection state, the session `cwd`, and Host callbacks for opening files and inspecting calls. It recursively walks the standard call blocks and sends the root and children at every depth through the same atomic dispatch path, without subscribing to a separate parent-to-children map.
|
||||
|
||||
Each root and child wrapper preserves the `conversation.chat.tool` call-anchor DOM contract used for paging and selection.
|
||||
Each root and child wrapper preserves the `data-chat-anchor-key="call:<id>"` and `data-chat-call-id` DOM contract used for paging and selection.
|
||||
|
||||
The package also fills `conversation.details.tool` with `ToolDetails`. The row and details renderers share the same pure card models for `terminal`, `read`, `diff`, `search`, and `web` render intents. Unknown intent tags and malformed wire card data fall back to flattened Tool result text.
|
||||
|
||||
@@ -44,6 +44,6 @@ None. The package is client-only presentation.
|
||||
|
||||
## Known Limitations and Deferred Work
|
||||
|
||||
- The Runtime currently exposes one level of Code Dispatch children. The renderer sends roots and children through the same atomic path, but it does not claim an arbitrary recursive wire topology.
|
||||
- Existing first-party Tool views are initially colocated here and can move to their owning business packages independently through the keyed slot.
|
||||
- Tool copy temporarily reuses the `ui-conversation` locale namespace.
|
||||
- The Host excludes `run_code` from Code Mode program bindings, so production events produce one dispatch level; the recursive Runtime/UI contract supports nesting.
|
||||
- First-party Tool views are colocated here and can move to their owning business packages independently through the keyed slot.
|
||||
- Tool copy reuses the `ui-conversation` locale namespace.
|
||||
|
||||
@@ -2,15 +2,15 @@
|
||||
|
||||
[English](README.md) | 中文
|
||||
|
||||
Client Tool 展示插件。`ui-conversation` 通过 `conversation.chat.tool` 交付一个已经排好位置的 root call;本包渲染该 root 及其 Code Dispatch 子调用,并把每个原子调用通过 keyed slot `tool.call.toolview` 分发。没有注册的 Tool 名称使用通用卡片。
|
||||
Client Tool 展示插件。`ui-conversation` 通过 `conversation.chat.node` 的同名 key 分发每个已排序的 `tool-call` Conversation Node;本包渲染其中的 root 及其 Code Dispatch 子调用,并把每个原子调用通过 keyed slot `tool.call.toolview` 分发。没有注册的 Tool 名称使用通用卡片。
|
||||
|
||||
业务 UI 包只注册 wire Tool 名称和原子视图,不配对 Session Event、不重建 transcript,也不拥有 root/subcall 拓扑。Runtime 继续负责 call/result 配对、生命周期和 `codeDispatches`;conversation view 继续负责 ChatFlow 位置。
|
||||
业务 UI 包只注册 wire Tool 名称和原子视图,不配对 Session Event、不重建 transcript,也不拥有 root/subcall 拓扑。Runtime 继续负责 call/result 配对、生命周期和递归 `subCalls` 投影;conversation view 继续负责 ChatFlow 位置。
|
||||
|
||||
## 渲染契约
|
||||
## 渲染约定
|
||||
|
||||
`ToolCallTree` 接收一个 root `ToolCallBlock`、selection 状态、会话 `cwd`,以及用于打开文件和检查调用的 Host 回调。它通过标准 session slot props 选择 Runtime 投影的 `codeDispatches[rootCallId]` 数组,再让 root 与每个 child 经过同一条原子分发路径。Runtime 当前只暴露一层 Code Dispatch child,因此 renderer 保留该形状,不自行发明递归数据。
|
||||
`ToolCallTree` 接收一个已经包含递归 `subCalls` 的 root `ToolCallBlock`、selection 状态、会话 `cwd`,以及用于打开文件和检查调用的 Host 回调。它递归遍历标准 call block,让 root 与任意深度的 child 经过同一条原子分发路径,不再订阅独立的 parent-to-children map。
|
||||
|
||||
每个 root 和 child wrapper 都保留 `conversation.chat.tool` 的 call-anchor DOM 契约,供分页和 selection 使用。
|
||||
每个 root 和 child wrapper 都保留 `data-chat-anchor-key="call:<id>"` 与 `data-chat-call-id` DOM 约定,供分页和 selection 使用。
|
||||
|
||||
本包还通过 `ToolDetails` 填充 `conversation.details.tool`。行 renderer 与详情 renderer 为 `terminal`、`read`、`diff`、`search` 和 `web` render intent 共用同一组纯 card model。本版本不认识的 intent 标签和格式错误的 wire card 数据都会回退为压平的 Tool result 文本。
|
||||
|
||||
@@ -44,6 +44,6 @@ owner 载荷为 `ToolCallOwnerProps`:`callId`、`toolName`、冻结的 `block`
|
||||
|
||||
## 已知限制与后续工作
|
||||
|
||||
- Runtime 当前只暴露一层 Code Dispatch 子调用。renderer 会让 root 和 child 经过同一个原子分发路径,但不宣称 wire 拓扑已经支持任意递归。
|
||||
- 现有第一方 Tool 视图初期仍集中在本包,之后可以通过 keyed slot 独立迁回各自业务包。
|
||||
- Tool 文案暂时复用 `ui-conversation` locale namespace。
|
||||
- Host 不把 `run_code` 暴露为 Code Mode 程序 binding,因此生产事件只产生一层分发;递归的运行时/UI 约定支持嵌套。
|
||||
- 第一方 Tool 视图集中在本包,可以通过 keyed slot 独立迁回各自业务包。
|
||||
- Tool 文案复用 `ui-conversation` locale namespace。
|
||||
|
||||
@@ -20,8 +20,9 @@ export const inject = ['slots']
|
||||
* @param ctx - Client root context.
|
||||
*/
|
||||
export function apply(ctx: ClientContext): void {
|
||||
ctx.slots.inject('conversation.chat.tool', () => ctx.slots.register({
|
||||
name: 'conversation.chat.tool',
|
||||
ctx.slots.inject('conversation.chat.node', () => ctx.slots.register({
|
||||
name: 'conversation.chat.node',
|
||||
key: 'tool-call',
|
||||
locale: NS,
|
||||
children: {
|
||||
'tool.call.toolview': { kind: 'keyed', scope: 'session' },
|
||||
|
||||
@@ -30,8 +30,8 @@ export interface ToolCallOwnerProps {
|
||||
/** Full props of a registered atomic Tool view. */
|
||||
export type ToolCallViewProps = PropsRuntime<'tool.call.toolview'>
|
||||
|
||||
/** Full props of the Tool call-tree renderer registered into the chat flow. */
|
||||
export type ToolTreeProps = PropsRuntime<'conversation.chat.tool'>
|
||||
/** Full props of the Tool call-tree renderer registered as a `tool-call` Chat Node. */
|
||||
export type ToolTreeProps = PropsRuntime<'conversation.chat.node', 'tool-call'>
|
||||
& PropsRenderSlots<'tool.call.toolview'>
|
||||
& PropsLocale<'conversation'>
|
||||
|
||||
|
||||
@@ -1,12 +1,12 @@
|
||||
/** Root/subcall Tool composition with one keyed atomic dispatch path. */
|
||||
import { memo, useMemo, type ReactNode } from 'react'
|
||||
import type { CodeSubCall, ToolCallBlock } from '@deepseek-ai/dsh-client-runtime/client'
|
||||
import type { ToolCallBlock } from '@deepseek-ai/dsh-client-runtime/client'
|
||||
import type { ToolCallOwnerProps, ToolTreeProps } from '../contract/slots.ts'
|
||||
import { GenericToolCard } from './toolviews/GenericToolCard.tsx'
|
||||
import css from './ToolCallTree.module.css'
|
||||
|
||||
/** Resolve a Code Dispatch child's wire Tool name from either lifecycle form. */
|
||||
function subCallName(node: CodeSubCall): string {
|
||||
/** Resolve a Tool call's wire name from either lifecycle form. */
|
||||
function callName(node: ToolCallBlock): string {
|
||||
return 'kind' in node ? node.call?.name ?? '' : node.name
|
||||
}
|
||||
|
||||
@@ -44,40 +44,33 @@ const ToolCall = memo(function ToolCall({
|
||||
)
|
||||
})
|
||||
|
||||
/**
|
||||
* Render one root Tool call and its currently supported one-level Code
|
||||
* Dispatch children. Root and children use the same atomic keyed dispatch.
|
||||
* @param props - whole-Tool owner data and the Tool-owned child-slot share.
|
||||
* @returns the Tool call tree.
|
||||
*/
|
||||
export function ToolCallTree({
|
||||
useSession, renderSlot, callId, toolName, block, selectedCallId, cwd, openFile, inspectCall, t,
|
||||
}: ToolTreeProps) {
|
||||
const subCalls = useSession(snapshot => snapshot.codeDispatches.get(callId))
|
||||
const ToolCallBranch = memo(function ToolCallBranch({
|
||||
renderSlot, block, selectedCallId, cwd, openFile, inspectCall, t,
|
||||
}: Pick<ToolTreeProps, 'renderSlot' | 'selectedCallId' | 'cwd' | 'openFile' | 'inspectCall' | 't'> & {
|
||||
block: ToolCallBlock
|
||||
}) {
|
||||
return (
|
||||
<ToolCall
|
||||
renderSlot={renderSlot}
|
||||
callId={callId}
|
||||
toolName={toolName}
|
||||
callId={block.callId}
|
||||
toolName={callName(block)}
|
||||
block={block}
|
||||
openFile={openFile}
|
||||
selected={callId === selectedCallId}
|
||||
selected={block.callId === selectedCallId}
|
||||
cwd={cwd}
|
||||
inspectCall={inspectCall}
|
||||
t={t}
|
||||
>
|
||||
{subCalls !== undefined && subCalls.length > 0 ? (
|
||||
{block.subCalls.length > 0 ? (
|
||||
<div className={css.subCalls} data-subcalls>
|
||||
{subCalls.map(node => (
|
||||
<ToolCall
|
||||
key={node.callId}
|
||||
{block.subCalls.map(child => (
|
||||
<ToolCallBranch
|
||||
key={child.callId}
|
||||
renderSlot={renderSlot}
|
||||
callId={node.callId}
|
||||
toolName={subCallName(node)}
|
||||
block={node}
|
||||
openFile={openFile}
|
||||
selected={node.callId === selectedCallId}
|
||||
block={child}
|
||||
selectedCallId={selectedCallId}
|
||||
cwd={cwd}
|
||||
openFile={openFile}
|
||||
inspectCall={inspectCall}
|
||||
t={t}
|
||||
/>
|
||||
@@ -86,4 +79,27 @@ export function ToolCallTree({
|
||||
) : null}
|
||||
</ToolCall>
|
||||
)
|
||||
})
|
||||
|
||||
/**
|
||||
* Render one root Tool call and its recursive children through the same
|
||||
* atomic keyed dispatch.
|
||||
* @param props - whole-Tool owner data and the Tool-owned child-slot share.
|
||||
* @returns the Tool call tree.
|
||||
*/
|
||||
export function ToolCallTree({
|
||||
renderSlot, node, selectedCallId, cwd, openFile, inspectCall, t,
|
||||
}: ToolTreeProps) {
|
||||
const block = node.data.root
|
||||
return (
|
||||
<ToolCallBranch
|
||||
renderSlot={renderSlot}
|
||||
block={block}
|
||||
selectedCallId={selectedCallId}
|
||||
cwd={cwd}
|
||||
openFile={openFile}
|
||||
inspectCall={inspectCall}
|
||||
t={t}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -49,8 +49,8 @@ export function SearchRow({ toolName, block, inspect, t }: SearchRowProps) {
|
||||
// sub-dispatch, legacy generic result) has its text nowhere else to go;
|
||||
// ToolRow's Output section carries it, and errorSummary its first line.
|
||||
// When a card is present ToolRow renders it instead of the output, so
|
||||
// passing model.output unconditionally is safe and keeps the four card
|
||||
// rows symmetric.
|
||||
// model.output passes unconditionally and the four card rows stay
|
||||
// symmetric.
|
||||
output={model.output}
|
||||
errorSummary={model.errorSummary}
|
||||
search={search}
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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 } })),
|
||||
|
||||
@@ -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()
|
||||
})
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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()
|
||||
})
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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)
|
||||
})
|
||||
})
|
||||
|
||||
@@ -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} />
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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')
|
||||
|
||||
@@ -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 } })),
|
||||
|
||||
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user