test(chat): cover live preparation and durable history replay

This commit is contained in:
imccyu committed 2026-09-23 16:11:43 +08:00
1 parent d3086e6571
commit 74511b3dcb
11 files changed
+457 -5

No files matched your search

@@ -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 .agents/notes/implemented/architecture/2026-08-09-client-conversation-node-assembly.md
2026-08-09-client-conversation-node-assembly.md: bb1d68cc045710d46a44ffb295c2307628338521
2026-08-09-client-conversation-node-assembly.zh.md: 6291211d589b3389e7dc9a42d15fe6ac0d9e7ab9
2026-08-09-client-conversation-node-assembly.md: c8773b77fe761c3a566e02f0bd6aeb18f26e0a89
2026-08-09-client-conversation-node-assembly.zh.md: 076ad35178996e01eae26b3909feae8d967414c1
@@ -14,6 +14,8 @@ Business events also use different correlation models. Tool has call IDs, Assist
## Decision
The [three-stage Tool lifecycle](2026-09-22-tool-call-three-phases.md) owns transient initialization, repeated start matches for one ID, and tool preparation; this record's other node-assembly responsibilities remain applicable.
Client Runtime provides a target-neutral Conversation Node assembly engine. Business plugins register Event Definitions, and view plugins register per-Session View Builders. `ui-conversation` registers the first built-in Definitions and the `chat` builder; Session only submits the current contiguous `SessionEventLikeEntry` window to the engine and publishes its snapshot instead of interpreting individual conversation businesses. The entry's outer discriminator distinguishes standard and packed records, while both carry an aligned inner `SessionEventLike` for Definition dispatch.
This Note retains the derivation, business-by-business validation, responsibilities, algorithms, and trade-offs that remain relevant after implementation.
@@ -14,6 +14,8 @@ Client Session 既维护传输窗口、连接状态和待处理交互,也在
## 决策
start 的瞬态创建入口、同 ID 多次 start 及工具准备阶段以[工具调用三阶段](2026-09-22-tool-call-three-phases.zh.md)为准;本记录的其他节点组装职责继续适用。
Client Runtime 提供 target-neutral 的 Conversation Node 组装引擎,业务插件注册 Event Definition,视图插件注册 per-Session View Builder。`ui-conversation` 注册第一批内建 Definition 和 `chat` builder;Session 只负责把当前连续 `SessionEventLikeEntry` window 送入引擎并发布它的 snapshot,且不解释具体 conversation 业务。entry 的外层 discriminator 区分标准与 packed record,两者都携带字段对齐的内部 `SessionEventLike`,供 Definition dispatch。
本 Note 保留实现后仍有价值的方案推导、逐业务适配、职责、算法和取舍。
@@ -0,0 +1,6 @@
# Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each
# 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 .agents/notes/implemented/architecture/2026-09-22-tool-call-three-phases.md
2026-09-22-tool-call-three-phases.md: 6b1943e9f30903d9a7f2c9b8cf87dceeae6cae92
2026-09-22-tool-call-three-phases.zh.md: a03d20dac236af53b664bb4d2e7b505ef61d6102
@@ -0,0 +1,67 @@
# Agent Note: Tool calls own preparation, dispatch, and results by callId
Status: implemented
English | [中文](2026-09-22-tool-call-three-phases.zh.md)
## Problem
The model often provides a tool name and call ID while it is still generating arguments, but `tool/call` arrives only after the entire Assistant stream ends. When one response contains several calls, an earlier call waits for later calls even after its own arguments are complete. File content is part of the arguments to `write`, so this delay can last several seconds.
Creating tool nodes only at `tool/call` presents that interval as no tool activity. Putting a preparing call in an Assistant part instead requires the Group to switch references between Assistant and Tool nodes. Preparation and dispatch belong to one tool lifecycle and do not require two cooperating nodes.
## Decision
### One Tool owns three stages
The [Tool Definition](../../../../packages/client/ui-chat/src/client/conversation-nodes/tool.ts) groups calls by callId and supplies explicit stage data to tool components.
| Stage | Creation or update evidence | Available data and presentation |
|---|---|---|
| `preparing` | A live delta with a call ID and tool name | Call identity, name, and time; no argument body, and one non-expandable row. |
| `start` | `tool/call` | Complete arguments; enables the tool's existing call presentation. |
| `result` | `tool/result` | The result and any complete arguments paired within the loaded window. |
An argument block ending does not trigger dispatch presentation. Showing preparation changes neither model requests, tool dispatch timing, Session events, nor the persisted format. An empty argument string does not stand in for a dispatched call.
### Start marks an initialization candidate
The [Conversation Definition](../../../../packages/client/ui-conversation/src/client/contract/conversation.ts) retains its match/start/update interface and existing roles. Durable events and transient deltas may both match as start. The earliest start in the current event sequence initializes State; every later Match, including another start for the same ID, goes through update.
A named tool delta and `tool/call` are both initialization candidates. A live delta can create preparation; a historical `tool/call` creates the dispatched stage directly. Matching still reads only the current event, without Context lookup, additional roles, registries, or separate caches.
When stream settlement removes temporary deltas, the Assembler selects the start from the remaining Matches, refreshes predecessor indexes, and recomputes State. A published preparation node hides when no call evidence remains; a later dispatch retains the same callId. Undispatched calls do not receive synthetic execution results.
### Converge on final state without replaying preparation
Historical pages do not expand settled Assistant messages into live deltas. Reconnection restores only an attempt that is still live. Live presentation may follow preparing → start → result, while history follows start → result directly. Equal durable events produce equal final state; intermediate presentation and preparation-group identity need not match.
Groups include Tool nodes from preparation onward and classify and count them by tool name. AssistantNodeView does not search tool blocks, and Groups neither encode tool groupPart values nor take over tool lifecycles. Grouping retains the [node-input grouping decision](2026-09-21-conversation-build-groups.md); the Builder does not interpret preparation business rules.
### Shared rows reuse their existing presentation
[ToolRow](../../../../packages/client/ui-tool/src/client/tool/components/ToolRow.tsx) reuses the existing icon, title, and row component across all stages. The shared row model returns empty presentation fields for preparing, and the shared argument parser returns null without parsing JSON. ToolRow prevents preparation rows from expanding.
Read, read_image, write/edit, search, web, todo, question, details, and the generic fallback use this path. Custom rows such as Bash, Skill, Present, and Cordis keep their own preparation branches. No automatic/custom registration declaration or second tool-classification list in ToolTree is introduced.
### Relationship to existing records
This decision replaces the [business-node assembly](2026-08-09-client-conversation-node-assembly.md) restriction that starts must be durable and a Context may receive only one start. That record's stable IDs, business Definitions, Contexts, Locations, predecessor dependencies, and target Builder responsibilities remain applicable.
## Alternatives considered
**Create nodes only at `tool/call`.** This cannot show a known tool identity while arguments are being generated and leaves a visible wait in multi-call responses.
**Transfer presentation through Assistant parts and Groups.** A tool would need two presentation sources, additional deduplication, and identity transfer. Lifecycle ownership becomes distributed, and renderers must recover Assistant content from part identifiers.
**Persist or replay preparing.** Preparation describes current generation; durable calls and results already suffice to reconstruct the final presentation. Reproducing the same intermediate process adds unnecessary state and event interpretation.
**Add a preparation registration mechanism.** Generic tools already share ToolRow and argument models, including reusable icons and titles. Additional declarations or configuration for this stage would maintain another representation of tool presentation.
## Consequences
- A tool may appear before its arguments are complete, but preparation neither implies execution nor exposes interactions requiring arguments.
- Multiple starts with one ID belong to one lifecycle. Independent calls must use distinct IDs; a second-start error no longer detects identity reuse.
- Shared rows do not replace the whole row component when preparation becomes dispatch. Custom rows may still switch their internal components by stage.
- Partial JSON parsing and `useToolCallDelta` are not provided; argument-driven early presentation is outside this implementation.
- Assembly tests separately cover live preparation, withdrawal, active-stream reconstruction, durable history, and paged convergence. Component tests cover argument-free presentation and stage changes; a recorded session covers browser preparation and reload results.
@@ -0,0 +1,67 @@
# Agent Note: 工具调用按 callId 管理准备、派发与结果
Status: implemented
[English](2026-09-22-tool-call-three-phases.md) | 中文
## 问题
模型生成工具参数时,工具名和调用 ID 往往已经可用,但 `tool/call` 要等整条 Assistant 流结束后才到达。一次响应包含多个调用时,前一个调用即使已经生成完整参数,仍要等待后续调用。文件正文也属于 `write` 参数,这段等待可能持续数秒。
只从 `tool/call` 创建工具节点,会把这段时间表现为没有工具活动;把准备中的调用放在 Assistant 片段里,又需要 Group 在 Assistant 与正式 Tool 节点之间切换引用。准备态和正式调用属于同一个工具生命周期,不应由两个节点配合表达。
## 决策
### 同一个 Tool 拥有三个阶段
[Tool Definition](../../../../packages/client/ui-chat/src/client/conversation-nodes/tool.ts) 按 callId 聚合调用,向工具组件提供明确的阶段数据。
| 阶段 | 创建或更新依据 | 可用数据与展示 |
|---|---|---|
| `preparing` | 带调用 ID 和工具名的实时 delta | 调用身份、名称和时间;不提供参数正文,只显示不可展开的一行。 |
| `start` | `tool/call` | 完整参数;启用工具原有的调用展示。 |
| `result` | `tool/result` | 调用结果,以及当前窗口内可配对的完整参数。 |
参数块结束不触发派发阶段。展示准备态不改变模型请求、工具派发时机、Session 事件或持久化格式,也不把空参数字符串伪装成正式调用。
### start 表示可创建入口
沿用 [Conversation Definition](../../../../packages/client/ui-conversation/src/client/contract/conversation.ts) 的 `match/start/update` 接口和既有角色。持久事件与瞬态 delta 都可以匹配为 start;当前事件序列中最早的 start 初始化 State,后续 Match,包括同 ID 的其他 start,都交给 update。
Tool 的具名 delta 和 `tool/call` 都是创建入口。实时先收到 delta,就从 preparing 创建;历史先收到 `tool/call`,就从 start 创建。匹配函数仍然只读取当前事件,不查询 Context,不增加角色、注册表或旁路缓存。
流结束移除临时 delta 后,Assembler 从剩余 Match 重新选择 start,刷新前序索引并重算 State。没有剩余调用证据时,已发布的准备节点隐藏;后续正式调用仍使用相同 callId。未派发的调用不合成执行结果。
### 最终状态收敛,不重现准备过程
历史分页不把已结束 Assistant 消息展开为实时 delta。重连只恢复仍在生成的活跃流。实时展示可以经历 preparing → start → result,历史回放可以直接经历 start → result;相同持久事件得到相同最终状态即可,不要求中间过程或准备态分组身份一致。
Group 从准备阶段起就聚合 Tool 节点,按工具名分类和计数。AssistantNodeView 不查找工具块,Group 不编码工具 groupPart,也不接替工具生命周期。分组仍遵循[节点输入的分组决策](2026-09-21-conversation-build-groups.zh.md),Builder 不解释准备态业务。
### 通用行复用现有外壳
[ToolRow](../../../../packages/client/ui-tool/src/client/tool/components/ToolRow.tsx) 在三个阶段复用既有图标、标题和行组件。公共行模型为 preparing 返回空展示字段,共享参数解析入口直接返回 null,不解析 JSON;ToolRow 禁止准备行展开。
read、read_image、write/edit、search、web、todo、question、details 和通用回退使用这条路径。Bash、Skill、Present、Cordis 等自定义外壳保留自己的准备态分支。不增加自动/自管注册声明,不在 ToolTree 维护另一份工具分类名单。
### 与已有记录的关系
本记录替代[业务节点组装](2026-08-09-client-conversation-node-assembly.zh.md)中“start 只能是持久事件,同一 Context 只能收到一次 start”的限制。该记录的稳定 ID、业务 Definition、Context、Location、前序依赖和目标 Builder 职责继续适用。
## 考虑过的替代方案
**只在 `tool/call` 创建节点。** 无法展示参数生成期间已经确定的工具身份,多调用响应仍有明显空等。
**通过 Assistant 片段和 Group 转接。** 工具需要两种展示来源、额外去重和身份衔接;生命周期归属分散,渲染器还要根据片段标识反查 Assistant 内容。
**保存或回放 preparing。** 准备态只描述当前生成过程,持久调用和结果已经足以重建最终展示;恢复相同中间过程会增加不必要的状态和事件解释。
**新增准备态注册机制。** 通用工具已经共享 ToolRow 和参数模型,已有图标与标题可以直接复用。仅为这一阶段增加声明或配置,会多维护一套工具展示信息。
## 影响
- 工具可以在参数完整前出现,但准备展示不表示工具已经执行,也不暴露依赖参数的交互。
- 同一 ID 的多个 start 被视为同一个生命周期;业务必须为独立调用提供不同 ID,不能再依赖第二个 start 报错检测身份复用。
- 通用行不因准备/派发分支替换整行组件;自定义外壳仍可按阶段切换内部组件。
- 不提供部分 JSON 解析或 `useToolCallDelta`,参数驱动的提前展示不属于本次实现。
- 组装测试分别覆盖实时准备态、撤回、活跃流重建、持久历史与分页收敛;组件测试检查无参数展示和阶段切换,录制会话覆盖浏览器中的准备态与重载结果。
+36 -3
View File
@@ -17,6 +17,7 @@ const FIXTURE = fileURLToPath(new URL('../../../snapshots/web/ptc-round/session.
const UI_EXPECTED = fileURLToPath(new URL('../../../snapshots/web/ptc-round/ui.expected.md', import.meta.url))
const TRAJECTORY_EXPECTED = fileURLToPath(new URL('../../../snapshots/web/ptc-round/trajectory.expected.md', import.meta.url))
const CODE_EXPECTED = fileURLToPath(new URL('../../../snapshots/web/ptc-round/code.expected.md', import.meta.url))
const PREPARING_EXPECTED = fileURLToPath(new URL('../../../snapshots/web/ptc-round/preparing.expected.md', import.meta.url))
const MODE = webSnapshotMode()
// Elicits the successful and failed sub-rows this scenario asserts.
@@ -29,6 +30,7 @@ describe('web e2e: PTC mode round renders nested sub-calls', () => {
let page: Page
let tripwire: ReturnType<typeof watchConsole>
const sessionEvents: SessionEvent[] = []
let releasePreparation: (() => void) | undefined
beforeAll(async () => {
scaffold = await launchWebScaffold({
@@ -46,6 +48,7 @@ describe('web e2e: PTC mode round renders nested sub-calls', () => {
}, 120_000)
afterAll(async () => {
releasePreparation?.()
await browser?.close()
await scaffold?.close()
})
@@ -58,9 +61,38 @@ describe('web e2e: PTC mode round renders nested sub-calls', () => {
const input = page.locator('[data-composer-input]').first()
await input.waitFor({ timeout: 10_000 })
const settled = scaffold.whenTurnSettled()
await input.fill(PROMPT)
await input.press('Enter')
const sessionId = await settled
const gate = Promise.withResolvers<undefined>()
const dispose = scaffold.ctx.on('llm/stream', async function* (_options, next) {
let held = false
for await (const chunk of next()) {
yield chunk
if (!held && chunk.type === 'tool-call-delta' && chunk.name) {
held = true
await gate.promise
}
}
}, { prepend: true })
releasePreparation = () => { gate.resolve(undefined); dispose() }
const observePreparation = async () => {
try {
await input.fill(PROMPT)
await input.press('Enter')
const row = page.locator('[data-chat-call-id] [data-state="preparing"]').first()
await row.waitFor({ state: 'attached', timeout: 30_000 })
await expandOwningTurnProcess(page, row)
await row.waitFor({ state: 'visible' })
await page.getByRole('button', { name: 'Preparing to run code', exact: true }).waitFor()
expect(sessionEvents.some(event => event.type === 'tool/call')).toBe(false)
expect(await row.getByRole('button').count()).toBe(0)
expect(await row.locator('pre').count()).toBe(0)
const group = row.locator('xpath=ancestor::*[@data-chat-group-key][1]')
await compareOrRefreshGolden(PREPARING_EXPECTED, await group.ariaSnapshot(), MODE)
} finally {
releasePreparation?.()
}
}
const [sessionId] = await Promise.all([settled, observePreparation()])
expect(await page.locator('[data-state="preparing"]').count()).toBe(0)
if (MODE === 'record') {
await recordFixture(scaffold, sessionId, FIXTURE)
}
@@ -118,6 +150,7 @@ describe('web e2e: PTC mode round renders nested sub-calls', () => {
await page.reload({ waitUntil: 'load' })
acknowledgeReloadConnectionLoss(tripwire, warningStart)
await page.getByText('DONE', { exact: true }).waitFor({ timeout: 15_000 })
expect(await page.locator('[data-state="preparing"]').count()).toBe(0)
}
const nest = page.locator('[data-subcalls]').first()
const frame = page.locator('[style*="grid-template-columns"]').first()
@@ -0,0 +1,162 @@
import { describe, expect, it } from 'vitest'
import { createAssistantMessage, createToolResultMessage, LlmAttemptId, ToolCallId } from '@deepseek-ai/dsh-llm'
import { AssistantStreamAccumulator } from '@deepseek-ai/dsh-llm/assistant-stream'
import type { StreamChunk } from '@deepseek-ai/dsh-llm'
import { SessionSeq, type SessionEvent } from '@deepseek-ai/dsh-session/types'
import type { SessionAssistantSettlementEntry, SessionEventLikeEntry, SessionLiveEventEntry } from '@deepseek-ai/dsh-api-session-controller/client'
import { ConversationNodeAssembler } from '@deepseek-ai/dsh-client-ui-conversation/client'
import type { ChatNode, ChatSnapshot } from '@deepseek-ai/dsh-client-ui-chat/client'
import { assistantDefinition } from '../src/client/conversation-nodes/assistant.ts'
import { toolDefinition } from '../src/client/conversation-nodes/tool.ts'
import { chatViewDefinition } from '../src/client/conversation-nodes/chat-snapshot-builder.ts'
import { processGroupDefinition } from '../src/client/conversation-nodes/process-groups.ts'
const attemptId = LlmAttemptId('preparing-test')
const first = ToolCallId('write-one')
const second = ToolCallId('write-two')
const args = '{"file_path":"hello.txt","content":"hello"}'
function entry(event: SessionEvent): SessionLiveEventEntry {
return { type: 'event', event }
}
const opening = [
entry({ type: 'turn/start', seq: SessionSeq(1), time: 1, data: { turn: 1 } }),
entry({ type: 'step/start', seq: SessionSeq(2), time: 2, data: { turn: 1, step: 1 } }),
]
function delta(seq: number, id = first, argumentsDelta = '', name: string | null = 'write'): SessionEventLikeEntry {
return {
type: 'transient',
event: {
type: 'assistant/live-chunk', seq, time: seq,
data: { turn: 1, step: 1, attemptId, chunk: {
type: 'tool-call-delta', index: id === first ? 0 : 1, id, argumentsDelta,
...(name === null ? {} : { name }),
} },
},
}
}
function call(seq: number, callId = first): SessionLiveEventEntry {
return entry({ type: 'tool/call', seq: SessionSeq(seq), time: seq,
data: { turn: 1, step: 1, callId, name: 'write', arguments: args } })
}
function result(seq: number, callId = first): SessionLiveEventEntry {
return entry({ type: 'tool/result', seq: SessionSeq(seq), time: seq, surfaceOp: 'append', data: {
turn: 1, step: 1, message: createToolResultMessage({ callId, content: [{ type: 'text', text: 'Written' }], isError: false }),
} })
}
function settlement(): SessionAssistantSettlementEntry {
const stream = new AssistantStreamAccumulator()
for (const [index, id] of [first, second].entries()) {
const chunk: StreamChunk = { type: 'tool-call-delta', index, id, name: 'write', argumentsDelta: args }
stream.push({ time: 3 + index, chunk })
}
return { type: 'event', event: {
type: 'assistant/message', seq: SessionSeq(5), time: 5, surfaceOp: 'append', data: {
turn: 1, step: 1, stream: [...stream.snapshot()],
message: createAssistantMessage({ source: { provider: 'test', model: 'test' }, content: [first, second].map(id => ({
type: 'tool-call', id, name: 'write', arguments: args,
})) }),
},
} }
}
function harness(entries: readonly SessionEventLikeEntry[] = opening, hasMore = false) {
const assembler = new ConversationNodeAssembler(
{ entries: () => [assistantDefinition, toolDefinition], fallbackEntry: () => undefined },
{ entries: () => [chatViewDefinition] },
{ entries: () => [processGroupDefinition], forTarget: target => target === 'chat' ? processGroupDefinition : undefined },
)
assembler.replaceWindow(entries, hasMore)
assembler.activateTarget('chat')
const tools = () => {
assembler.flush()
const snapshot = assembler.snapshot('chat') as ChatSnapshot
return snapshot.nodes.values().filter((node): node is ChatNode<'tool-call'> => node.kind === 'tool-call' && node.visibility === 'visible')
.sort((left, right) => left.anchorSeq - right.anchorSeq)
}
const phases = () => tools().map(node => [node.id, 'kind' in node.data.root ? 'result' : node.data.root.phase])
const material = () => tools().map(({ key, anchorSeq, data }) => ({ key, anchorSeq, data }))
return { assembler, tools, phases, material }
}
describe('Tool preparation and durable replay', () => {
it('creates two independent live preparations and updates each call under the same key', () => {
const h = harness()
h.assembler.append(delta(2.1, first, '', null))
expect(h.phases()).toEqual([])
h.assembler.append(delta(2.2))
const original = h.tools()[0]!
expect(h.phases()).toEqual([[first, 'preparing']])
expect(original.data.root).not.toHaveProperty('argsRaw')
h.assembler.append(delta(2.3, first, '{"content":"hello', null))
expect(h.tools()[0]?.data.root).toBe(original.data.root)
h.assembler.append(delta(2.4, second))
expect(h.phases()).toEqual([[first, 'preparing'], [second, 'preparing']])
const groups = h.assembler.grouped('chat')!
const group = groups.entries.find(entry => entry.kind === 'group')!
if (group.kind !== 'group') throw new Error('expected a process group')
expect(groups.groupSource(group.key).getSnapshot()?.data.summary).toMatchObject({
counts: [{ kind: 'edit', count: 2 }], preparing: true,
})
h.assembler.append(call(6))
expect(h.tools()[0]?.key).toBe(original.key)
expect(h.phases()).toEqual([[first, 'start'], [second, 'preparing']])
expect(h.tools()[0]?.data.root).toMatchObject({ argsRaw: args })
h.assembler.append(result(7))
h.assembler.append(call(8, second))
h.assembler.append(result(9, second))
expect(h.phases()).toEqual([[first, 'result'], [second, 'result']])
})
it('withdraws transient preparation on settlement and converges with a durable-only replacement', () => {
const h = harness()
h.assembler.append(delta(2.1))
const key = h.tools()[0]!.key
const message = settlement()
h.assembler.settleAssistant(attemptId, message)
expect(h.phases()).toEqual([])
const history = [...opening, message, call(6), result(7), call(8, second), result(9, second)]
for (const event of history.slice(3)) h.assembler.append(event)
expect(h.tools()[0]?.key).toBe(key)
const replay = harness(history)
expect(h.phases()).toEqual([[first, 'result'], [second, 'result']])
expect(h.material()).toEqual(replay.material())
})
it('does not create preparing rows from stored Assistant streams in history or older pages', () => {
const message = settlement()
const history = [...opening, message, call(6), result(7), call(8, second), result(9, second)]
const full = harness(history)
expect(full.phases()).toEqual([[first, 'result'], [second, 'result']])
const paged = harness([result(9, second)], true)
expect(paged.phases()).toEqual([[second, 'result']])
paged.assembler.prepend([call(8, second)], true)
expect(paged.phases()).toEqual([[second, 'result']])
paged.assembler.prepend(history.slice(0, 5), false)
expect(paged.material()).toEqual(full.material())
paged.assembler.replaceWindow([...opening, message], false)
expect(paged.phases()).toEqual([])
paged.assembler.append(call(6))
expect(paged.phases()).toEqual([[first, 'start']])
})
it('abandons one attempt and lets a subsequent live attempt create its own preparation', () => {
const h = harness()
h.assembler.append(delta(2.1))
expect(h.phases()).toEqual([[first, 'preparing']])
h.assembler.settleAssistant(attemptId)
expect(h.phases()).toEqual([])
h.assembler.append(delta(2.2, second))
expect(h.phases()).toEqual([[second, 'preparing']])
h.assembler.replaceWindow([...opening, delta(2.2, second)], false)
expect(h.phases()).toEqual([[second, 'preparing']])
h.assembler.settleAssistant(attemptId)
h.assembler.append(call(8, second))
expect(h.phases()).toEqual([[second, 'start']])
})
})
@@ -0,0 +1,84 @@
// @vitest-environment jsdom
import { afterEach, describe, expect, it, vi } from 'vitest'
import { cleanup, fireEvent, render } from '@testing-library/react'
import { useDisclosure } from '@deepseek-ai/dsh-client-ui-chat/src/client/chat/use-disclosure.ts'
import type { StartedToolCall, ToolResultNode } from '@deepseek-ai/dsh-client-ui-chat/client'
import { makeTranslate } from '@deepseek-ai/dsh-client-test-runtime'
import { en } from '@deepseek-ai/dsh-client-ui-conversation/src/client/locales.ts'
import { en as common } from '@deepseek-ai/dsh-client-locale/src/locales/en.ts'
import { GenericToolCard } from '../src/client/tool/toolviews/GenericToolCard.tsx'
import { FileMutationRow } from '../src/client/tool/toolviews/file-mutation-row.tsx'
import { ReadRow } from '../src/client/tool/toolviews/read-row.tsx'
import { ReadImageRow } from '../src/client/tool/toolviews/read-image-row.tsx'
import { SearchRow } from '../src/client/tool/toolviews/search-row.tsx'
import { WebRow } from '../src/client/tool/toolviews/web-row.tsx'
import { DetailsRow } from '../src/client/tool/toolviews/details-row.tsx'
import { TodoRow } from '../src/client/tool/toolviews/todo-row.tsx'
import { AskQuestionRow } from '../src/client/tool/toolviews/ask-question-row.tsx'
import { BashRow } from '../src/client/tool/toolviews/bash-sample.tsx'
import { parsedToolCall } from '../src/client/tool/models/raw-tool-call.ts'
import { toolRowModel } from '../src/client/tool/models/tool-call-model.ts'
afterEach(cleanup)
type Props = Parameters<typeof TodoRow>[0] & Parameters<typeof ReadImageRow>[0]
function preparation(name: string): Props {
return {
phase: 'preparing', callId: 'call', toolName: name,
block: { phase: 'preparing', callId: 'call', name, turn: 1, step: 1, time: 1, subCalls: [] },
t: makeTranslate(en, common), useDisclosure, openFile: vi.fn(), loadImage: vi.fn(),
useTodoHistory: vi.fn(), useSession: vi.fn(() => false), renderSlot: vi.fn(() => null),
} as Props
}
describe('argument-free tool preparation', () => {
it.each([
['read', ReadRow], ['read_image', ReadImageRow], ['write', FileMutationRow], ['edit', FileMutationRow],
['grep', SearchRow], ['glob', SearchRow], ['web_search', WebRow], ['web_fetch', WebRow],
['todo_write', TodoRow], ['ask_user_question', AskQuestionRow], ['subagent', DetailsRow],
['run_code', GenericToolCard], ['custom_tool', GenericToolCard],
] as const)('%s has a tool-owned prefix without arguments or disclosure', (name, Component) => {
const props = preparation(name)
const view = render(<Component {...props} />)
expect(view.container.querySelector('[data-state="preparing"]')).not.toBeNull()
expect(view.container.querySelector('svg')).not.toBeNull()
expect(view.container.textContent?.trim()).not.toBe('')
expect(view.queryByRole('button')).toBeNull()
expect(view.container.querySelector('pre')).toBeNull()
expect(parsedToolCall(props.block)).toBeNull()
expect(toolRowModel(name, props.block)).toMatchObject({ state: 'preparing', bodyRaw: null, output: null, filePath: undefined })
fireEvent.click(view.container.firstElementChild!)
fireEvent.keyDown(view.container.firstElementChild!, { key: 'Enter' })
expect(view.container.querySelector('[aria-expanded="true"]')).toBeNull()
})
it('keeps the common write row mounted as complete arguments and a result arrive', () => {
const props = preparation('write')
const view = render(<FileMutationRow {...props} />)
const row = view.container.querySelector('[data-tool="write"]')
const started: StartedToolCall = {
phase: 'start', callId: 'call', name: 'write', turn: 1, step: 1, time: 2, subCalls: [],
argsRaw: '{"file_path":"hello.txt","content":"hello"}',
}
view.rerender(<FileMutationRow {...props} phase="start" block={started} />)
expect(view.container.querySelector('[data-tool="write"]')).toBe(row)
expect(view.getByText('hello.txt')).toBeTruthy()
expect(view.container.querySelector('[data-state="running"]')).not.toBeNull()
const result: ToolResultNode = {
kind: 'tool-result', seq: 3, time: 3, callId: 'call', callTime: 2,
call: { name: 'write', argsRaw: started.argsRaw }, content: [], isError: false, subCalls: [],
}
view.rerender(<FileMutationRow {...props} phase="result" block={result} />)
expect(view.container.querySelector('[data-tool="write"]')).toBe(row)
expect(view.container.querySelector('[data-state="ok"]')).not.toBeNull()
})
it('keeps specialized Bash session hooks outside its preparation branch', () => {
const useSessions = vi.fn(() => { throw new Error('Bash call details are unavailable during preparation') })
const view = render(<BashRow {...preparation('bash')} useSessions={useSessions} />)
expect(view.container.querySelector('[data-state="preparing"]')).not.toBeNull()
expect(useSessions).not.toHaveBeenCalled()
expect(view.queryByRole('button')).toBeNull()
})
})
@@ -0,0 +1,3 @@
- button "Preparing to run code" [expanded]
- button "Think"
- text: Preparing tool call Code
+26
View File
@@ -0,0 +1,26 @@
{"type":"session","version":4,"id":"{{session:1}}","createdAt":1788240622920,"cwd":"{{cwd}}","isSeeded":false,"delegationDepth":0,"agentPreset":"ptc"}
{"type":"permission/preset","data":{"preset":"workspace-write"}}
{"type":"sandbox/mode","data":{"mode":"workspace-write"}}
{"type":"approval/policy","data":{"policy":"ask"}}
{"type":"agent/inbox/spliced","data":{"target":"next-turn","start":0,"inserted":[{"content":[{"type":"text","text":"Using ONE run_code program: run bash `echo CODE_ROUND_OK`, then read the file missing.txt catching its error in the program. Return an object with both outcomes. Then reply DONE and stop."}],"source":{"kind":"user","rpcId":"{{rpc:1}}","clientTimeZone":"{{clientTimeZone}}"},"role":"user","id":"{{message:1}}"}]}}
{"type":"turn/start","data":{"turn":1}}
{"type":"agent/inbox/spliced","data":{"target":"next-turn","start":0,"removedCount":1,"inserted":[]}}
{"type":"step/start","data":{"turn":1,"step":1}}
{"type":"system/message","data":{"turn":1,"step":1,"message":{"role":"system","content":[{"type":"text","text":"{{system}}"}],"source":{"kind":"system-prompt"},"id":"{{message:2}}"}},"surfaceOp":"append"}
{"type":"user/message","data":{"content":[{"type":"text","text":"Using ONE run_code program: run bash `echo CODE_ROUND_OK`, then read the file missing.txt catching its error in the program. Return an object with both outcomes. Then reply DONE and stop."}],"source":{"kind":"user","rpcId":"{{rpc:1}}","clientTimeZone":"{{clientTimeZone}}"},"role":"user","id":"{{message:1}}"},"surfaceOp":"append"}
{"type":"user/message","data":{"content":[{"type":"text","text":"Current runtime context. This snapshot supersedes earlier runtime-context snapshots.\n\nCurrent DSH file policy: workspace-write. Any available operation enforced by the DSH file sandbox may modify files under the session workspace: \"{{cwd}}\". Some platform temporary areas may also be writable.\n\nApproval policy: ask. Operations that require approval may ask through the configured answerers; without an available answerer, the request fails closed."}],"source":{"kind":"runtime-context","form":"snapshot","sections":[{"name":"sandbox:policy","text":"Current DSH file policy: workspace-write. Any available operation enforced by the DSH file sandbox may modify files under the session workspace: \"{{cwd}}\". Some platform temporary areas may also be writable."},{"name":"approval:policy","text":"Approval policy: ask. Operations that require approval may ask through the configured answerers; without an available answerer, the request fails closed."}]},"role":"user","id":"{{message:3}}"},"surfaceOp":"append"}
{"type":"request/header","data":{"header":{"config":{"provider":"deepseek-official","model":"deepseek-v4-flash"},"tools":"{{tools}}"},"reason":"initial"}}
{"type":"request/context","data":{"provider":"deepseek-official","model":"deepseek-v4-flash","contextWindow":128000}}
{"type":"session/title","data":{"title":"Using ONE run_code program: run","messageSeqs":[8],"source":{"kind":"fallback"}}}
{"type":"assistant/message","data":{"turn":1,"step":1,"message":{"role":"assistant","content":[{"type":"reasoning","text":"The user wants me to write a single `run_code` program that:\n1. Runs bash to echo \"CODE_ROUND_OK\"\n2. Tries to read a file \"missing.txt\" and catches the error\n3. Returns an object with both outcomes\n4. They also want me to reply \"DONE\" and stop after\n\nLet me write this program."},{"type":"tool-call","id":"call_00_6VNoF1gDSerTBKoCfYSH3765","name":"run_code","arguments":"{\"description\": \"Run bash echo and catch missing file read\", \"code\": \"\\nconst bashResult = await tools.bash({\\n command: \\\"echo CODE_ROUND_OK\\\",\\n description: \\\"Echo CODE_ROUND_OK\\\"\\n});\\n\\nlet readError = null;\\ntry {\\n await tools.read({ file_path: \\\"missing.txt\\\" });\\n} catch (e) {\\n readError = {\\n toolName: e.toolName,\\n message: e.message\\n };\\n}\\n\\nreturn { bash: bashResult.stdout.text.trim(), readError };\\n\"}"}],"source":{"kind":"model","provider":"deepseek-official","model":"deepseek-v4-flash"},"id":"{{message:4}}"},"usage":{"inputTokens":8082,"outputTokens":235,"cacheReadTokens":384,"reasoningTokens":75},"stream":[{"type":"chunk","time":1788699493302,"chunk":{"type":"block-start","index":0,"blockType":"reasoning"}},{"type":"reasoning-chunks","time0":1788699493318,"index":0,"dt":[16,16,16,16,18,16,17,16,15,16,16,16,16,15,16,16,17,17,16,16,17,16,16,16,16,16,16,16,17,16,16,17,16,16,17,16,17,16,16,16,16,16,16,16,15,16,16,16,17,16,16,17,16,16,16,16,16,15,16,16,16,17,16,16,16,15,16,16,15,16,16,16,17,16],"texts":["The"," user"," wants"," me"," to"," write"," a"," single"," `","run","_code","`"," program"," that",":\n","1","."," Runs"," bash"," to"," echo"," \"","CODE","_RO","UND","_OK","\"\n","2","."," T","ries"," to"," read"," a"," file"," \"","missing",".txt","\""," and"," catches"," the"," error","\n","3","."," Returns"," an"," object"," with"," both"," outcomes","\n","4","."," They"," also"," want"," me"," to"," reply"," \"","D","ONE","\""," and"," stop"," after","\n\n","Let"," me"," write"," this"," program","."]},{"type":"chunk","time":1788699494526,"chunk":{"type":"block-start","index":1,"blockType":"tool-call"}},{"type":"tool-call-chunks","time0":1788699494542,"index":1,"dt":[17,16,16,16,16,16,16,16,16,16,17,16,15,17,16,16,16,16,16,16,16,16,16,15,18,16,16,17,16,15,16,16,16,16,16,17,16,17,16,18,16,16,16,15,17,16,16,16,15,17,16,16,15,17,15,16,16,16,17,16,16,17,16,16,16,16,16,17,16,16,17,18,17,17,15,16,16,16,17,16,16,16,17,16,16,16,16,18,15,16,16,17,18,18,16,16,17,16,17,16,16,16,16,16,16,16,16,16,17,17,16,15,16,15,17,16,15,15],"id":"call_00_6VNoF1gDSerTBKoCfYSH3765","name":"run_code","args":["","{","\"","description","\"",": ","\"","Run"," bash"," echo"," and"," catch"," missing"," file"," read","\"",", ","\"","code","\"",": ","\"","\\n","const"," bash","Result"," ="," await"," tools",".b","ash","({\\n"," "," command",":"," \\\"","echo"," CODE","_RO","UND","_OK","\\\",\\n"," "," description",":"," \\\"","E","cho"," CODE","_RO","UND","_OK","\\\"\\n","});\\n\\n","let"," read","Error"," ="," null",";\\n","try"," {\\n"," "," await"," tools",".read","({"," file","_path",":"," \\\"","missing",".txt","\\\""," });\\n","}"," catch"," (","e",")"," {\\n"," "," read","Error"," ="," {\\n"," "," tool","Name",":"," e",".t","ool","Name",",\\n"," "," message",":"," e",".message","\\n"," "," };\\n","}\\n\\n","return"," {"," bash",":"," bash","Result",".stdout",".text",".trim","(),"," read","Error"," };\\n","\"","}"]},{"type":"chunk","time":1788699496468,"chunk":{"type":"block-end","index":0,"block":{"type":"reasoning","text":"The user wants me to write a single `run_code` program that:\n1. Runs bash to echo \"CODE_ROUND_OK\"\n2. Tries to read a file \"missing.txt\" and catches the error\n3. Returns an object with both outcomes\n4. They also want me to reply \"DONE\" and stop after\n\nLet me write this program."}}},{"type":"chunk","time":1788699496484,"chunk":{"type":"block-end","index":1,"block":{"type":"tool-call","id":"call_00_6VNoF1gDSerTBKoCfYSH3765","name":"run_code","arguments":"{\"description\": \"Run bash echo and catch missing file read\", \"code\": \"\\nconst bashResult = await tools.bash({\\n command: \\\"echo CODE_ROUND_OK\\\",\\n description: \\\"Echo CODE_ROUND_OK\\\"\\n});\\n\\nlet readError = null;\\ntry {\\n await tools.read({ file_path: \\\"missing.txt\\\" });\\n} catch (e) {\\n readError = {\\n toolName: e.toolName,\\n message: e.message\\n };\\n}\\n\\nreturn { bash: bashResult.stdout.text.trim(), readError };\\n\"}"}}},{"type":"chunk","time":1788699496501,"chunk":{"type":"usage","usage":{"inputTokens":8082,"outputTokens":235,"cacheReadTokens":384,"reasoningTokens":75}}},{"type":"chunk","time":1788699496517,"chunk":{"type":"finish","reason":{"kind":"tool-calls"}}}]},"surfaceOp":"append"}
{"type":"tool/call","data":{"turn":1,"step":1,"callId":"call_00_6VNoF1gDSerTBKoCfYSH3765","name":"run_code","arguments":"{\"description\": \"Run bash echo and catch missing file read\", \"code\": \"\\nconst bashResult = await tools.bash({\\n command: \\\"echo CODE_ROUND_OK\\\",\\n description: \\\"Echo CODE_ROUND_OK\\\"\\n});\\n\\nlet readError = null;\\ntry {\\n await tools.read({ file_path: \\\"missing.txt\\\" });\\n} catch (e) {\\n readError = {\\n toolName: e.toolName,\\n message: e.message\\n };\\n}\\n\\nreturn { bash: bashResult.stdout.text.trim(), readError };\\n\"}"}}
{"type":"tool/ptc-dispatch-start","data":{"rootCallId":"call_00_6VNoF1gDSerTBKoCfYSH3765","parentCallId":"call_00_6VNoF1gDSerTBKoCfYSH3765","subCallId":"call_00_6VNoF1gDSerTBKoCfYSH3765:ptc:1","name":"bash","arguments":{"command":"echo CODE_ROUND_OK","description":"Echo CODE_ROUND_OK"}}}
{"type":"tool/ptc-dispatch","data":{"rootCallId":"call_00_6VNoF1gDSerTBKoCfYSH3765","parentCallId":"call_00_6VNoF1gDSerTBKoCfYSH3765","subCallId":"call_00_6VNoF1gDSerTBKoCfYSH3765:ptc:1","name":"bash","arguments":{"command":"echo CODE_ROUND_OK","description":"Echo CODE_ROUND_OK"},"isError":false,"content":[{"type":"text","text":"CODE_ROUND_OK\n"}]}}
{"type":"tool/ptc-dispatch-start","data":{"rootCallId":"call_00_6VNoF1gDSerTBKoCfYSH3765","parentCallId":"call_00_6VNoF1gDSerTBKoCfYSH3765","subCallId":"call_00_6VNoF1gDSerTBKoCfYSH3765:ptc:2","name":"read","arguments":{"file_path":"missing.txt"}}}
{"type":"tool/ptc-dispatch","data":{"rootCallId":"call_00_6VNoF1gDSerTBKoCfYSH3765","parentCallId":"call_00_6VNoF1gDSerTBKoCfYSH3765","subCallId":"call_00_6VNoF1gDSerTBKoCfYSH3765:ptc:2","name":"read","arguments":{"file_path":"missing.txt"},"isError":true,"error":{"name":"FsError","code":"FS_NOT_FOUND"},"content":[{"type":"text","text":"Error: cannot read \"{{cwd}}/missing.txt\": not found"}]}}
{"type":"tool/result","data":{"turn":1,"step":1,"message":{"role":"tool","source":{"kind":"tool","callId":"call_00_6VNoF1gDSerTBKoCfYSH3765"},"toolCallId":"call_00_6VNoF1gDSerTBKoCfYSH3765","content":[{"type":"text","text":"{\n \"bash\": \"CODE_ROUND_OK\",\n \"readError\": {\n \"toolName\": \"read\",\n \"message\": \"cannot read \\\"{{cwd}}/missing.txt\\\": not found\"\n }\n}"}],"isError":false,"id":"{{message:5}}"}},"sourceEventSeqs":[14],"surfaceOp":"append"}
{"type":"step/end","data":{"turn":1,"step":1}}
{"type":"step/start","data":{"turn":1,"step":2}}
{"type":"assistant/message","data":{"turn":1,"step":2,"message":{"role":"assistant","content":[{"type":"reasoning","text":"The program ran successfully. Let me now reply DONE as instructed."},{"type":"text","text":"DONE"}],"source":{"kind":"model","provider":"deepseek-official","model":"deepseek-v4-flash"},"id":"{{message:6}}"},"usage":{"inputTokens":196,"outputTokens":17,"cacheReadTokens":8576,"reasoningTokens":14},"stream":[{"type":"chunk","time":1788699496621,"chunk":{"type":"block-start","index":0,"blockType":"reasoning"}},{"type":"reasoning-chunks","time0":1788699496637,"index":0,"dt":[17,16,15,16,18,16,16,16,16,17,16,16,16],"texts":["The"," program"," ran"," successfully","."," Let"," me"," now"," reply"," D","ONE"," as"," instructed","."]},{"type":"chunk","time":1788699496863,"chunk":{"type":"block-start","index":1,"blockType":"text"}},{"type":"text-chunks","time0":1788699496879,"index":1,"dt":[16],"texts":["D","ONE"]},{"type":"chunk","time":1788699496911,"chunk":{"type":"block-end","index":0,"block":{"type":"reasoning","text":"The program ran successfully. Let me now reply DONE as instructed."}}},{"type":"chunk","time":1788699496927,"chunk":{"type":"block-end","index":1,"block":{"type":"text","text":"DONE"}}},{"type":"chunk","time":1788699496943,"chunk":{"type":"usage","usage":{"inputTokens":196,"outputTokens":17,"cacheReadTokens":8576,"reasoningTokens":14}}},{"type":"chunk","time":1788699496959,"chunk":{"type":"finish","reason":{"kind":"stop"}}}]},"surfaceOp":"append"}
{"type":"step/end","data":{"turn":1,"step":2}}
{"type":"turn/end","data":{"turn":1,"reason":{"kind":"completed"}}}