Merge remote-tracking branch 'origin/master' into xtr/agent-loop-message-machine
# Conflicts: # docs/cordis-catalog/services.md # examples/acp-agent/tests/snapshots/code-mode-workspace-context/session.jsonl # packages/core/tools/README.i18n.yaml # packages/core/tools/src/code-mode.ts
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
|
||||
README.md: f5e2603e8ad588740bea3be4f1a195d658721f0c
|
||||
README.zh.md: 900d3d1608da3c86078dc56ec819b1f851e63de7
|
||||
README.md: b9ec555f158722ea1f41e01c4b3f7131d3fe3467
|
||||
README.zh.md: b1e3c1f4331148ebf1c58b4bcd4869270bb44311
|
||||
|
||||
@@ -8,7 +8,7 @@ The no-session hero renders the frontend Session Intent from the Session list pr
|
||||
|
||||
The view ring IS a slot: the conversation registration declares the `'conversation.view'` list slot (session scope) in its `children` table, ConversationRoot renders the active entry through its renderSlot share (`only: <active id>`), and view tabs project from the ring ledger's registration options (`id`/`order`/`label`). The chat view is this package's own ring entry; other plugins (ui-trajectory) contribute tabs through plain `ctx.slots.register` — the former package-local view registry (`registerView`/`ViewEntry`/`ConversationViewMap` and the chrome attachment table) is retired, with per-view chrome dissolved into the view components themselves.
|
||||
|
||||
Generic tool rows classify the built-in bash, read, search, write, and edit names into dedicated visual variants. The filesystem variants render the edit icon and `Write · <path>` or `Edit · <path>` summary while retaining the shared row-to-details interaction.
|
||||
Generic tool rows classify the built-in bash, read, search, write, edit, and run_code names into dedicated visual variants. The filesystem variants render the edit icon and `Write · <path>` or `Edit · <path>` summary while retaining the shared row-to-details interaction. The code variant summarizes with the model-authored `description` and expands to the program itself; its logged sub-dispatches render as always-visible nested rows through the SAME keyed toolview hole (custom registrations and the GenericToolCard fallback apply to sub-rows unchanged), and the details panel resolves a selected sub-call id to its full logged args and complete output.
|
||||
|
||||
Tool rows are slots too — the standalone tool ring (`ToolViewRegistry`/`ctx.toolviews`/outlet) is retired. The chat entry declares the keyed `'conversation.chat.toolview'` hole (session scope; the key space is runtime-open); its render site dispatches per row via `entryKey: toolName` with `GenericToolCard` as the call-site `fallback`. The owner payload is the uniform `ToolRowOwnerProps` (`callId`/`toolName`/`block`/`openDetails`) and `ToolRowProps` pre-composes it with the session standard kit. A registrant is a plain plugin: `ctx.slots.register({ name: 'conversation.chat.toolview', key: '<tool>', inject? }, Row)` with `inject: ['slots', 'conversation']` as the load-order seam (apply mounts ConversationService after the chat registration, so the service being present guarantees the slot is declared); session differentiation happens inside the component (`useSessions` reading `parentId` — the bash sample is the third-party-posture exemplar). Trajectory/waterfall toolview slots share this shape and land with their own render sites (RendersCheck rejects a declaration nobody renders).
|
||||
|
||||
|
||||
@@ -8,7 +8,7 @@
|
||||
|
||||
视图环本身就是 slot:会话注册声明 `'conversation.view'` 列表 slot(Session scope),并将其列在 `children` 表中;ConversationRoot 通过 renderSlot share 渲染活跃配置项(`only: <active id>`);视图标签页从环账本的注册选项(`id`/`order`/`label`)投影而来。聊天视图是该包自身的环配置项;其他插件(ui-trajectory)通过普通的 `ctx.slots.register` 贡献标签页。先前包内的视图注册表(`registerView`/`ViewEntry`/`ConversationViewMap` 及 chrome 附加表)已退役,逐视图 chrome 则被拆入视图组件自身。
|
||||
|
||||
通用工具行把内置的 bash、read、search、write 和 edit 名称归入专用视觉变体。文件系统变体会渲染 edit 图标和 `Write · <path>` 或 `Edit · <path>` 摘要,同时保留共享的行到详情交互。
|
||||
通用工具行把内置的 bash、read、search、write、edit 和 run_code 名称归入专用视觉变体。文件系统变体会渲染 edit 图标和 `Write · <path>` 或 `Edit · <path>` 摘要,同时保留共享的行到详情交互。code 变体以模型撰写的 `description` 作摘要,展开后显示程序本身;其已记录的子调用经由同一个键控 toolview 空位渲染为始终可见的嵌套行(自定义注册和 GenericToolCard fallback 原样适用于子行),details 面板则会根据选中的子调用 id 解析出其完整记录的参数与完整输出。
|
||||
|
||||
工具行同样是 slot:独立工具环(`ToolViewRegistry`/`ctx.toolviews`/outlet)已经退役。聊天配置项声明键控的 `'conversation.chat.toolview'` 空位(Session scope;key 空间在运行时开放);其渲染点逐行通过 `entryKey: toolName` 分发,并以 `GenericToolCard` 作为调用点 `fallback`。owner 载荷是统一的 `ToolRowOwnerProps`(`callId`/`toolName`/`block`/`openDetails`),`ToolRowProps` 则预先将其与 Session 标准工具包组合。注册方只是普通插件:`ctx.slots.register({ name: 'conversation.chat.toolview', key: '<tool>', inject? }, Row)`,以 `inject: ['slots', 'conversation']` 作为加载顺序 seam(apply 在聊天注册后挂载 ConversationService,因此服务存在即可保证 slot 已声明);Session 区分在组件内部完成(`useSessions` 读取 `parentId`,bash 示例是第三方姿态的范例)。Trajectory/waterfall 工具视图 slot 共享此形状,并随各自的渲染点落地(RendersCheck 会拒绝没有任何渲染方的声明)。
|
||||
|
||||
|
||||
@@ -45,6 +45,18 @@
|
||||
outline-offset: 1px;
|
||||
}
|
||||
|
||||
/* run_code sub-dispatch rows: indented under the parent row, left-edged so
|
||||
the code turn reads as one unit; each nested row is itself a .callRow
|
||||
(same components, same selection outline as top-level rows). */
|
||||
.subCalls {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 4px;
|
||||
margin: 4px 0 2px 22px;
|
||||
padding-left: 8px;
|
||||
border-left: 1px solid var(--dsw-alias-border-l2);
|
||||
}
|
||||
|
||||
.hint {
|
||||
color: var(--dsw-alias-label-tertiary);
|
||||
font-size: 12px;
|
||||
|
||||
@@ -20,7 +20,7 @@ import {
|
||||
memo, useLayoutEffect, useMemo, useRef, useState, type ReactNode,
|
||||
} from 'react'
|
||||
import type {
|
||||
ConversationNode, ConversationSnapshot, RunningToolCall, ToolResultNode,
|
||||
CodeSubCall, ConversationNode, ConversationSnapshot, RunningToolCall, ToolResultNode,
|
||||
} from '@deepseek-ai/dsh-client-runtime/client'
|
||||
import type { SnapshotSelectorHook } from '@deepseek-ai/dsh-client-ui-slots'
|
||||
import { IconChevronDownOutline14 } from '@deepseek-ai/dsh-client-ui-primitives'
|
||||
@@ -45,10 +45,39 @@ type RenderToolRow = ChatViewSlotProps['renderSlot']
|
||||
* chat view narrows once to the runtime snapshot the binding actually feeds. */
|
||||
type UseConversation = SnapshotSelectorHook<ConversationSnapshot>
|
||||
|
||||
/** One `run_code` sub-dispatch row: the identical keyed-slot dispatch as a
|
||||
* top-level call (same registrations, same fallback), nested by the parent.
|
||||
* A started-but-unsettled sub-call arrives as the RunningToolCall shape and
|
||||
* renders the running state exactly as a native in-flight row. */
|
||||
const SubCallRow = memo(function SubCallRow({ renderSlot, node, onOpenDetails, selected }: {
|
||||
renderSlot: RenderToolRow
|
||||
node: CodeSubCall
|
||||
onOpenDetails: OpenDetails
|
||||
selected: boolean
|
||||
}) {
|
||||
const settled = 'kind' in node
|
||||
const toolName = settled ? node.call?.name ?? '' : node.name
|
||||
const seq = settled ? node.seq : node.time
|
||||
const owner = useMemo(() => ({
|
||||
callId: node.callId, toolName, block: node,
|
||||
openDetails: () => { onOpenDetails({ turnSeq: seq, callId: node.callId, toolName }) },
|
||||
}), [node, toolName, seq, onOpenDetails])
|
||||
return (
|
||||
<div className={css.callRow} data-selected={selected || undefined}>
|
||||
{renderSlot('conversation.chat.toolview', owner, {
|
||||
entryKey: toolName,
|
||||
fallback: <GenericToolCard {...owner} />,
|
||||
})}
|
||||
</div>
|
||||
)
|
||||
})
|
||||
|
||||
/** One tool call row (result or running): dispatches through the keyed
|
||||
* toolview slot with the owner payload; unregistered tools fall back to
|
||||
* GenericToolCard at this render site. */
|
||||
const CallRow = memo(function CallRow({ renderSlot, callId, toolName, block, seq, onOpenDetails, selected }: {
|
||||
* GenericToolCard at this render site. A `run_code` call additionally
|
||||
* renders its logged sub-dispatches as always-visible indented rows —
|
||||
* each one the same keyed-slot dispatch as a native top-level call. */
|
||||
const CallRow = memo(function CallRow({ renderSlot, callId, toolName, block, seq, onOpenDetails, selected, subCalls, selectedCallId }: {
|
||||
renderSlot: RenderToolRow
|
||||
callId: string
|
||||
toolName: string
|
||||
@@ -57,6 +86,10 @@ const CallRow = memo(function CallRow({ renderSlot, callId, toolName, block, seq
|
||||
seq: number
|
||||
onOpenDetails: OpenDetails
|
||||
selected: boolean
|
||||
/** `run_code` sub-dispatches in dispatch order (reference-stable per parent; running entries settle in place); undefined for ordinary calls. */
|
||||
subCalls?: readonly CodeSubCall[] | undefined
|
||||
/** The store's selected callId, matched against sub-rows (undefined when no sub-row here is selected). */
|
||||
selectedCallId?: string | undefined
|
||||
}) {
|
||||
const owner = useMemo(() => ({
|
||||
callId, toolName, block,
|
||||
@@ -68,17 +101,32 @@ const CallRow = memo(function CallRow({ renderSlot, callId, toolName, block, seq
|
||||
entryKey: toolName,
|
||||
fallback: <GenericToolCard {...owner} />,
|
||||
})}
|
||||
{subCalls !== undefined && subCalls.length > 0 && (
|
||||
<div className={css.subCalls} data-subcalls>
|
||||
{subCalls.map((node) => (
|
||||
<SubCallRow
|
||||
key={node.callId}
|
||||
renderSlot={renderSlot}
|
||||
node={node}
|
||||
onOpenDetails={onOpenDetails}
|
||||
selected={node.callId === selectedCallId}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
})
|
||||
|
||||
/** Consecutive tool results as one step-run group (figma VERTICAL gap10). */
|
||||
const ToolGroup = memo(function ToolGroup({ renderSlot, results, onOpenDetails, selectedCallId }: {
|
||||
const ToolGroup = memo(function ToolGroup({ renderSlot, results, onOpenDetails, selectedCallId, codeDispatches }: {
|
||||
renderSlot: RenderToolRow
|
||||
results: readonly ToolResultNode[]
|
||||
onOpenDetails: OpenDetails
|
||||
/** Only set when the selected call lives in THIS group (memo economy). */
|
||||
/** Only set when the selected call lives in THIS group, top-level or nested (memo economy). */
|
||||
selectedCallId: string | undefined
|
||||
/** Sub-dispatch index off the snapshot (map reference is chunk-storm stable). */
|
||||
codeDispatches: ReadonlyMap<string, readonly CodeSubCall[]>
|
||||
}) {
|
||||
return (
|
||||
<div className={css.toolGroup}>
|
||||
@@ -92,6 +140,8 @@ const ToolGroup = memo(function ToolGroup({ renderSlot, results, onOpenDetails,
|
||||
seq={node.seq}
|
||||
onOpenDetails={onOpenDetails}
|
||||
selected={node.callId === selectedCallId}
|
||||
subCalls={codeDispatches.get(node.callId)}
|
||||
selectedCallId={selectedCallId}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
@@ -116,6 +166,7 @@ function StreamingTail({ useSession, onGrow }: {
|
||||
export function ChatView({ useSession, useStore, renderSlot, openDetails, loadOlder }: ChatViewSlotProps) {
|
||||
const nodes = useSession((s) => s.nodes)
|
||||
const runningCalls = useSession((s) => s.runningCalls)
|
||||
const codeDispatches = useSession((s) => s.codeDispatches)
|
||||
const pending = useSession((s) => s.pending)
|
||||
const openState = useSession((s) => s.openState)
|
||||
const openErrorMessage = useSession((s) => s.openError === null ? null : `${s.openError.message}(${s.openError.code})`)
|
||||
@@ -203,7 +254,8 @@ export function ChatView({ useSession, useStore, renderSlot, openDetails, loadOl
|
||||
const renderItem = (item: ChatFlowItem): ReactNode => {
|
||||
if (item.kind === 'tool-group') {
|
||||
const inGroup = selectedCallId !== undefined
|
||||
&& item.results.some((r) => r.callId === selectedCallId)
|
||||
&& item.results.some((r) => r.callId === selectedCallId
|
||||
|| codeDispatches.get(r.callId)?.some((sub) => sub.callId === selectedCallId) === true)
|
||||
return (
|
||||
<ToolGroup
|
||||
key={item.key}
|
||||
@@ -211,6 +263,7 @@ export function ChatView({ useSession, useStore, renderSlot, openDetails, loadOl
|
||||
results={item.results}
|
||||
onOpenDetails={openDetails}
|
||||
selectedCallId={inGroup ? selectedCallId : undefined}
|
||||
codeDispatches={codeDispatches}
|
||||
/>
|
||||
)
|
||||
}
|
||||
@@ -250,6 +303,8 @@ export function ChatView({ useSession, useStore, renderSlot, openDetails, loadOl
|
||||
seq={call.turn}
|
||||
onOpenDetails={openDetails}
|
||||
selected={call.callId === selectedCallId}
|
||||
subCalls={codeDispatches.get(call.callId)}
|
||||
selectedCallId={selectedCallId}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
|
||||
@@ -6,7 +6,7 @@
|
||||
|
||||
import type { ReactNode } from 'react'
|
||||
import {
|
||||
IconApiOutline14, IconBrowseOutline16, IconEditOutline16, IconSearchOutline16, IconThinkOutline14,
|
||||
IconApiOutline14, IconBrowseOutline16, IconCodeOutline16, IconEditOutline16, IconSearchOutline16, IconThinkOutline14,
|
||||
} from '@deepseek-ai/dsh-client-ui-primitives'
|
||||
import type { ToolRowOwnerProps } from '../contract/slots.ts'
|
||||
import { toolRowModel, type ToolRowVariant } from '../contract/tool-call-model.ts'
|
||||
@@ -21,6 +21,7 @@ const VARIANT_ICONS: Record<ToolRowVariant, ReactNode> = {
|
||||
bash: <IconApiOutline14 size={16} />,
|
||||
write: <IconEditOutline16 />,
|
||||
edit: <IconEditOutline16 />,
|
||||
code: <IconCodeOutline16 />,
|
||||
others: <IconSparkle16 />,
|
||||
}
|
||||
|
||||
|
||||
@@ -86,3 +86,15 @@ button.leading {
|
||||
word-break: break-word;
|
||||
color: var(--dsw-alias-label-tertiary);
|
||||
}
|
||||
|
||||
/* The code variant's expanded body is the run_code program: monospace on the
|
||||
markdown code-block fill so the program reads as code, not prose. */
|
||||
.root[data-variant='code'] .body {
|
||||
font-family: var(--ds-font-family-code);
|
||||
font-size: 13px;
|
||||
line-height: 20px;
|
||||
padding: 6px 8px;
|
||||
margin-left: 22px;
|
||||
border-radius: 6px;
|
||||
background: var(--dsw-alias-markdown-code-block);
|
||||
}
|
||||
|
||||
@@ -13,8 +13,8 @@ export type { ToolCallBlock } from '@deepseek-ai/dsh-client-runtime/client'
|
||||
/** The frozen slice the chat view hands to toolview components as `block`
|
||||
* (both members are cache-stable references off ConversationSnapshot). */
|
||||
|
||||
/** The seven row variants (think is fed by reasoning blocks, not tool calls). */
|
||||
export type ToolRowVariant = 'think' | 'search' | 'read' | 'bash' | 'write' | 'edit' | 'others'
|
||||
/** The eight row variants (think is fed by reasoning blocks, not tool calls). */
|
||||
export type ToolRowVariant = 'think' | 'search' | 'read' | 'bash' | 'write' | 'edit' | 'code' | 'others'
|
||||
|
||||
/** Row state semantic; colors self-supplied via StateDot (design gives none). */
|
||||
export type ToolRowState = 'running' | 'ok' | 'error' | 'stopped'
|
||||
@@ -22,7 +22,7 @@ export type ToolRowState = 'running' | 'ok' | 'error' | 'stopped'
|
||||
/** Figma row titles per variant (design literals, not translatable copy). */
|
||||
export const VARIANT_TITLES: Record<ToolRowVariant, string> = {
|
||||
think: 'Think', search: 'Search', read: 'Read', bash: 'Bash',
|
||||
write: 'Write', edit: 'Edit', others: 'Tool call',
|
||||
write: 'Write', edit: 'Edit', code: 'Code', others: 'Tool call',
|
||||
}
|
||||
|
||||
/** Known tool name -> variant. */
|
||||
@@ -35,6 +35,7 @@ const TOOL_VARIANTS: Record<string, ToolRowVariant> = {
|
||||
glob: 'search',
|
||||
write: 'write',
|
||||
edit: 'edit',
|
||||
run_code: 'code',
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -86,6 +87,7 @@ const SUMMARY_KEYS: Record<ToolRowVariant, readonly string[]> = {
|
||||
think: [],
|
||||
write: ['path', 'file_path'],
|
||||
edit: ['path', 'file_path'],
|
||||
code: ['description'],
|
||||
others: [],
|
||||
}
|
||||
|
||||
@@ -101,10 +103,17 @@ function deriveSummary(variant: ToolRowVariant, argsRaw: string): string {
|
||||
return firstLine(argsRaw)
|
||||
}
|
||||
|
||||
function deriveBody(argsRaw: string): string | null {
|
||||
function deriveBody(variant: ToolRowVariant, argsRaw: string): string | null {
|
||||
if (argsRaw === '') return null
|
||||
const parsed = parseArgs(argsRaw)
|
||||
return parsed === undefined ? argsRaw : JSON.stringify(parsed, null, 2)
|
||||
if (parsed === undefined) return argsRaw
|
||||
// The code row's expanded body IS the program (monospace via the row's
|
||||
// variant styling), not the args JSON envelope around it.
|
||||
if (variant === 'code' && typeof parsed === 'object' && parsed !== null) {
|
||||
const code = (parsed as Record<string, unknown>).code
|
||||
if (typeof code === 'string' && code !== '') return code
|
||||
}
|
||||
return JSON.stringify(parsed, null, 2)
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -128,7 +137,7 @@ export function toolRowModel(toolName: string, block: ToolCallBlock): ToolRowMod
|
||||
variant,
|
||||
title: VARIANT_TITLES[variant],
|
||||
summary,
|
||||
body: deriveBody(argsRaw),
|
||||
body: deriveBody(variant, argsRaw),
|
||||
state,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -31,6 +31,18 @@ function materialFor(s: ConversationSnapshot, callId: string): CallMaterial | nu
|
||||
if (open !== undefined) {
|
||||
return { name: open.name, argsRaw: open.argsRaw, result: null, running: true }
|
||||
}
|
||||
// run_code sub-dispatches: the native call-block shapes, so a selected
|
||||
// sub-row resolves through the same material as a native call — the
|
||||
// settled ToolResultNode form, or the RunningToolCall form mid-flight.
|
||||
for (const subs of s.codeDispatches.values()) {
|
||||
for (const sub of subs) {
|
||||
if (sub.callId !== callId) continue
|
||||
if ('kind' in sub) {
|
||||
return { name: sub.call?.name ?? callId, argsRaw: sub.call?.argsRaw ?? null, result: sub, running: false }
|
||||
}
|
||||
return { name: sub.name, argsRaw: sub.argsRaw, result: null, running: true }
|
||||
}
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,229 @@
|
||||
// @vitest-environment jsdom
|
||||
// Code Mode sub-call acceptance on the REAL machinery stack (same bench as
|
||||
// chat-toolview-slot.spec): a run_code result renders the 'code' variant row
|
||||
// (description summary, program body), its logged sub-dispatches render as
|
||||
// always-visible nested rows through the SAME keyed toolview hole — the bash
|
||||
// sub-call lands in the bash sample plugin's registration exactly like a
|
||||
// top-level bash row, unregistered sub-tools fall back to GenericToolCard —
|
||||
// and a sub-row click opens details for the sub-callId. Running parents
|
||||
// (runningCalls) nest their so-far dispatches the same way.
|
||||
|
||||
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 type {
|
||||
CodeSubCall, ConversationSnapshot, RunningToolCall, SessionId, SessionListState,
|
||||
ToolResultNode, WorkspaceListState,
|
||||
} from '@deepseek-ai/dsh-client-runtime/client'
|
||||
import { createSlotRenderer } from '@deepseek-ai/dsh-client-web-react'
|
||||
import type { PropsRenderSlots } from '@deepseek-ai/dsh-client-ui-slots'
|
||||
import { apply, inject } from '@deepseek-ai/dsh-client-ui-conversation/client'
|
||||
|
||||
const SID = 's1' as SessionId
|
||||
|
||||
afterEach(cleanup)
|
||||
beforeEach(() => {
|
||||
localStorage.clear()
|
||||
})
|
||||
|
||||
const PROGRAM = 'const listing = await tools.bash({ command: "ls notes", description: "List notes" })\nreturn listing'
|
||||
const RUN_CODE_ARGS = JSON.stringify({ code: PROGRAM, description: 'List the notes directory' })
|
||||
|
||||
const codeResult = (seq: number, callId: string): ToolResultNode => ({
|
||||
kind: 'tool-result', seq, time: seq * 1_000, callId,
|
||||
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,
|
||||
})
|
||||
|
||||
const runningCode = (callId: string): RunningToolCall => ({
|
||||
callId, name: 'run_code', argsRaw: RUN_CODE_ARGS, turn: 9, step: 0, time: 9_000, callView: null,
|
||||
})
|
||||
|
||||
const subCall = (seq: number, parent: string, n: number, name: string, args: object, resultText: string, isError = false): CodeSubCall => ({
|
||||
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,
|
||||
})
|
||||
|
||||
function snapshotWith(
|
||||
nodes: ToolResultNode[],
|
||||
codeDispatches: ReadonlyMap<string, readonly CodeSubCall[]>,
|
||||
runningCalls: RunningToolCall[] = [],
|
||||
): ConversationSnapshot {
|
||||
return {
|
||||
sessionId: SID, nodes, foldDegraded: false, partial: null, runningCalls, codeDispatches,
|
||||
pending: [], running: runningCalls.length > 0, composerPhase: 'active', removed: false,
|
||||
openState: 'open', openError: null,
|
||||
hasMore: false, loadingOlder: false, promptError: null, intent: null, pendingPrompt: null, lastAgentError: null,
|
||||
} as ConversationSnapshot
|
||||
}
|
||||
|
||||
/** Test-owned AppFrame role: declares the layout-owned children and renders the conversation area under the framework session provider. */
|
||||
type AppRootProps = PropsRenderSlots<'conversation' | 'details' | 'conversation.empty'>
|
||||
function AppRoot({ renderSlot, SessionProvider }: AppRootProps) {
|
||||
return <SessionProvider>{() => renderSlot('conversation', {})}</SessionProvider>
|
||||
}
|
||||
|
||||
/** Same real-stack bench as the toolview-slot spec: SlotsService + renderer + this package's apply; fakes only at service seams. */
|
||||
async function bench(snapshot: ConversationSnapshot) {
|
||||
const ctx = new Context()
|
||||
const slotsFiber = ctx.plugin(SlotsService)
|
||||
await slotsFiber.await()
|
||||
const slots = ctx.get('slots') as SlotsService
|
||||
|
||||
const session = createSnapshotStore<ConversationSnapshot>(snapshot)
|
||||
const list = createSnapshotStore<SessionListState>({
|
||||
ids: [SID],
|
||||
byId: { [SID]: { id: SID, title: 'S', displayTitle: 'S', running: false, updatedAt: 1 } },
|
||||
current: SID,
|
||||
intent: undefined,
|
||||
phase: 'ready',
|
||||
})
|
||||
const cell = { sessionId: SID, session }
|
||||
const scoped = { send: vi.fn(async () => {}), cancel: vi.fn(async () => {}) }
|
||||
const layout = { openDetails: vi.fn(), closeDetails: vi.fn() }
|
||||
ctx.provide('sessions', {
|
||||
list,
|
||||
binding: (id: SessionId) => ({ sessionId: id, session: { loadOlder: vi.fn() } }),
|
||||
scope: () => ({ get: () => scoped }),
|
||||
cell: (id: string) => (id === SID ? cell : undefined),
|
||||
create: vi.fn(),
|
||||
open: vi.fn(),
|
||||
updateIntent: vi.fn(),
|
||||
})
|
||||
ctx.provide('workspaces', {
|
||||
list: createSnapshotStore<WorkspaceListState>({
|
||||
items: [], intent: undefined, state: 'idle', phase: 'ready', error: null,
|
||||
baselinesReady: true, recentWorkspaceId: undefined,
|
||||
}),
|
||||
startSession: vi.fn(),
|
||||
sendSession: vi.fn(),
|
||||
})
|
||||
ctx.provide('layout', layout)
|
||||
ctx.provide('i18n', { bind: () => (key: string) => key })
|
||||
|
||||
slots.install(createSlotRenderer())
|
||||
slots.register({
|
||||
name: 'root',
|
||||
children: {
|
||||
'conversation': { kind: 'single', scope: 'session' },
|
||||
'details': { kind: 'single', scope: 'session' },
|
||||
'conversation.empty': { kind: 'single', scope: 'root' },
|
||||
},
|
||||
}, AppRoot)
|
||||
|
||||
const fiber = ctx.plugin({ inject: [...inject], apply })
|
||||
await fiber.await()
|
||||
return { ctx, slots, fiber, session, layout }
|
||||
}
|
||||
|
||||
function mountApp(slots: SlotsService) {
|
||||
return render(<>{slots.renderSlot('root', {})}</>)
|
||||
}
|
||||
|
||||
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, [
|
||||
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 view = mountApp(b.slots)
|
||||
|
||||
// Parent row: the code variant with the model-authored description.
|
||||
const codeRoot = view.container.querySelector('[data-variant="code"]')
|
||||
expect(codeRoot).not.toBeNull()
|
||||
expect(view.getByText('Code')).toBeTruthy()
|
||||
expect(view.getByText('List the notes directory')).toBeTruthy()
|
||||
|
||||
// Nested rows are ALWAYS visible (no parent expand needed): the bash
|
||||
// sub-call landed in the bash sample plugin's keyed registration — the
|
||||
// exact component a native top-level bash row uses — and the unregistered
|
||||
// sub-tool fell back to GenericToolCard at the same render site.
|
||||
const nest = view.container.querySelector('[data-subcalls]')
|
||||
expect(nest).not.toBeNull()
|
||||
expect(nest!.querySelector('[data-sample="bash-global"]')).not.toBeNull()
|
||||
expect(view.getByText('List notes')).toBeTruthy()
|
||||
expect(view.getByText('Tool call')).toBeTruthy()
|
||||
})
|
||||
|
||||
it('expanding the code row reveals the program body verbatim', async () => {
|
||||
const parent = 'call-64'
|
||||
const b = await bench(snapshotWith([codeResult(10, parent)], new Map()))
|
||||
const view = mountApp(b.slots)
|
||||
// The code row is expandable via its leading control (body = the program).
|
||||
const toggle = view.container.querySelector('[data-variant="code"] button[aria-expanded]')
|
||||
expect(toggle).not.toBeNull()
|
||||
fireEvent.click(toggle!)
|
||||
expect(view.getByText(/const listing = await tools\.bash/)).toBeTruthy()
|
||||
})
|
||||
|
||||
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, [
|
||||
subCall(11, parent, 1, 'mystery', { n: 1 }, 'Error: boom', true),
|
||||
]]])
|
||||
const b = await bench(snapshotWith([codeResult(10, parent)], dispatches))
|
||||
const view = mountApp(b.slots)
|
||||
const nested = view.container.querySelector('[data-subcalls] [data-variant][data-state="error"]')
|
||||
expect(nested).not.toBeNull()
|
||||
})
|
||||
|
||||
it('a sub-row click opens details for the sub-callId', async () => {
|
||||
const parent = 'call-64'
|
||||
const dispatches = new Map([[parent, [
|
||||
subCall(11, parent, 1, 'bash', { command: 'ls notes', description: 'List notes' }, 'demo.txt'),
|
||||
]]])
|
||||
const b = await bench(snapshotWith([codeResult(10, parent)], dispatches))
|
||||
const view = mountApp(b.slots)
|
||||
view.getByText('List notes').click()
|
||||
expect(b.layout.openDetails).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
|
||||
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, [
|
||||
subCall(21, parent, 1, 'bash', { command: 'ls notes', description: 'List notes' }, 'demo.txt'),
|
||||
]]])
|
||||
const b = await bench(snapshotWith([], dispatches, [runningCode(parent)]))
|
||||
const view = mountApp(b.slots)
|
||||
const running = view.container.querySelector('[data-variant="code"][data-state="running"]')
|
||||
expect(running).not.toBeNull()
|
||||
const nest = view.container.querySelector('[data-subcalls]')
|
||||
expect(nest).not.toBeNull()
|
||||
expect(nest!.querySelector('[data-sample="bash-global"]')).not.toBeNull()
|
||||
})
|
||||
|
||||
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 = {
|
||||
callId: `${parent}:code:1`, name: 'grep', argsRaw: '{"pattern":"todo"}',
|
||||
turn: 0, step: 0, time: 21_000, callView: null,
|
||||
}
|
||||
const dispatches = new Map([[parent, [runningSub]]])
|
||||
const b = await bench(snapshotWith([], dispatches, [runningCode(parent)]))
|
||||
const view = mountApp(b.slots)
|
||||
// The nested row derives 'running' from the RunningToolCall shape — the
|
||||
// same StateDot ring a native in-flight row wears.
|
||||
const nested = view.container.querySelector('[data-subcalls] [data-variant][data-state="running"]')
|
||||
expect(nested).not.toBeNull()
|
||||
})
|
||||
|
||||
it('an ordinary tool row renders no sub-call nest', async () => {
|
||||
const parent = 'call-64'
|
||||
const plain: ToolResultNode = {
|
||||
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,
|
||||
}
|
||||
const b = await bench(snapshotWith([plain], new Map()))
|
||||
const view = mountApp(b.slots)
|
||||
expect(view.container.querySelector('[data-subcalls]')).toBeNull()
|
||||
})
|
||||
})
|
||||
@@ -26,7 +26,7 @@ const assistant = (seq: number, turn: number, usage?: unknown): AssistantMessage
|
||||
|
||||
function snapshotBase(): ConversationSnapshot {
|
||||
return {
|
||||
sessionId: SID, nodes: [], foldDegraded: false, partial: null, runningCalls: [],
|
||||
sessionId: SID, nodes: [], foldDegraded: false, partial: null, runningCalls: [], codeDispatches: new Map(),
|
||||
pending: [], running: false, composerPhase: 'active', removed: false, openState: 'open', openError: null,
|
||||
hasMore: false, loadingOlder: false, promptError: null, intent: null, pendingPrompt: null, lastAgentError: null,
|
||||
}
|
||||
|
||||
@@ -39,7 +39,7 @@ const toolResult = (seq: number, callId: string, name: string, args = '{"command
|
||||
|
||||
function snapshotWith(nodes: ToolResultNode[]): ConversationSnapshot {
|
||||
return {
|
||||
sessionId: SID, nodes, foldDegraded: false, partial: null, runningCalls: [],
|
||||
sessionId: SID, nodes, foldDegraded: false, partial: null, runningCalls: [], codeDispatches: new Map(),
|
||||
pending: [], running: false, composerPhase: 'active', removed: false, openState: 'open', openError: null,
|
||||
hasMore: false, loadingOlder: false, promptError: null, intent: null, pendingPrompt: null, lastAgentError: null,
|
||||
} as ConversationSnapshot
|
||||
|
||||
@@ -28,7 +28,7 @@ const SID = 's1' as SessionId
|
||||
|
||||
function snapshotBase(): ConversationSnapshot {
|
||||
return {
|
||||
sessionId: SID, nodes: [], foldDegraded: false, partial: null, runningCalls: [],
|
||||
sessionId: SID, nodes: [], foldDegraded: false, partial: null, runningCalls: [], codeDispatches: new Map(),
|
||||
pending: [], running: false, composerPhase: 'active', removed: false, openState: 'open', openError: null,
|
||||
hasMore: false, loadingOlder: false, promptError: null, intent: null, pendingPrompt: null, lastAgentError: null,
|
||||
}
|
||||
|
||||
@@ -18,7 +18,7 @@ const SID = 's1' as SessionId
|
||||
|
||||
function snapshotBase(): ConversationSnapshot {
|
||||
return {
|
||||
sessionId: SID, nodes: [], foldDegraded: false, partial: null, runningCalls: [],
|
||||
sessionId: SID, nodes: [], foldDegraded: false, partial: null, runningCalls: [], codeDispatches: new Map(),
|
||||
pending: [], running: false, composerPhase: 'active', removed: false, openState: 'open', openError: null,
|
||||
hasMore: false, loadingOlder: false, promptError: null, intent: null, pendingPrompt: null, lastAgentError: null,
|
||||
} as ConversationSnapshot
|
||||
@@ -84,4 +84,40 @@ describe('render branch tails', () => {
|
||||
expect(view.getByText('详情')).toBeTruthy()
|
||||
expect(view.getByText('该调用不在当前窗口内')).toBeTruthy()
|
||||
})
|
||||
|
||||
it('DetailsPanel resolves a run_code sub-callId to its full logged args and output', () => {
|
||||
localStorage.clear()
|
||||
const snap = snapshotBase()
|
||||
const longText = 'x'.repeat(1_000)
|
||||
snap.codeDispatches = new Map([['p1', [{
|
||||
kind: 'tool-result', seq: 8, time: 8_000, callId: 'p1:code:1',
|
||||
call: { name: 'read', argsRaw: '{"path":"notes/demo.txt"}' },
|
||||
callTime: 8_000,
|
||||
content: [{ type: 'text', text: longText }], isError: false, callView: null, resultView: null,
|
||||
}]]])
|
||||
const chat = createChatStore().create()
|
||||
chat.actions.select({ turnSeq: 8, callId: 'p1:code:1', toolName: 'read' } satisfies SelectionTarget)
|
||||
const emptyList = createSnapshotStore<SessionListState>(
|
||||
{ ids: [], byId: {}, current: undefined, intent: undefined, phase: 'ready' })
|
||||
const emptyWorkspaces = createSnapshotStore<WorkspaceListState>({
|
||||
items: [], intent: undefined, state: 'idle', phase: 'ready', error: null,
|
||||
baselinesReady: true, recentWorkspaceId: undefined,
|
||||
})
|
||||
const view = render(
|
||||
<DetailsPanel
|
||||
sessionId={SID}
|
||||
useSession={bindSnapshotSelector({ getSnapshot: () => snap, subscribe: () => () => {} }) as unknown as UseSession<ConversationSnapshot>}
|
||||
useSessions={bindSnapshotSelector(emptyList)}
|
||||
useWorkspaces={bindSnapshotSelector(emptyWorkspaces)}
|
||||
useStore={bindSnapshotSelector(chat)}
|
||||
actions={chat.actions}
|
||||
closeDetails={vi.fn()}
|
||||
/>,
|
||||
)
|
||||
// Sub-call material: the sub-tool name titles the panel, args pretty-print,
|
||||
// and the COMPLETE logged output renders (no truncation anywhere).
|
||||
expect(view.getByText('read')).toBeTruthy()
|
||||
expect(view.getByText(/notes\/demo\.txt/)).toBeTruthy()
|
||||
expect(view.getByText(longText)).toBeTruthy()
|
||||
})
|
||||
})
|
||||
|
||||
@@ -119,7 +119,7 @@ function conversationSnapshot(
|
||||
pendingPrompt: ConversationSnapshot['pendingPrompt'] = null,
|
||||
): ConversationSnapshot {
|
||||
return {
|
||||
sessionId: SID, nodes: [], foldDegraded: false, partial: null, runningCalls: [],
|
||||
sessionId: SID, nodes: [], foldDegraded: false, partial: null, runningCalls: [], codeDispatches: new Map(),
|
||||
pending: [], running: false, composerPhase, removed: false, openState: 'open', openError: null,
|
||||
hasMore: false, loadingOlder: false, promptError: null, intent: null, pendingPrompt, lastAgentError: null,
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user