Merge remote-tracking branch 'origin/master' into HEAD

This commit is contained in:
Huanqi Cao
2026-08-08 20:15:08 +08:00
114 changed files with 1837 additions and 569 deletions

View File

@@ -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/runtime/README.md
README.md: bb85d3c7b45eb0d82d789a9133bba787f1d6c5e9
README.zh.md: 18aedbb487a490c85ce68ccc460fd82218157843
README.md: a9b604974595b1b7856f74b72d36093491ec1bd1
README.zh.md: 6eee14bdbe7a9fb86b0a12355e7d0a45cd500774

View File

@@ -44,9 +44,9 @@ Because the projection is log-ordered, the node array is seq-monotonic by constr
`SessionHistoryInspection.requests` is one chronological, purpose-discriminated provider-request stream. Assistant requests always carry their numeric `turn` and `step`; compaction requests carry `step: 0` and a `turn` owner that may be `null`. That null owner means a manual compaction ran standalone between turns, not that it belongs to either adjacent turn. A `session/end-seed` boundary closes an unmatched compaction request as an error at the boundary time with `Compaction was interrupted before completion.`; a later start projects as an independent request instead of overwriting the orphan.
## Code Mode sub-dispatch index
## Code Mode child-call tree
`ConversationSnapshot.codeDispatches` groups a `run_code` call's sub-dispatches under their parent callId, in start order, using the native call-block shapes: a `tool/code-dispatch-start` event lands the `RunningToolCall` form (rows derive the running ring from the shape) and its `tool/code-dispatch` settlement replaces it in place with the `ToolResultNode` form, `callTime` carrying the paired start's time. A settle whose start fell outside the replay window appends directly with `callTime: null` (duration unknown — never a fabricated zero). Live mux frames and history replay build the identical index; sub-calls never join the transcript `nodes` flow; per-parent array and map references are memo-stable across unrelated snapshot swaps.
Every `ToolCallBlock` recursively owns its children through `subCalls`, in start order. Runtime's `ToolCallTree` privately maintains the parent-callId-to-children index: a `tool/code-dispatch-start` event lands as a `RunningToolCall`, and the matching `tool/code-dispatch` settlement replaces it in place with a `ToolResultNode` whose `callTime` comes from the paired start. When the start fell outside the replay window, the settlement appends directly with `callTime: null`; Runtime never fabricates a zero duration. Live mux frames and history replay share this fold and tree projection, and child calls never become independent roots in transcript `nodes`. A child update copies only its ancestor path to the owning root; unchanged siblings and other roots retain object identity. Wire or history edges that would introduce a cycle or exceed the fixed 256-call recursive-depth safety limit are consumed without mutating the tree, so the rest of the session remains renderable.
## Session title projection

View File

@@ -44,9 +44,9 @@ SlotsService 分别为 renderer 提供 `useSessions` 与 `useWorkspaces` 的裸
`SessionHistoryInspection.requests` 是一条按时间顺序排列、以用途为判别字段的提供方请求流。助手请求始终携带数值型 `turn``step`;压缩请求携带 `step: 0`,其 `turn` 所有者可以是 `null`。这个 null 所有者表示手动压缩独立运行在两个轮次之间,并不表示它属于任一相邻轮次。`session/end-seed` 边界会在边界时刻将未匹配的压缩请求以错误状态结束,错误固定为 `Compaction was interrupted before completion.`;后续 start 会投影为独立请求,而不会覆盖这项遗留的未匹配请求。
## Code Mode 子调用索引
## Code Mode 子调用
`ConversationSnapshot.codeDispatches` 按父调用的 callId 和启动顺序,用原生调用块形状组织一个 `run_code` 调用的子调用`tool/code-dispatch-start` 事件落成 `RunningToolCall` 形状(行组件从该形状推导运行中的转圈状态),其 `tool/code-dispatch` 完结事件原位替换为 `ToolResultNode` 形状`callTime` 携带成对 start 事件的时间。start 落在回放窗口之外完结事件则直接追加,`callTime: null`(耗时未知——绝不伪造零耗时。live mux 帧与历史回放构建相同的索引;子调用永不进入 transcript `nodes` 流;无关快照交换不会改变每个父调用对应的数组引用和映射引用,两者均保持 memo 稳定
每个 `ToolCallBlock` 都通过 `subCalls` 按启动顺序递归拥有自己的子调用。Runtime 的 `ToolCallTree` 私下维护 parent callId 到 child 的索引`tool/code-dispatch-start` 事件落成 `RunningToolCall`,对应的 `tool/code-dispatch` 完结事件原位替换为 `ToolResultNode``callTime` 来自成对 start 事件start 落在回放窗口之外时,完结事件会以 `callTime: null` 直接追加,绝不伪造零耗时。live mux 帧与历史回放共用这套 fold 和树投影;子调用不会成为 transcript `nodes` 中的独立 root。一次 child 变化只会复制从该 child 到所属 root 的祖先链,未变化的 sibling 和其他 root 保持对象引用稳定。会引入环,或使递归深度超过 256 个调用这一固定安全上限的协议或历史记录边会被视为已消费,但不会修改树,因此会话其余部分仍可渲染
## Session 标题投影

View File

@@ -8,7 +8,7 @@ import { SessionsService } from './sessions/service.ts'
import type { SessionListState } from './sessions/service.ts'
import { SessionHistoryService } from './session-history/service.ts'
import { WorkspacesService } from './workspaces/service.ts'
import type { ConversationSnapshot, RunningToolCall, ToolResultNode } from './sessions/conversation.ts'
import type { ConversationSnapshot } from './sessions/conversation.ts'
import type { UseProjection } from './sessions/projection-store.ts'
export { SlotsService } from './slots.ts'
@@ -49,10 +49,10 @@ export type {
} from './contract/store.ts'
export type {
AssistantBlock, AssistantMessageNode, AssistantProvenanceView, AssistantRequestConfig,
AssistantTiming, CodeSubCall, CommandNode, CompactionSummaryNode, ComposerPhase,
AssistantTiming, CommandNode, CompactionSummaryNode, ComposerPhase,
ContextMessageNode, ConversationNode, ConversationSnapshot, ModelRetryNode, QueuedMessage,
RunningToolCall,
SteeringMessageNode, TodoItem, ToolResultNode, TurnErrorNode, UnknownSurfaceNode, UserMessageNode,
SteeringMessageNode, TodoItem, ToolCallBlock, ToolResultNode, TurnErrorNode, UnknownSurfaceNode, UserMessageNode,
} from './sessions/conversation.ts'
export type {
ConversationContext, ConversationContextOriginKind,
@@ -89,13 +89,6 @@ declare module '@deepseek-ai/dsh-type-meta' {
/** The conversation-snapshot selector hook supplied to session-scoped UI entries. */
export type UseConversationSession = SnapshotSelectorHook<ConversationSnapshot>
/**
* One tool call as the chat flow renders it: still-running (spinner card) or
* settled (result node). The fold produces both shapes; toolview components
* narrow on the discriminant fields.
*/
export type ToolCallBlock = RunningToolCall | ToolResultNode
declare module '@deepseek-ai/dsh-client-ui-slots' {
/**
* Session standard kit, real members (ui-slots declares the empty seat;

View File

@@ -1,4 +1,3 @@
import type { ContentBlock } from '@deepseek-ai/dsh-llm/types'
import type { SessionEvent } from '@deepseek-ai/dsh-session/types'
import {
SurfaceManager, isSurfaceEligibleType, isSurfaceEvent,
@@ -7,7 +6,7 @@ import type {
HistoryEntry, ToolCallView, ToolResultView,
} from '@deepseek-ai/dsh-client-connection/client'
import type {
AssistantRequestConfig, AssistantTiming, CodeSubCall, ConversationNode,
AssistantRequestConfig, AssistantTiming, ConversationNode,
PartialAssistant, RunningToolCall,
} from '../sessions/conversation.ts'
import { toAssistantBlocks } from '../sessions/conversation.ts'
@@ -20,6 +19,7 @@ import type { ConversationPromptSnapshot } from '../sessions/request-inspection.
import { PartialAccumulator } from '../sessions/partial.ts'
import type { AssistantStepMetadata } from '../sessions/assistant-timing.ts'
import { indexAssistantStepTiming, settledAssistantTiming } from '../sessions/assistant-timing.ts'
import { ToolCallTree } from '../sessions/tool-call-tree.ts'
interface CallIndexEntry {
name: string
@@ -41,7 +41,6 @@ export interface ConversationHistoryProjection {
interruptedNodes: readonly ConversationNode[]
partial: PartialAssistant | null
runningCalls: readonly RunningToolCall[]
codeDispatches: ReadonlyMap<string, readonly CodeSubCall[]>
}
function replacementCrossesWindowHead(event: SessionEvent, baseSeq: number): boolean {
@@ -177,6 +176,7 @@ function materializeNode(
meta: event.data.meta,
callView: call?.callView ?? null,
resultView,
subCalls: [],
}
}
default:
@@ -188,74 +188,22 @@ function materializeNode(
}
/* jscpd:ignore-end */
function projectTransient(entries: readonly HistoryEntry[]): Pick<
interface TransientProjection extends Pick<
ConversationHistoryProjection,
'interruptedNodes' | 'partial' | 'runningCalls' | 'codeDispatches'
'interruptedNodes' | 'partial' | 'runningCalls'
> {
toolCallTree: ToolCallTree
}
function projectTransient(entries: readonly HistoryEntry[]): TransientProjection {
let partial: PartialAccumulator | null = null
const openCalls = new Map<string, RunningToolCall>()
const interruptedNodes: ConversationNode[] = []
const codeDispatches = new Map<string, readonly CodeSubCall[]>()
const toolCallTree = new ToolCallTree()
for (const entry of entries) {
const { event } = entry
if ((event.type as string) === 'tool/code-dispatch-start') {
const data = event.data as unknown as {
parentCallId: string
subCallId: string
name: string
arguments: unknown
}
const siblings = codeDispatches.get(data.parentCallId) ?? []
// The independent replay emits the same public running-call shape as
// Chat without reading or mutating Session's live index.
/* jscpd:ignore-start */
codeDispatches.set(data.parentCallId, [...siblings, {
callId: data.subCallId,
name: data.name,
argsRaw: JSON.stringify(data.arguments),
turn: 0,
step: 0,
time: event.time,
callView: null,
}])
/* jscpd:ignore-end */
continue
}
if ((event.type as string) === 'tool/code-dispatch') {
const data = event.data as unknown as {
parentCallId: string
subCallId: string
name: string
arguments: unknown
isError: boolean
content: ContentBlock[]
}
const siblings = codeDispatches.get(data.parentCallId) ?? []
const at = siblings.findIndex(sub => sub.callId === data.subCallId)
const started = at === -1 ? undefined : siblings[at]
// History independently reproduces the public settled-call shape instead
// of consuming Session's live code-dispatch projection.
/* jscpd:ignore-start */
const settled: CodeSubCall = {
kind: 'tool-result', seq: event.seq, time: event.time,
callId: data.subCallId,
call: { name: data.name, argsRaw: JSON.stringify(data.arguments) },
callTime: started?.time ?? null,
content: data.content,
isError: data.isError,
callView: null,
resultView: null,
}
codeDispatches.set(
data.parentCallId,
at === -1
? [...siblings, settled]
: siblings.map((sub, index) => index === at ? settled : sub),
)
/* jscpd:ignore-end */
continue
}
if (toolCallTree.apply(event)) continue
switch (event.type) {
case 'assistant/chunk': {
const { turn, step, chunk } = event.data
@@ -280,6 +228,7 @@ function projectTransient(entries: readonly HistoryEntry[]): Pick<
step: event.data.step,
time: event.time,
callView: entry.view?.for === 'call' ? entry.view.view : null,
subCalls: [],
})
/* jscpd:ignore-end */
break
@@ -317,6 +266,7 @@ function projectTransient(entries: readonly HistoryEntry[]): Pick<
error: { name: 'Interrupted', code: 'interrupted' },
callView: call.callView,
resultView: null,
subCalls: [],
})
/* jscpd:ignore-end */
}
@@ -331,7 +281,7 @@ function projectTransient(entries: readonly HistoryEntry[]): Pick<
interruptedNodes,
partial: partial?.toPartial() ?? null,
runningCalls: [...openCalls.values()],
codeDispatches,
toolCallTree,
}
}
@@ -462,9 +412,17 @@ export function projectConversationHistory(
}
}
const transient = projectTransient(entries)
const projectedEventNodes = transient.toolCallTree.projectNodes(eventNodes)
const projectedContexts = contexts.map((context): ConversationContext => {
const nodes = transient.toolCallTree.projectNodes(context.nodes)
return nodes === context.nodes ? context : { ...context, nodes }
})
return {
eventNodes,
contexts,
...projectTransient(entries),
eventNodes: projectedEventNodes,
contexts: projectedContexts,
interruptedNodes: transient.toolCallTree.projectNodes(transient.interruptedNodes),
partial: transient.partial,
runningCalls: transient.toolCallTree.projectRunningCalls(transient.runningCalls),
}
}

View File

@@ -174,6 +174,8 @@ export interface ToolResultNode {
callView: ToolCallView | null
/** Host-computed render intent from this tool/result's wire view; null = same default. */
resultView: ToolResultView | null
/** Child calls owned by this call, in dispatch order. */
subCalls: readonly ToolCallBlock[]
}
/**
@@ -263,21 +265,6 @@ export type ConversationNode =
| CompactionSummaryNode
| UnknownSurfaceNode
/**
* One `run_code` sub-dispatch materialized in the native call-block shapes so
* every consumer (tool rows, details panel) renders it through the exact
* components that render a native call: a started-but-unsettled sub-call is a
* {@link RunningToolCall} (rows derive the running state from the shape,
* exactly as for native calls) and its `tool/code-dispatch` settlement
* replaces it in place with the {@link ToolResultNode} form. Never part of
* the transcript `nodes` flow — sub-calls live under their parent via
* {@link ConversationSnapshot.codeDispatches}. `callId` is the deterministic
* sub-call id (`<parent>:code:<n>`); the call side carries the sub-tool name
* and its JSON-stringified logged arguments; `content`/`isError` are the
* settled sub-call's complete logged outcome.
*/
export type CodeSubCall = RunningToolCall | ToolResultNode
/** In-flight tool card material: tool/call seen, tool/result not yet. */
export interface RunningToolCall {
callId: string
@@ -289,8 +276,12 @@ export interface RunningToolCall {
time: number
/** Host-computed render intent riding the tool/call frame; null = generic JSON card. */
callView: ToolCallView | null
/** Child calls owned by this call, in dispatch order. */
subCalls: readonly ToolCallBlock[]
}
/** One running or settled call, recursively owning its child calls. */
export type ToolCallBlock = RunningToolCall | ToolResultNode
/** One transient inbox occurrence from the authoritative `session/queue` snapshot. */
export interface QueuedMessage {
@@ -355,13 +346,6 @@ export interface ConversationSnapshot {
turnEnds: ReadonlyMap<number, number>
partial: PartialAssistant | null
runningCalls: readonly RunningToolCall[]
/**
* `run_code` sub-dispatches grouped under their parent callId, in dispatch
* order. Populated from in-window `tool/code-dispatch` events (live and
* replay identically); the per-parent array reference is stable across
* unrelated snapshot swaps (memo premise, same regime as `nodes`).
*/
codeDispatches: ReadonlyMap<string, readonly CodeSubCall[]>
pending: readonly PendingInteraction[]
/** Authoritative transient inbox snapshot, including queued and steering placements. */
queue: readonly QueuedMessage[]

View File

@@ -1,7 +1,7 @@
import type { ToolSchema } from '@deepseek-ai/dsh-llm/types'
import type { HistoryEntry } from '@deepseek-ai/dsh-client-connection/client'
import type {
CodeSubCall, ConversationNode, PartialAssistant, RunningToolCall,
ConversationNode, PartialAssistant, RunningToolCall,
} from './conversation.ts'
import type { ConversationContext } from './conversation-context.ts'
import { projectConversationHistory } from '../session-history/history-fold.ts'
@@ -34,7 +34,6 @@ export interface SessionHistoryInspection {
interruptedNodes: readonly ConversationNode[]
partial: PartialAssistant | null
runningCalls: readonly RunningToolCall[]
codeDispatches: ReadonlyMap<string, readonly CodeSubCall[]>
}
/**
@@ -112,9 +111,6 @@ export function createHistoryInspection(
get runningCalls() {
return conversationProjection().runningCalls
},
get codeDispatches() {
return conversationProjection().codeDispatches
},
get requests() {
return requestProjection().requests
},

View File

@@ -13,7 +13,7 @@ import type {
import { transportError } from '@deepseek-ai/dsh-host-apiproxy/api'
import type { SessionFace } from '../contract/session.ts'
import type {
CodeSubCall, ComposerPhase, ConversationNode, ConversationSnapshot, ModelRetryNode,
ComposerPhase, ConversationNode, ConversationSnapshot, ModelRetryNode,
OpenState, PromptError, QueuedMessage, RunningToolCall,
} from './conversation.ts'
import type { PendingInteraction } from './pending.ts'
@@ -24,6 +24,7 @@ import { Notifier } from './notifier.ts'
import { isVisibleAssistantChunk, PartialAccumulator } from './partial.ts'
import { ProjectionValueStore } from './projection-store.ts'
import type { ProjectionsBaseline } from './projection-store.ts'
import { ToolCallTree } from './tool-call-tree.ts'
/** Messages requested per history page. */
export const PAGE_MESSAGES = 50
@@ -129,11 +130,8 @@ export class Session implements SessionFace {
private queued: QueuedMessage[] = []
private queueRev = 0
private queueCache: { rev: number; value: QueuedMessage[] } | null = null
/** `run_code` sub-dispatches by parent callId (window-derived, like openCalls). Appends
* copy-on-write the per-parent array so published snapshot references never mutate. */
private codeDispatches = new Map<string, readonly CodeSubCall[]>()
private dispatchesRev = 0
private dispatchesCache: { rev: number; value: ReadonlyMap<string, readonly CodeSubCall[]> } | null = null
/** Window-derived child-call lifecycle and immutable tree projection. */
private readonly toolCallTree = new ToolCallTree()
private running = false
private address: SubagentAddress | undefined
private parentAvailable = false
@@ -746,65 +744,10 @@ export class Session implements SessionFace {
this.derivedRev++
return
}
// The `tool/code-dispatch-start`/`tool/code-dispatch` pair is declared by
// the host-side dsh-tools plugin whose types cannot enter the client
// program (its host Context merges collide with the client's), so this
// wire consumer narrows them structurally — the same posture as every
// other cross-wire event payload.
if ((event.type as string) === 'tool/code-dispatch-start') {
// A started sub-dispatch enters the index as a RunningToolCall — the
// exact shape a native in-flight call renders from — under its parent
// run_code callId; it never joins the surface flow.
const data = event.data as unknown as {
parentCallId: string
subCallId: string
name: string
arguments: unknown
}
const running: CodeSubCall = {
callId: data.subCallId, name: data.name,
argsRaw: JSON.stringify(data.arguments),
turn: 0, step: 0, time: event.time, callView: null,
}
const siblings = this.codeDispatches.get(data.parentCallId) ?? []
this.codeDispatches.set(data.parentCallId, [...siblings, running])
this.dispatchesRev++
return
}
if ((event.type as string) === 'tool/code-dispatch') {
// Settlement replaces the running entry in place (same array position,
// so parallel sub-calls keep their start order) with the
// ToolResultNode form; a settle with no observed start (history window
// cut mid-pair, or a pre-start-event log) appends directly.
const data = event.data as unknown as {
parentCallId: string
subCallId: string
name: string
arguments: unknown
isError: boolean
content: ContentBlock[]
}
const siblings = this.codeDispatches.get(data.parentCallId) ?? []
const at = siblings.findIndex(sub => sub.callId === data.subCallId)
const started = at === -1 ? undefined : siblings[at]
const settled: CodeSubCall = {
kind: 'tool-result', seq: event.seq, time: event.time,
callId: data.subCallId,
call: { name: data.name, argsRaw: JSON.stringify(data.arguments) },
// Duration source: the paired start's time when observed; null =
// unknown (settle-only window), matching the native tool-result
// contract so views never present a fabricated zero duration.
callTime: started === undefined ? null : started.time,
content: data.content, isError: data.isError,
callView: null, resultView: null,
}
this.codeDispatches.set(
data.parentCallId,
at === -1 ? [...siblings, settled] : siblings.map((sub, index) => (index === at ? settled : sub)),
)
this.dispatchesRev++
return
}
// These lifecycle events are declared by a host-only plugin whose Context
// types cannot enter the client program. ToolCallTree owns their structural
// wire narrowing, pairing, and nested snapshot projection.
if (this.toolCallTree.apply(event)) return
switch (event.type) {
case 'turn/start':
this.lastStepByTurn.set(event.data.turn, 0)
@@ -834,6 +777,7 @@ export class Session implements SessionFace {
callId: String(event.data.callId), name: event.data.name, argsRaw: event.data.arguments,
turn: event.data.turn, step: event.data.step, time: event.time,
callView: view?.for === 'call' ? view.view : null,
subCalls: [],
})
this.callsRev++
return
@@ -901,7 +845,7 @@ export class Session implements SessionFace {
call: { name: call.name, argsRaw: call.argsRaw },
callTime: call.time,
content: [], isError: true, error: { name: 'Interrupted', code: 'interrupted' },
callView: call.callView, resultView: null,
callView: call.callView, resultView: null, subCalls: [],
})
this.derivedRev++
}
@@ -948,8 +892,7 @@ export class Session implements SessionFace {
this.turnTimingsRev++
this.turnEnds = new Map()
this.turnEndsRev++
this.codeDispatches = new Map()
this.dispatchesRev++
this.toolCallTree.reset()
for (let i = 0; i < this.events.length; i++) {
const event = this.events[i]
/* v8 ignore next -- dense-array guard: i stays within events.length, so the undefined arm needs a sparse array no caller builds. */
@@ -988,22 +931,18 @@ export class Session implements SessionFace {
if (this.pendingCache === null || this.pendingCache.rev !== this.pendingRev) {
this.pendingCache = { rev: this.pendingRev, value: [...this.pending.values()] }
}
if (this.dispatchesCache === null || this.dispatchesCache.rev !== this.dispatchesRev) {
this.dispatchesCache = { rev: this.dispatchesRev, value: new Map(this.codeDispatches) }
}
if (this.queueCache === null || this.queueCache.rev !== this.queueRev) {
this.queueCache = { rev: this.queueRev, value: this.queued }
}
const partial = this.partial?.toPartial() ?? null
return {
sessionId: this.sessionId,
nodes,
nodes: this.toolCallTree.projectNodes(nodes),
turnTimings: this.turnTimingsCache.value,
turnEnds: this.turnEndsCache.value,
partial,
runningCalls: this.callsCache.value,
runningCalls: this.toolCallTree.projectRunningCalls(this.callsCache.value),
pending: this.pendingCache.value,
codeDispatches: this.dispatchesCache.value,
queue: this.queueCache.value,
running: this.running,
subagent: this.address === undefined

View File

@@ -0,0 +1,212 @@
import type { ContentBlock } from '@deepseek-ai/dsh-llm/types'
import type { SessionEvent } from '@deepseek-ai/dsh-session/types'
import type {
ConversationNode, RunningToolCall, ToolCallBlock, ToolResultNode,
} from './conversation.ts'
interface ProjectedBlock {
source: ToolCallBlock
children: readonly ToolCallBlock[]
value: ToolCallBlock
}
/** Fixed wire-safety ceiling for every recursive Tool call consumer. */
export const MAX_TOOL_CALL_TREE_DEPTH = 256
function sameReferences<T>(
left: readonly T[],
right: readonly T[],
): boolean {
return left.length === right.length
&& left.every((block, index) => block === right[index])
}
/**
* Owns Code Dispatch pairing and projects its private parent index into the
* recursive Tool call contract exposed by conversation snapshots.
*/
export class ToolCallTree {
private readonly childrenByParent = new Map<string, readonly ToolCallBlock[]>()
private readonly depthByCall = new Map<string, number>()
private readonly projectedByCall = new Map<string, ProjectedBlock>()
private revision = 0
private nodesCache: {
source: readonly ConversationNode[]
revision: number
value: readonly ConversationNode[]
} | null = null
private runningCache: {
source: readonly RunningToolCall[]
revision: number
value: readonly RunningToolCall[]
} | null = null
/** Forget all event-derived child calls before replaying a new window. */
reset(): void {
this.childrenByParent.clear()
this.depthByCall.clear()
this.projectedByCall.clear()
this.revision++
}
/**
* Fold one event when it belongs to the Code Dispatch lifecycle.
* @param event - Session event from the current live or history window.
* @returns Whether the event was consumed as a child-call lifecycle event.
*/
apply(event: SessionEvent): boolean {
if ((event.type as string) === 'tool/code-dispatch-start') {
const data = event.data as unknown as {
parentCallId: string
subCallId: string
name: string
arguments: unknown
}
const running: RunningToolCall = {
callId: data.subCallId,
name: data.name,
argsRaw: JSON.stringify(data.arguments),
turn: 0,
step: 0,
time: event.time,
callView: null,
subCalls: [],
}
const siblings = this.childrenByParent.get(data.parentCallId) ?? []
if (!this.acceptEdge(data.parentCallId, data.subCallId)) return true
this.childrenByParent.set(data.parentCallId, [...siblings, running])
this.revision++
return true
}
if ((event.type as string) !== 'tool/code-dispatch') return false
const data = event.data as unknown as {
parentCallId: string
subCallId: string
name: string
arguments: unknown
isError: boolean
content: ContentBlock[]
}
const siblings = this.childrenByParent.get(data.parentCallId) ?? []
const at = siblings.findIndex(sub => sub.callId === data.subCallId)
if (at === -1 && !this.acceptEdge(data.parentCallId, data.subCallId)) return true
const started = at === -1 ? undefined : siblings[at]
const settled: ToolResultNode = {
kind: 'tool-result',
seq: event.seq,
time: event.time,
callId: data.subCallId,
call: { name: data.name, argsRaw: JSON.stringify(data.arguments) },
callTime: started?.time ?? null,
content: data.content,
isError: data.isError,
callView: null,
resultView: null,
subCalls: [],
}
this.childrenByParent.set(
data.parentCallId,
at === -1
? [...siblings, settled]
: siblings.map((sub, index) => index === at ? settled : sub),
)
this.revision++
return true
}
/**
* Attach recursively projected children to all settled roots in a node list.
* @param nodes - Cache-stable base conversation nodes.
* @returns The original list when no root changed, otherwise a structurally shared list.
*/
projectNodes(nodes: readonly ConversationNode[]): readonly ConversationNode[] {
if (this.nodesCache?.source === nodes && this.nodesCache.revision === this.revision) {
return this.nodesCache.value
}
const projected = nodes.map((node): ConversationNode => {
if (node.kind !== 'tool-result') return node
return this.projectBlock(node) as ToolResultNode
})
const value = sameReferences(nodes, projected) ? nodes : projected
this.nodesCache = { source: nodes, revision: this.revision, value }
return value
}
/**
* Attach recursively projected children to all running root calls.
* @param calls - Cache-stable base running calls.
* @returns The original list when no root changed, otherwise a structurally shared list.
*/
projectRunningCalls(calls: readonly RunningToolCall[]): readonly RunningToolCall[] {
if (this.runningCache?.source === calls && this.runningCache.revision === this.revision) {
return this.runningCache.value
}
const projected = calls.map(call => this.projectBlock(call) as RunningToolCall)
const value = sameReferences(calls, projected) ? calls : projected
this.runningCache = { source: calls, revision: this.revision, value }
return value
}
private projectBlock(block: ToolCallBlock): ToolCallBlock {
const children = this.childrenByParent.get(block.callId) ?? block.subCalls
const projectedChildren = children.map(child => this.projectBlock(child))
const childValue = sameReferences(children, projectedChildren)
? children
: projectedChildren
const cached = this.projectedByCall.get(block.callId)
if (cached?.source === block && sameReferences(cached.children, childValue)) {
return cached.value
}
const value: ToolCallBlock = block.subCalls === childValue
? block
: { ...block, subCalls: childValue }
this.projectedByCall.set(block.callId, {
source: block,
children: childValue,
value,
})
return value
}
/**
* Accept an edge only when every recursive consumer can traverse it safely.
* Host-minted ids exclude cycles and current bindings emit one level; a
* malformed wire/history edge is consumed without hiding the rest of the session.
*/
private acceptEdge(parentCallId: string, subCallId: string): boolean {
if (this.wouldCreateCycle(parentCallId, subCallId)) return false
const pending = [{
callId: subCallId,
depth: (this.depthByCall.get(parentCallId) ?? 1) + 1,
}]
const updates = new Map<string, number>()
for (const candidate of pending) {
const knownDepth = updates.get(candidate.callId)
?? this.depthByCall.get(candidate.callId)
?? 1
if (candidate.depth <= knownDepth) continue
if (candidate.depth > MAX_TOOL_CALL_TREE_DEPTH) return false
updates.set(candidate.callId, candidate.depth)
for (const child of this.childrenByParent.get(candidate.callId) ?? []) {
pending.push({ callId: child.callId, depth: candidate.depth + 1 })
}
}
for (const [callId, depth] of updates) this.depthByCall.set(callId, depth)
return true
}
private wouldCreateCycle(parentCallId: string, subCallId: string): boolean {
if (parentCallId === subCallId) return true
const pending = [subCallId]
const visited = new Set(pending)
for (const callId of pending) {
for (const child of this.childrenByParent.get(callId) ?? []) {
if (child.callId === parentCallId) return true
if (visited.has(child.callId)) continue
visited.add(child.callId)
pending.push(child.callId)
}
}
return false
}
}

View File

@@ -103,6 +103,7 @@ function materializeNode(
meta: event.data.meta,
callView: call?.callView ?? null,
resultView,
subCalls: [],
}
}
/* v8 ignore next 2 -- defensive arm: only the four surface-eligible types

View File

@@ -168,6 +168,37 @@ describe('projectConversationHistory', () => {
})
})
it('projects nested dispatches onto settled and interrupted history calls', () => {
const projection = projectConversationHistory([
ev.turnStart(0, 1),
ev.toolCall(1, 1, 'settled', 'run_code', '{}'),
ev.codeDispatchStart(2, 'settled', 1, 'run_code', { code: 'nested' }),
ev.codeDispatchStart(3, 'settled:code:1', 1, 'read', { path: 'a.txt' }),
ev.codeDispatch(4, 'settled:code:1', 1, 'read', { path: 'a.txt' }, 'alpha'),
ev.codeDispatch(5, 'settled', 1, 'run_code', { code: 'nested' }, 'alpha'),
ev.toolResult(6, 1, 'settled', 'done'),
ev.turnEnd(7, 1),
ev.turnStart(8, 2),
ev.toolCall(9, 2, 'interrupted', 'run_code', '{}'),
ev.codeDispatchStart(10, 'interrupted', 1, 'bash', { command: 'sleep 1' }),
ev.turnEnd(11, 2, 'aborted'),
].map(event => ({ event })))
const settled = {
callId: 'settled',
subCalls: [{
callId: 'settled:code:1',
subCalls: [{ callId: 'settled:code:1:code:1', call: { name: 'read' } }],
}],
}
expect(projection.eventNodes).toMatchObject([settled])
expect(projection.contexts[0]?.nodes).toMatchObject([settled])
expect(projection.interruptedNodes).toMatchObject([{
callId: 'interrupted',
subCalls: [{ callId: 'interrupted:code:1', name: 'bash' }],
}])
})
it('drops completed token payloads without changing inspection projections', () => {
const events = [
ev.user(0, 'before'),

View File

@@ -1151,7 +1151,17 @@ describe('resync', () => {
})
describe('run_code sub-dispatch indexing', () => {
describe('nested run_code sub-dispatches', () => {
const subCallsOf = (session: Session, callId: string) => {
const snapshot = session.getSnapshot()
const running = snapshot.runningCalls.find(call => call.callId === callId)
if (running !== undefined) return running.subCalls
for (const node of snapshot.nodes) {
if (node.kind === 'tool-result' && node.callId === callId) return node.subCalls
}
return undefined
}
it('a start event lands as a running-shaped sub-call and its settle replaces it in place', async () => {
const { api, session } = makeSession()
api.onHistory = () => histResponse(plainTurn(0, 0, '问', '答'))
@@ -1161,19 +1171,19 @@ describe('run_code sub-dispatch indexing', () => {
feed(ev.toolCall(7, 1, 'p1', 'run_code', '{"code":"1","description":"d"}'))
feed(ev.codeDispatchStart(8, 'p1', 1, 'bash', { command: 'sleep' }))
feed(ev.codeDispatchStart(9, 'p1', 2, 'read', { path: 'a.txt' }))
const live = session.getSnapshot().codeDispatches.get('p1')
const live = subCallsOf(session, 'p1')
expect(live).toHaveLength(2)
// Running shape (no 'kind'): the exact RunningToolCall form native rows use.
expect(live?.[0]).toMatchObject({ callId: 'p1:code:1', name: 'bash', argsRaw: '{"command":"sleep"}' })
expect(live?.[0] !== undefined && 'kind' in live[0]).toBe(false)
// Settle out of order (parallel run): #2 first — replaces in place, keeping start order.
feed(ev.codeDispatch(10, 'p1', 2, 'read', { path: 'a.txt' }, 'alpha'))
const mixed = session.getSnapshot().codeDispatches.get('p1')
const mixed = subCallsOf(session, 'p1')
expect(mixed?.map(sub => 'kind' in sub)).toEqual([false, true])
expect(mixed?.[1]).toMatchObject({ callId: 'p1:code:2', content: [{ type: 'text', text: 'alpha' }] })
// The settle carries the paired start's time as callTime (duration source).
feed(ev.codeDispatch(11, 'p1', 1, 'bash', { command: 'sleep' }, 'done'))
const settled = session.getSnapshot().codeDispatches.get('p1')
const settled = subCallsOf(session, 'p1')
expect(settled?.map(sub => 'kind' in sub)).toEqual([true, true])
expect(settled?.[0]).toMatchObject({ callId: 'p1:code:1', callTime: 1_700_000_000_008 })
})
@@ -1187,7 +1197,7 @@ describe('run_code sub-dispatch indexing', () => {
feed(ev.toolCall(7, 1, 'p1', 'run_code', '{"code":"return 1","description":"跑一个程序"}'))
feed(ev.codeDispatch(8, 'p1', 1, 'bash', { command: 'ls', description: '列目录' }, 'demo.txt'))
feed(ev.codeDispatch(9, 'p1', 2, 'read', { path: 'a.txt' }, 'Error: ENOENT', true))
const subs = session.getSnapshot().codeDispatches.get('p1')
const subs = subCallsOf(session, 'p1')
expect(subs).toHaveLength(2)
expect(subs?.[0]).toMatchObject({
kind: 'tool-result', callId: 'p1:code:1',
@@ -1205,23 +1215,29 @@ describe('run_code sub-dispatch indexing', () => {
expect(session.getSnapshot().nodes.some(n => n.kind === 'tool-result' && n.callId.includes(':code:'))).toBe(false)
})
it('rebuilds the same index from a history window (replay parity)', async () => {
it('rebuilds the same nested tree from a history window (replay parity)', async () => {
const { api, session } = makeSession()
api.onHistory = () => histResponse([
...plainTurn(0, 0, '问', '答'),
ev.turnStart(6, 1),
ev.toolCall(7, 1, 'p1', 'run_code', '{"code":"return 1","description":"跑一个程序"}'),
ev.codeDispatch(8, 'p1', 1, 'bash', { command: 'ls' }, 'demo.txt'),
ev.toolResult(9, 1, 'p1', '{"done":true}'),
ev.turnEnd(10, 1),
ev.codeDispatchStart(8, 'p1', 1, 'run_code', { code: 'return tools.read({ path: "a.txt" })' }),
ev.codeDispatch(9, 'p1:code:1', 1, 'read', { path: 'a.txt' }, 'alpha'),
ev.codeDispatch(10, 'p1', 1, 'run_code', { code: 'return tools.read({ path: "a.txt" })' }, 'alpha'),
ev.toolResult(11, 1, 'p1', '{"done":true}'),
ev.turnEnd(12, 1),
])
await session.open()
const subs = session.getSnapshot().codeDispatches.get('p1')
const subs = subCallsOf(session, 'p1')
expect(subs).toHaveLength(1)
expect(subs?.[0]).toMatchObject({ callId: 'p1:code:1', call: { name: 'bash' } })
expect(subs?.[0]).toMatchObject({
callId: 'p1:code:1',
call: { name: 'run_code' },
subCalls: [{ callId: 'p1:code:1:code:1', call: { name: 'read' } }],
})
})
it('keeps the dispatch map reference across unrelated changes and swaps it on a new dispatch', async () => {
it('keeps an unaffected root reference and path-copies it on a new child', async () => {
const { api, session } = makeSession()
api.onHistory = () => histResponse(plainTurn(0, 0, '稳', '定'))
await session.open()
@@ -1230,13 +1246,48 @@ describe('run_code sub-dispatch indexing', () => {
feed(ev.toolCall(7, 1, 'p1', 'run_code', '{"code":"1","description":"d"}'))
feed(ev.codeDispatch(8, 'p1', 1, 'bash', { command: 'ls' }, 'x'))
const before = session.getSnapshot()
const beforeRoot = before.runningCalls.find(call => call.callId === 'p1')!
feed(ev.chunkStart(9, 1))
feed(ev.chunkText(10, 1, '流式'))
const after = session.getSnapshot()
expect(after.codeDispatches).toBe(before.codeDispatches)
const afterRoot = after.runningCalls.find(call => call.callId === 'p1')!
expect(afterRoot).toBe(beforeRoot)
feed(ev.codeDispatch(11, 'p1', 2, 'read', { path: 'a' }, 'y'))
expect(session.getSnapshot().codeDispatches).not.toBe(after.codeDispatches)
expect(session.getSnapshot().codeDispatches.get('p1')).toHaveLength(2)
const changedRoot = session.getSnapshot().runningCalls.find(call => call.callId === 'p1')!
expect(changedRoot).not.toBe(afterRoot)
expect(changedRoot.subCalls[0]).toBe(afterRoot.subCalls[0])
expect(changedRoot.subCalls).toHaveLength(2)
})
it('path-copies only the owning branch when a nested child changes', async () => {
const { api, session } = makeSession()
api.onHistory = () => histResponse(plainTurn(0, 0, '树', '结构'))
await session.open()
const feed = (event: SessionEvent) => { session.handleMuxEnvelope('r' as never, { type: 'session/event', sessionId: SID, event }) }
feed(ev.turnStart(6, 1))
feed(ev.toolCall(7, 1, 'p1', 'run_code', '{"code":"1","description":"first"}'))
feed(ev.toolCall(8, 1, 'p2', 'run_code', '{"code":"2","description":"second"}'))
feed(ev.codeDispatch(9, 'p1', 1, 'run_code', { code: 'nested' }, 'child'))
feed(ev.codeDispatch(10, 'p1', 2, 'read', { path: 'sibling' }, 'sibling'))
feed(ev.codeDispatch(11, 'p2', 1, 'bash', { command: 'pwd' }, 'root two'))
const before = session.getSnapshot()
const beforeFirst = before.runningCalls.find(call => call.callId === 'p1')!
const beforeSecond = before.runningCalls.find(call => call.callId === 'p2')!
const beforeChild = beforeFirst.subCalls[0]!
const beforeSibling = beforeFirst.subCalls[1]!
feed(ev.codeDispatch(12, 'p1:code:1', 1, 'read', { path: 'nested' }, 'leaf'))
const after = session.getSnapshot()
const afterFirst = after.runningCalls.find(call => call.callId === 'p1')!
const afterSecond = after.runningCalls.find(call => call.callId === 'p2')!
expect(afterFirst).not.toBe(beforeFirst)
expect(afterSecond).toBe(beforeSecond)
expect(afterFirst.subCalls[0]).not.toBe(beforeChild)
expect(afterFirst.subCalls[1]).toBe(beforeSibling)
expect(afterFirst.subCalls[0]?.subCalls).toMatchObject([
{ callId: 'p1:code:1:code:1', call: { name: 'read' } },
])
})
})

View File

@@ -0,0 +1,89 @@
import type { SessionEvent } from '@deepseek-ai/dsh-session/types'
import { describe, expect, it } from 'vitest'
import type { RunningToolCall, ToolCallBlock } from '../src/client/sessions/conversation.ts'
import {
MAX_TOOL_CALL_TREE_DEPTH, ToolCallTree,
} from '../src/client/sessions/tool-call-tree.ts'
const at = (seq: number, type: string, data: Record<string, unknown>): SessionEvent =>
({ seq, time: 1_700_000_000_000 + seq, type, data }) as unknown as SessionEvent
const start = (seq: number, parentCallId: string, subCallId: string): SessionEvent =>
at(seq, 'tool/code-dispatch-start', {
parentCallId, subCallId, name: 'run_code', arguments: {},
})
const settle = (seq: number, parentCallId: string, subCallId: string): SessionEvent =>
at(seq, 'tool/code-dispatch', {
parentCallId, subCallId, name: 'run_code', arguments: {},
isError: false, content: [],
})
const root = (callId: string): RunningToolCall => ({
callId, name: 'run_code', argsRaw: '{}', turn: 1, step: 1,
time: 1_700_000_000_000, callView: null, subCalls: [],
})
describe('ToolCallTree', () => {
it('rejects a self-parenting dispatch edge', () => {
const tree = new ToolCallTree()
const roots = [root('root')]
expect(tree.apply(start(0, 'root', 'root'))).toBe(true)
expect(tree.projectRunningCalls(roots)).toBe(roots)
})
it('rejects a settling edge that would close a multi-call cycle', () => {
const tree = new ToolCallTree()
tree.apply(start(0, 'a', 'b'))
tree.apply(start(1, 'b', 'c'))
expect(tree.apply(settle(2, 'c', 'a'))).toBe(true)
expect(tree.projectRunningCalls([root('a')])).toMatchObject([{
callId: 'a',
subCalls: [{
callId: 'b',
subCalls: [{ callId: 'c', subCalls: [] }],
}],
}])
})
it('accepts an acyclic graph with a shared descendant', () => {
const tree = new ToolCallTree()
tree.apply(start(0, 'a', 'b'))
tree.apply(start(1, 'a', 'c'))
tree.apply(start(2, 'b', 'd'))
tree.apply(start(3, 'c', 'd'))
expect(tree.apply(start(4, 'root', 'a'))).toBe(true)
expect(tree.projectRunningCalls([root('root')])).toMatchObject([{
callId: 'root',
subCalls: [{
callId: 'a',
subCalls: [{ callId: 'b' }, { callId: 'c' }],
}],
}])
})
it('rejects an edge beyond the recursive depth safety limit', () => {
const tree = new ToolCallTree()
for (let depth = 1; depth < MAX_TOOL_CALL_TREE_DEPTH; depth++) {
tree.apply(start(depth, `call-${depth - 1}`, `call-${depth}`))
}
expect(tree.apply(start(
MAX_TOOL_CALL_TREE_DEPTH,
`call-${MAX_TOOL_CALL_TREE_DEPTH - 1}`,
`call-${MAX_TOOL_CALL_TREE_DEPTH}`,
))).toBe(true)
let current: ToolCallBlock = tree.projectRunningCalls([root('call-0')])[0]!
let depth = 1
while (current.subCalls.length > 0) {
current = current.subCalls[0]!
depth++
}
expect(depth).toBe(MAX_TOOL_CALL_TREE_DEPTH)
expect(current.callId).toBe(`call-${MAX_TOOL_CALL_TREE_DEPTH - 1}`)
})
})

View File

@@ -50,7 +50,6 @@ export function conversationSnapshot(sessionId: SessionId): ConversationSnapshot
turnEnds: new Map(),
partial: null,
runningCalls: [],
codeDispatches: new Map(),
pending: [],
queue: [],
running: false,

View File

@@ -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-conversation/README.md
README.md: 837edaa097d47ebfb72d027616a18fdfeed8a488
README.zh.md: 419799666dc0689f8fe754d4c9de6d5dcf7fb09c
README.md: 3e3a6b1a09cbed77700fb656882178efd3744a80
README.zh.md: f3f25426156b3778859ec8f64e8d947362b967ab

View File

@@ -40,7 +40,7 @@ The chat stats line takes its token accounting from the generic token-meter `tok
`src/client/` is organized by domain. `contract/` is the shared face for slot declarations, composed props, and cross-domain types; `skeleton/`, `chat/`, `input/`, `queue/`, and `settings/` keep their implementations internal, while `apply.ts` is their assembly point. The `/client` export surface contains only loader entries, service classes, and contract types; components and store factories reach the page through slot registrations.
A finished turn ends with a turn-tail hole: the chat view renders the `conversation.chat.turnTail` list slot between the closing assistant's body and its IconActions, once per turn at the seq `assistantActionsSeqs` elects, dispatching `TurnTailOwnerProps` (the snapshot nodes, the closing seq, and the tool rows' `openFile`). This package owns only the hole; the produced-files row that fills it — derivation from the mutation tools' `locations`, the chip cap, the copy — lives in `@deepseek-ai/dsh-client-ui-deliverables`, so composing that plugin out of cordis.yml turns the surface off while the hole renders empty at zero cost.
A finished turn ends with a turn-tail hole: the chat view renders the `conversation.chat.turnTail` list slot between the closing assistant's body and its IconActions, once per turn at the seq `assistantActionsSeqs` elects, dispatching `TurnTailOwnerProps` (the snapshot nodes, the closing seq, and the tool rows' `openFile`). This package owns only the hole; the produced-files row that fills it — derivation from the mutation tools' `locations`, the chip cap, the copy — lives in `@deepseek-ai/dsh-client-ui-deliverables`, so composing that plugin out of cordis.yml turns the surface off while the hole renders empty at zero cost. The closing prose participates through the same off switch: the chat view asks the optional `chatFileMentions` service (ctx.get; provided by the same plugin) for a closing message's inline-code vocabulary and threads the result into MarkdownText's `fileMentions` seam — an absent service leaves the prose inert.
## Model Experience

View File

@@ -40,7 +40,7 @@ Host 带 placement 的 `session/queue` 快照也会携带待处理 steering。Qu
`src/client/` 按领域组织。`contract/` 是 slot 声明、组合 props 与跨领域类型的共享表层;`skeleton/``chat/``input/``queue/``settings/` 保持内部实现,`apply.ts` 是它们的组装点。`/client` 导出表层只包含 loader entry、service class 和 contract 类型;组件与 store factory 经 slot 注册抵达页面。
完成的一轮以一个 turn-tail 空位收尾chat 视图在收尾 assistant 正文与其 IconActions 之间渲染 `conversation.chat.turnTail` list slot每轮一次、位于 `assistantActionsSeqs` 选出的 seq派发 `TurnTailOwnerProps`(快照节点、收尾 seq以及工具行的 `openFile`)。本包只拥有空位;填充它的产物行——从改写工具 `locations` 的派生、chip 上限、文案——都在 `@deepseek-ai/dsh-client-ui-deliverables` 里,因此把那个插件从 cordis.yml 中组合掉即可关闭该交互面,空位以零成本渲染为空。
完成的一轮以一个 turn-tail 空位收尾chat 视图在收尾 assistant 正文与其 IconActions 之间渲染 `conversation.chat.turnTail` list slot每轮一次、位于 `assistantActionsSeqs` 选出的 seq派发 `TurnTailOwnerProps`(快照节点、收尾 seq以及工具行的 `openFile`)。本包只拥有空位;填充它的产物行——从改写工具 `locations` 的派生、chip 上限、文案——都在 `@deepseek-ai/dsh-client-ui-deliverables` 里,因此把那个插件从 cordis.yml 中组合掉即可关闭该交互面,空位以零成本渲染为空。收尾正文经由同一个开关参与其中chat 视图向可选的 `chatFileMentions` servicectx.get由同一插件提供索取收尾消息的行内代码词表并把结果接进 MarkdownText 的 `fileMentions` seam——service 缺席时正文保持死文本。
## 模型体验

View File

@@ -317,6 +317,7 @@ export function apply(ctx: Context): void {
actions.select(target)
layout.openDetails()
},
fileMentions: owner => ctx.get('chatFileMentions')?.forClosing(owner),
openFile: (path) => {
const cwd = sessions.list.getSnapshot().byId[sessionId]?.cwd
void workspaces.openPath(resolveWorkspacePath(cwd, path)).catch(() => {

View File

@@ -13,7 +13,8 @@ import { memo, useMemo } from 'react'
import type { AssistantBlock } from '@deepseek-ai/dsh-client-runtime/client'
import type { PropsRenderSlots } from '@deepseek-ai/dsh-client-ui-slots'
import { JsonBlock, MarkdownText } from '@deepseek-ai/dsh-client-ui-primitives'
import type { ChatViewSlotProps, TurnTailOwnerProps } from '../contract/slots.ts'
import type { MarkdownFileMentions } from '@deepseek-ai/dsh-client-ui-primitives'
import type { ChatViewSlotProps, ChatViewInjected, TurnTailOwnerProps } from '../contract/slots.ts'
import { hasContentText } from './chat-flow.ts'
import { MessageIconActions } from './MessageIconActions.tsx'
import { ReasoningRow } from './ReasoningRow.tsx'
@@ -41,6 +42,8 @@ export interface AssistantMarkdownProps {
onFork?: ((seq: number) => void) | undefined
/** Turn-tail slot dispatch share and owner currency; omitted for a mid-turn assistant. */
turnTail?: (Pick<PropsRenderSlots<'conversation.chat.turnTail'>, 'renderSlotChain'> & { owner: TurnTailOwnerProps }) | undefined
/** Prose file-mention factory (the injected face); omitted wherever `turnTail` is. */
fileMentions?: ChatViewInjected['fileMentions'] | undefined
/** The message is not the transcript tail of a completed turn. */
forkUnavailable?: boolean | undefined
/** The owning view's locale seat, passed down as a plain prop. */
@@ -58,11 +61,25 @@ function copyText(blocks: readonly AssistantBlock[]): string {
/** Reasoning block as the Think variant summary row (figma 39:28304). */
export const AssistantMarkdown = memo(function AssistantMarkdown({
blocks, streaming, interrupted, time, runMs, ttftMs, tokensPerSecond, seq, onFork, forkUnavailable, turnTail, t,
blocks, streaming, interrupted, time, runMs, ttftMs, tokensPerSecond, seq, onFork, forkUnavailable, turnTail,
fileMentions, t,
}: AssistantMarkdownProps) {
// Stable per locale revision (t identity changes on switch): a fresh object
// per render would rebuild MarkdownText's component table every chunk.
const codeLabels = useMemo(() => ({ copyLabel: t('copy'), copiedLabel: t('copied') }), [t])
// Mention vocabulary for the closing prose. Keyed on the anchor seq, not the
// growing transcript: a settled turn's produced files are final, and a
// fresh identity per append would discard MarkdownText's cached parse for
// every settled closing message on every stream chunk. The window-prepend
// edge (a mid-turn window start later gaining earlier same-turn writes)
// leaves a mention unlinked until remount — never a wrong link.
const owner = turnTail?.owner
const mentions: MarkdownFileMentions | undefined = useMemo(
() => (owner === undefined ? undefined : fileMentions?.(owner)),
// Deliberately not `owner`: its identity changes per append while the
// seq-addressed vocabulary it yields does not.
[fileMentions, owner?.seq],
)
const last = blocks.length - 1
// Tool-call heads render as tool rows in the chat view's grouping pass, so
// a node that is only those heads (or empty) would paint an empty root
@@ -79,7 +96,13 @@ export const AssistantMarkdown = memo(function AssistantMarkdown({
{blocks.map((block, i) => {
switch (block.kind) {
case 'text': return (
<MarkdownText key={i} text={block.text} streaming={streaming} codeLabels={codeLabels} />
<MarkdownText
key={i}
text={block.text}
streaming={streaming}
codeLabels={codeLabels}
fileMentions={mentions}
/>
)
case 'reasoning': return <ReasoningRow key={i} text={block.text} running={streaming && i === last} t={t} />
// Grouped into tool rows by ChatView; hasVisible above skips an empty shell.

View File

@@ -24,7 +24,7 @@ import {
memo, useEffect, useLayoutEffect, useMemo, useRef, useState, type ReactNode,
} from 'react'
import type {
CommandNode, ConversationNode, ConversationSnapshot, RunningToolCall, ToolResultNode,
CommandNode, ConversationNode, ConversationSnapshot, RunningToolCall, ToolCallBlock, 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'
@@ -111,6 +111,11 @@ type ChatScrollPosition = NonNullable<ReturnType<ChatViewSlotProps['chatScroll']
* chat view narrows once to the runtime snapshot the binding actually feeds. */
type UseConversation = SnapshotSelectorHook<ConversationSnapshot>
function treeContainsCall(block: ToolCallBlock, callId: string | undefined): boolean {
return callId !== undefined
&& (block.callId === callId || block.subCalls.some(child => treeContainsCall(child, callId)))
}
function activeRetrySeq(nodes: readonly ConversationNode[], running: boolean): number | null {
if (!running) return null
for (let index = nodes.length - 1; index >= 0; index -= 1) {
@@ -174,7 +179,7 @@ const ToolGroup = memo(function ToolGroup({ renderSlot, results, openFile, selec
toolName={node.call?.name ?? ''}
block={node}
openFile={openFile}
selectedCallId={selectedCallId}
selectedCallId={treeContainsCall(node, selectedCallId) ? selectedCallId : undefined}
cwd={cwd}
inspectCall={inspectCall}
/>
@@ -258,7 +263,8 @@ function StreamingTail({ useSession, t }: {
* ordered root Tool call crosses the declared whole-Tool render seat.
*/
export function ChatView({
useSession, useSessions, useStore, renderSlot, renderSlotChain, sessionId, openFile, loadOlder, inspectCall, chatScroll, forkAt, t,
useSession, useSessions, useStore, renderSlot, renderSlotChain, sessionId, openFile, loadOlder, inspectCall, chatScroll, forkAt,
fileMentions, t,
}: ChatViewSlotProps) {
const nodes = useSession(s => s.nodes)
const turnTimings = useSession(s => s.turnTimings)
@@ -530,6 +536,7 @@ export function ChatView({
turnTail={actionSeqs.has(node.seq)
? { renderSlotChain, owner: { nodes, seq: node.seq, openFile } }
: undefined}
fileMentions={actionSeqs.has(node.seq) ? fileMentions : undefined}
t={t}
/>
)
@@ -595,7 +602,7 @@ export function ChatView({
toolName={call.name}
block={call}
openFile={openFile}
selectedCallId={selectedCallId}
selectedCallId={treeContainsCall(call, selectedCallId) ? selectedCallId : undefined}
cwd={cwd}
inspectCall={inspectCall}
/>

View File

@@ -4,6 +4,7 @@ import type {
InjectFace, MaybeSnapshotSelectorHook, PropsLocale, PropsRenderSlots, PropsRuntime, PropsStore, SnapshotSelectorHook,
} from '@deepseek-ai/dsh-client-ui-slots'
import type { CommandNode, CompactionSummaryNode, ConversationNode, ConversationSnapshot, ObservableSnapshot, PendingInteraction, PendingWait, SessionId, ToolCallBlock, WorkspaceId } from '@deepseek-ai/dsh-client-runtime/client'
import type { MarkdownFileMentions } from '@deepseek-ai/dsh-client-ui-primitives'
import type {} from '@deepseek-ai/dsh-client-ui-layout/client'
import type { ComposerBlock } from '../input/blocks.ts'
import type { ComposerKeyboard, EditSelection, InputActions, InputNotice, InputState } from '../input/contract.ts'
@@ -160,6 +161,30 @@ export interface ConvViewOwnerProps {
onInspectDone?: () => void
}
/**
* Optional prose file-mention provider, consumed via `ctx.get('chatFileMentions')`
* (optional-service convention): the chat view asks it for a closing message's
* inline-code vocabulary and threads the result into MarkdownText. Absent
* service — the providing plugin composed out of cordis.yml — turns the
* surface off; the prose renders inert code.
*/
export interface ChatFileMentions {
/**
* Mention vocabulary for the closing message the owner currency names.
* @param owner - Turn-tail owner currency (nodes, closing seq, opener).
* @returns The resolver MarkdownText consumes, or undefined when the turn
* produced nothing worth linking.
*/
forClosing(owner: TurnTailOwnerProps): MarkdownFileMentions | undefined
}
declare module 'cordis' {
interface Context {
/** Prose file-mention provider (ui-deliverables); reach via ctx.get — optional. */
chatFileMentions: ChatFileMentions
}
}
/**
* Owner currency of the chat view's turn-tail hole: the finalized snapshot
* and the closing assistant's anchor. Registrants derive their own facts
@@ -519,6 +544,13 @@ export interface ChatViewInjected {
}
/** Fork through the completed turn ending at the eligible message `seq`, then open the child. */
forkAt: (seq: number) => void
/**
* Prose file-mention vocabulary for one closing message, from the optional
* {@link ChatFileMentions} service (resolved lazily per call, so composing
* the provider in or out takes effect live). Undefined when the service is
* absent or the turn produced nothing worth linking.
*/
fileMentions: (owner: TurnTailOwnerProps) => MarkdownFileMentions | undefined
}
/** Full chat-view component props: runtime & its Tool/command/tail render shares & store & injected & locale seat. */

View File

@@ -12,6 +12,7 @@ export type {
} from './contract/views.ts'
export type { ConversationKey } from './locales.ts'
export type {
ChatFileMentions,
ChatStore, ChatViewInjected, ChatViewSlotProps, CommandRowOwnerProps, CommandRowProps, ComposerBarInjected,
ComposerChainProps, ConversationInjected,
ConversationSessionHeaderInjected, ConversationSessionInjected, ConversationSlotProps, ConvViewOwnerProps,

View File

@@ -40,19 +40,27 @@ function runningMaterial(call: RunningToolCall): CallMaterial {
return { name: call.name, argsRaw: call.argsRaw, block: call }
}
function findCall(block: ToolCallBlock, callId: string): ToolCallBlock | undefined {
if (block.callId === callId) return block
for (const child of block.subCalls) {
const found = findCall(child, callId)
if (found !== undefined) return found
}
return undefined
}
function materialFor(s: ConversationSnapshot, callId: string): CallMaterial | null {
for (const node of s.nodes) {
if (node.kind === 'tool-result' && node.callId === callId) return settledMaterial(node, callId)
if (node.kind !== 'tool-result') continue
const found = findCall(node, callId)
if (found !== undefined) {
return 'kind' in found ? settledMaterial(found, callId) : runningMaterial(found)
}
}
const open = s.runningCalls.find(c => c.callId === callId)
if (open !== undefined) return runningMaterial(open)
// 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
return 'kind' in sub ? settledMaterial(sub, callId) : runningMaterial(sub)
for (const root of s.runningCalls) {
const found = findCall(root, callId)
if (found !== undefined) {
return 'kind' in found ? settledMaterial(found, callId) : runningMaterial(found)
}
}
return null

View File

@@ -42,7 +42,7 @@ const assistant = (seq: number, turn: number, usage?: unknown): AssistantMessage
function snapshotBase(): ConversationSnapshot {
return {
sessionId: SID, nodes: [], turnTimings: new Map(), turnEnds: new Map(), partial: null, runningCalls: [], codeDispatches: new Map(),
sessionId: SID, 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,
}
@@ -86,7 +86,7 @@ describe('deriveStats', () => {
it('ignores tool results with no call time', () => {
const tool: ToolResultNode = {
kind: 'tool-result', seq: 5, time: 5_000, callId: 'c', call: null, callTime: null, content: [],
isError: false, callView: null, resultView: null,
isError: false, callView: null, resultView: null, subCalls: [],
}
const stats = deriveStats([tool, assistant(1, 1)])
expect(stats.steps).toBe(1)
@@ -104,7 +104,7 @@ describe('deriveStats', () => {
}
const tool: ToolResultNode = {
kind: 'tool-result', seq: 5, time: 7_000, callId: 'c', call: null, callTime: 4_000, content: [],
isError: false, callView: null, resultView: null,
isError: false, callView: null, resultView: null, subCalls: [],
}
const stats = deriveStats([timed, untimed, tool])
expect(stats.llmMs).toBe(2_500)

View File

@@ -37,7 +37,7 @@ const SID = 's1' as SessionId
function snapshotBase(): ConversationSnapshot {
return {
sessionId: SID, nodes: [], turnTimings: new Map(), turnEnds: new Map(), partial: null, runningCalls: [], codeDispatches: new Map(),
sessionId: SID, 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,
}
@@ -88,10 +88,10 @@ const toolResult = (seq: number, callId: string, name = 'bash'): ToolResultNode
kind: 'tool-result', seq, time: seq * 1_000, callId,
call: { name, argsRaw: `{"command":"cmd-${callId}","description":"run ${callId}"}` },
callTime: seq * 1_000 - 500,
content: [], isError: false, callView: null, resultView: null,
content: [], isError: false, callView: null, resultView: null, subCalls: [],
})
const runningCall = (callId: string, name = 'bash'): RunningToolCall => ({
callId, name, argsRaw: `{"command":"cmd-${callId}"}`, turn: 2, step: 1, time: 1_000, callView: null,
callId, name, argsRaw: `{"command":"cmd-${callId}"}`, turn: 2, step: 1, time: 1_000, callView: null, subCalls: [],
})
const command = (over: Partial<CommandNode> = {}): CommandNode => ({
kind: 'command', seq: 5, time: 5_000, commandId: 'cmd-1' as CommandNode['commandId'],
@@ -180,6 +180,8 @@ function makeHarness(init?: Partial<ConversationSnapshot>) {
inspectCall,
chatScroll,
forkAt,
// Absent-service default; mention tests override with a real resolver.
fileMentions: () => undefined,
// Mirrors the real lookup chain (conversation namespace, then common).
t,
}
@@ -350,6 +352,45 @@ describe('chat-flow derivation', () => {
expect([...assistantActionsSeqs(nodes, new Map([[1, 5]]))]).toEqual([5])
})
it('threads the injected file-mention vocabulary into the closing prose only', () => {
const wrote = (seq: number, callId: string, path: string): ToolResultNode => ({
...toolResult(seq, callId, 'write'),
callView: {
card: 'diff', title: 'Write', diffs: [{ path, oldText: null, newText: 'x' }], locations: [{ path }],
},
})
const h = makeHarness({
nodes: [
user(1, 'build it'),
assistant(2, 'writing `report.html` now', 1),
wrote(3, 'w', 'site/report.html'),
assistant(4, 'Wrote `report.html`; `notes.md` untouched.', 1),
],
turnEnds: new Map([[1, 4]]),
})
// Stub provider mirroring the real service: only produced files resolve.
h.props.fileMentions = owner => ({
resolve: (value) => {
if (value !== 'report.html') return undefined
return {
open: () => { h.openFile(`for-seq-${String(owner.seq)}/site/report.html`) },
label: '打开 site/report.html',
title: 'site/report.html',
}
},
})
const view = render(<h.ChatView {...h.props} />)
// Exactly one live mention: the closing message links, the mid-turn
// narration stays inert code, and the unknown file resolves to nothing.
const mentions = view.container.querySelectorAll('code button')
expect(mentions).toHaveLength(1)
const mention = view.getByRole('button', { name: '打开 site/report.html' })
expect(mention.getAttribute('title')).toBe('site/report.html')
fireEvent.click(mention)
// The vocabulary was built from the closing message's own owner currency.
expect(h.openFile).toHaveBeenCalledWith('for-seq-4/site/report.html')
})
it('runningTurnStartTime selects the latest turn/start without a turn/end', () => {
expect(runningTurnStartTime(new Map([
[1, { startTime: 1_000, endTime: 5_000 }],

View File

@@ -47,7 +47,7 @@ function renderToolDetailsProbe(owners?: DetailsToolOwnerProps[]): DetailsSlotPr
function snapshotBase(): ConversationSnapshot {
return {
sessionId: SID, nodes: [], turnTimings: new Map(), turnEnds: new Map(), partial: null, runningCalls: [], codeDispatches: new Map(),
sessionId: SID, 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,
}
@@ -126,18 +126,28 @@ describe('render branch tails', () => {
expect(view.getByText('该调用不在当前窗口内')).toBeTruthy()
})
it('DetailsPanel resolves a run_code sub-callId to its full logged args and output', () => {
it('DetailsPanel resolves a nested run_code leaf 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,
}]]])
snap.runningCalls = [{
callId: 'p1', name: 'run_code', argsRaw: '{}', turn: 1, step: 1,
time: 7_000, callView: null, subCalls: [{
kind: 'tool-result', seq: 8, time: 8_000, callId: 'p1:code:1',
call: { name: 'run_code', argsRaw: '{"code":"return 1"}' },
callTime: 8_000,
content: [], isError: false, callView: null, resultView: null,
subCalls: [{
kind: 'tool-result', seq: 9, time: 9_000, callId: 'p1:code:1:code:1',
call: { name: 'read', argsRaw: '{"path":"notes/demo.txt"}' },
callTime: 8_500,
content: [{ type: 'text', text: longText }], isError: false, callView: null, resultView: null,
subCalls: [],
}],
}],
}]
const chat = createChatStore().create()
chat.actions.select({ turnSeq: 8, callId: 'p1:code:1', toolName: 'read' } satisfies SelectionTarget)
chat.actions.select({ turnSeq: 9, callId: 'p1:code:1:code:1', toolName: 'read' } satisfies SelectionTarget)
const emptyList = createSnapshotStore<SessionListState>(
{ ids: [], byId: {}, current: undefined, phase: 'ready', subagentsByParent: {}, currentAddress: undefined })
const emptyWorkspaces = createSnapshotStore<WorkspaceListState>({
@@ -168,7 +178,7 @@ describe('render branch tails', () => {
expect(view.getByTestId('tool-details-seat')).toBeTruthy()
expect(owners).toHaveLength(1)
expect(owners[0]?.block).toMatchObject({
callId: 'p1:code:1',
callId: 'p1:code:1:code:1',
call: { name: 'read', argsRaw: '{"path":"notes/demo.txt"}' },
content: [{ type: 'text', text: longText }],
})

View File

@@ -35,7 +35,7 @@ const SID = 's1' as SessionId
function snapshotOf(overrides: Partial<ConversationSnapshot> = {}): ConversationSnapshot {
return {
sessionId: SID, nodes: [], turnTimings: new Map(), turnEnds: new Map(), partial: null, runningCalls: [], codeDispatches: new Map(),
sessionId: SID, 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,

View File

@@ -26,7 +26,7 @@ const SID = 's1' as SessionId
/** Standard-props InputBar mount over a real shell (the composer-bar entry shape). */
function mountBar(shell: SessionInputShell, over?: { running?: boolean; disabled?: boolean }) {
const session = createSnapshotStore<ConversationSnapshot>({
sessionId: SID, nodes: [], turnTimings: new Map(), turnEnds: new Map(), partial: null, runningCalls: [], codeDispatches: new Map(),
sessionId: SID, nodes: [], turnTimings: new Map(), turnEnds: new Map(), partial: null, runningCalls: [],
pending: [], queue: [], running: over?.running ?? false, composerPhase: 'active',
removed: over?.disabled ?? false, openState: 'open', openError: null, hasMore: false,
loadingOlder: false, promptError: null, blank: false, subagent: null, lastAgentError: null,

View File

@@ -112,7 +112,7 @@ async function scopedBench(register?: (slash: SlashService) => void) {
actx.on('slash/input-consume-token', req => shell.consumeToken(req.guard) ? true : undefined)
const wiring = shell
const sessionStore = createSnapshotStore<ConversationSnapshot>({
sessionId, nodes: [], turnTimings: new Map(), turnEnds: new Map(), partial: null, runningCalls: [], codeDispatches: new Map(),
sessionId, 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,

View File

@@ -32,7 +32,7 @@ function row(id: string, text: string | null, preview = text ?? '[image]'): Queu
function snapshotWith(queue: QueuedMessage[]): ConversationSnapshot {
return {
sessionId: SID, nodes: [], turnTimings: new Map(), turnEnds: new Map(), partial: null, runningCalls: [], codeDispatches: new Map(),
sessionId: SID, nodes: [], turnTimings: new Map(), turnEnds: new Map(), partial: null, runningCalls: [],
pending: [], queue, running: true, composerPhase: 'active', removed: false, openState: 'open', openError: null,
hasMore: false, loadingOlder: false, promptError: null, blank: false, subagent: null, lastAgentError: null,
}

View File

@@ -70,7 +70,7 @@ const workspaceState = (items: readonly WorkspaceView[]): WorkspaceListState =>
function conversationSnapshot(overrides: Partial<ConversationSnapshot> = {}): ConversationSnapshot {
return {
sessionId: SID, nodes: [], turnTimings: new Map(), turnEnds: new Map(), partial: null, runningCalls: [], codeDispatches: new Map(),
sessionId: SID, 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,

View File

@@ -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-deliverables/README.md
README.md: b8b0ea2ef1cbc9b18b905fc08b41278f403ef043
README.zh.md: a16535b8a8d3625ca1cf90e88c6d9dca742d916b
README.md: 189dedd88fed6914012204118ccdf9bdd0cd3bb2
README.zh.md: bfcec3c54602533028942ed167b9526eaf3ca959

View File

@@ -8,6 +8,8 @@ Produced-files feature owner: registers the deliverables row a finished turn end
`ProducedFiles` renders the row between the closing message's body and its IconActions footer: a quiet label, up to six chips (basename text, full path as the `title`), and an explicit remainder count past the cap. Each chip opens through the owner-supplied `openFile` — the same Host opener the tool rows use, with the chat view resolving relative paths against the session cwd. Design rationale: the [workspace file links Agent Note](../../../.agents/notes/implemented/feature/2026-07-31-web-workspace-file-links.md).
The closing prose carries the same vocabulary. This plugin provides the `chatFileMentions` service the chat view consults per closing message: `producedFileMentions` resolves an inline-code token by exact path, or by being exactly the basename of exactly one produced path — a basename two paths share stays inert rather than guessing, so a mention link can never open the wrong file or 404. A resolved mention keeps its code chip and takes the markdown sheet's link language — link-blue at rest, underlined on hover, exactly like URL-promoted inline code — with the full path as its `title`; mentions never render inside anchors or streaming text. Decision record: the [inline file mentions Agent Note](../../../.agents/notes/implemented/feature/2026-08-07-web-inline-file-mentions.md).
## Model Experience
None, as the row is a pure client derivation over already-logged tool metadata and nothing here reaches a model request.
@@ -18,4 +20,4 @@ None; this package neither assembles nor sends provider requests.
## Known Limitations and Deferred Work
- **Prose mentions stay inert.** An inline-code file name in the closing message does not open the file yet; linking it to the same `locations` vocabulary is the stacked follow-up.
- **Mention matching is exact path or unique basename only.** A suffix mention (`out/index.html` written as `index.html` resolves; `deep/out/index.html` written as `out/index.html` does not) stays inert; widening the matcher is deferred until a real closing-message shape needs it.

View File

@@ -8,6 +8,8 @@
`ProducedFiles` 在收尾消息正文与其 IconActions 之间渲染该行:一个安静的标签、至多六枚 chip文本为文件名完整路径作为 `title`),超出上限则显示一个明确的剩余计数。每枚 chip 经由 owner 提供的 `openFile` 打开——与工具行相同的 Host 打开器chat 视图会把相对路径按会话 cwd 解析。设计原理:[workspace 文件链接 Agent Note](../../../.agents/notes/implemented/feature/2026-07-31-web-workspace-file-links.md)。
收尾正文承载同一份词表。本插件提供 chat 视图按收尾消息查询的 `chatFileMentions` service`producedFileMentions` 按精确路径解析行内代码 token或当 token 恰好是且仅是一条产出路径的 basename 时解析——两条路径共享的 basename 保持死文本而不猜测,因此提及链接永远不会打开错误的文件或 404。解析成功的提及保留 code 胶囊并采用 markdown 样式表的链接语言——静止为链接蓝、悬停出下划线,与 URL 提升的行内代码完全一致——完整路径作为其 `title`;提及绝不会渲染在锚点内部或流式文本里。决策记录:[行内文件提及 Agent Note](../../../.agents/notes/implemented/feature/2026-08-07-web-inline-file-mentions.md)。
## 模型体验
无。该行是对已记录工具元数据的纯客户端派生,这里没有任何内容进入模型请求。
@@ -18,4 +20,4 @@
## 已知限制与暂缓事项
- **正文提及仍是死文本。**收尾消息里以行内代码写出的文件名尚不能点击打开;把它接到同一份 `locations` 词表是 stacked 的后续工作
- **提及匹配只认精确路径或唯一 basename。**后缀式提及(`out/index.html` 写作 `index.html` 可解析;`deep/out/index.html` 写作 `out/index.html` 则不行)保持死文本;放宽匹配器等真实的收尾消息形态需要时再做

View File

@@ -6,18 +6,13 @@
import type { PropsLocale } from '@deepseek-ai/dsh-client-ui-slots'
import type { TurnTailOwnerProps } from '@deepseek-ai/dsh-client-ui-conversation/client'
import { basename } from './turn-deliverables.ts'
import type { NS } from './locales.ts'
import css from './ProducedFiles.module.css'
/** Files past this stay counted but unlisted: a refactor turn must not bury the answer. */
const SHOWN = 6
/** Trailing path segment, the part that identifies the file at a glance. */
function basename(path: string): string {
const at = Math.max(path.lastIndexOf('/'), path.lastIndexOf('\\'))
return at === -1 ? path : path.slice(at + 1)
}
/** Matched paths plus the opener and locale seats needed to present them. */
export type ProducedFilesProps = Pick<TurnTailOwnerProps, 'openFile'> & {
matched: readonly string[]

View File

@@ -1,16 +1,18 @@
/**
* Deliverables plugin, browser half: registers the produced-files row into
* the chat view's turn-tail hole. All policy lives here — the derivation
* from the mutation tools' `locations`, the chip cap, and the copy — so
* composing this plugin out of cordis.yml removes the surface entirely; the
* owning view renders an empty hole at zero cost.
* the chat view's turn-tail chain, and provides the `chatFileMentions`
* service that links inline-code mentions of produced files in the closing
* prose. All policy lives here — the derivation from the mutation tools'
* `locations`, the mention matching, the chip cap, and the copy — so
* composing this plugin out of cordis.yml removes both surfaces entirely;
* the owning view renders an empty chain and inert prose at zero cost.
*/
import type { ClientContext } from '@deepseek-ai/dsh-client-runtime/client'
import type {} from '@deepseek-ai/dsh-client-ui-conversation/client'
import type { ChatFileMentions } from '@deepseek-ai/dsh-client-ui-conversation/client'
import type {} from '@deepseek-ai/dsh-client-locale/client'
import { ProducedFiles } from './ProducedFiles.tsx'
import { en, NS, zh, type DeliverablesKey } from './locales.ts'
import { selectProducedFiles } from './turn-deliverables.ts'
import { producedFileMentions, selectProducedFiles } from './turn-deliverables.ts'
declare module '@deepseek-ai/dsh-client-ui-slots' {
interface LocaleNamespaceMap {
@@ -39,4 +41,17 @@ export function apply(ctx: ClientContext): void {
locale: NS,
}, ProducedFiles),
)
// The prose side of the same vocabulary: the chat view reaches this face
// via ctx.get, so its absence — this plugin composed out — is the off state.
const t = ctx.locale.bind(NS)
const mentions: ChatFileMentions = {
forClosing(owner) {
// Same claim test the turn-tail chain entry runs: no produced files,
// no vocabulary — the two surfaces agree by construction.
const paths = selectProducedFiles(owner)
if (paths === null) return undefined
return producedFileMentions(paths, owner.openFile, path => t('produced.open', { name: path }))
},
}
ctx.provide('chatFileMentions', mentions)
}

View File

@@ -4,6 +4,7 @@
* own follow-along `locations`, never the closing prose.
*/
import type { ConversationNode, ToolResultNode } from '@deepseek-ai/dsh-client-runtime/client'
import type { MarkdownFileMentions } from '@deepseek-ai/dsh-client-ui-primitives'
import type { TurnTailOwnerProps } from '@deepseek-ai/dsh-client-ui-conversation/client'
/**
@@ -88,3 +89,45 @@ export function selectProducedFiles(owner: TurnTailOwnerProps): readonly string[
const paths = producedForClosing(nodes, seq)
return paths.length === 0 ? null : paths
}
/**
* Trailing path segment, the part that identifies the file at a glance.
* @param path - Slash- or backslash-separated path.
* @returns The final segment, or the whole string when separator-free.
*/
export function basename(path: string): string {
const at = Math.max(path.lastIndexOf('/'), path.lastIndexOf('\\'))
return at === -1 ? path : path.slice(at + 1)
}
/**
* File-mention vocabulary over one turn's produced paths, for the closing
* message's prose: an inline-code token opens the file it names. A token
* resolves by exact path, or by being exactly the basename of exactly one
* produced path — a basename two paths share stays inert rather than
* guessing, so a mention link can never open the wrong file or 404.
* @param paths - The turn's produced paths (tool order, already deduped).
* @param openFile - The chat view's file opener.
* @param label - Localizes the accessible open-label for a resolved path.
* @returns The resolver MarkdownText consumes; the full path rides `title`,
* the same disambiguator the row's chips carry.
*/
export function producedFileMentions(
paths: readonly string[],
openFile: (path: string) => void,
label: (path: string) => string,
): MarkdownFileMentions {
return {
resolve(value) {
const path = paths.includes(value) ? value : onlyPathWithBasename(paths, value)
if (path === undefined) return undefined
return { open: () => { openFile(path) }, label: label(path), title: path }
},
}
}
/** The single produced path whose basename is exactly `value`, else undefined. */
function onlyPathWithBasename(paths: readonly string[], value: string): string | undefined {
const matches = paths.filter(path => basename(path) === value)
return matches.length === 1 ? matches[0] : undefined
}

View File

@@ -13,9 +13,10 @@ import type {
AssistantMessageNode, ConversationNode, ToolResultNode, UserMessageNode,
} from '@deepseek-ai/dsh-client-runtime/client'
import { apply as applyLocale } from '@deepseek-ai/dsh-client-locale/client'
import type { ChatFileMentions } from '@deepseek-ai/dsh-client-ui-conversation/client'
import { makeTranslate } from '@deepseek-ai/dsh-client-test-runtime'
import { ProducedFiles } from '../src/client/ProducedFiles.tsx'
import { producedForClosing, selectProducedFiles } from '../src/client/turn-deliverables.ts'
import { basename, producedFileMentions, producedForClosing, selectProducedFiles } from '../src/client/turn-deliverables.ts'
import { apply, inject } from '../src/client/index.ts'
import { apply as applyNode } from '../src/index.ts'
import { apply as applyInvariant } from '../src/invariant.ts'
@@ -37,7 +38,7 @@ const toolResult = (seq: number, callId: string, name = 'bash'): ToolResultNode
kind: 'tool-result', seq, time: seq * 1_000, callId,
call: { name, argsRaw: `{"command":"cmd-${callId}","description":"run ${callId}"}` },
callTime: seq * 1_000 - 500,
content: [], isError: false, callView: null, resultView: null,
content: [], isError: false, callView: null, resultView: null, subCalls: [],
})
const wrote = (seq: number, callId: string, ...paths: string[]): ToolResultNode => ({
...toolResult(seq, callId, 'write'),
@@ -142,6 +143,33 @@ describe('ProducedFiles row', () => {
})
})
describe('producedFileMentions resolver', () => {
const label = (path: string) => `打开 ${path}`
it('resolves exact paths and unique basenames; ambiguity and unknowns stay unresolved', () => {
const opened: string[] = []
const resolver = producedFileMentions(
['out/index.html', 'a/style.css', 'b/style.css'],
(path) => { opened.push(path) },
label,
)
// Unique basename resolves to its full path; the full path rides title.
const byBasename = resolver.resolve('index.html')
expect(byBasename?.label).toBe('打开 out/index.html')
expect(byBasename?.title).toBe('out/index.html')
byBasename?.open()
expect(opened).toEqual(['out/index.html'])
// An exact path resolves even when its basename is ambiguous.
const exact = resolver.resolve('a/style.css')
expect(exact?.title).toBe('a/style.css')
// A basename two paths share stays unresolved rather than guessing,
// and so does a token naming nothing the turn wrote.
expect(resolver.resolve('style.css')).toBeUndefined()
expect(resolver.resolve('notes.md')).toBeUndefined()
expect(basename('a\\b\\c.txt')).toBe('c.txt')
})
})
describe('package shells', () => {
it('the node half mounts inert and the invariant companion registers ownership', async () => {
// The node half is deliberately inert; mounting it must simply not throw.
@@ -173,7 +201,24 @@ describe('plugin registration', () => {
await fiber.await()
expect(ctx.slots.entries('conversation.chat.turnTail')).toHaveLength(1)
// The prose face is live while the plugin is: a produced turn yields a
// resolver whose matches open through the owner-supplied opener.
const opened: string[] = []
const owner = {
nodes: [user(1, 'go'), wrote(2, 'w', 'site/report.html'), assistant(3, 'done', 1)],
seq: 3,
openFile: (path: string) => { opened.push(path) },
}
const service = (ctx as unknown as { get(name: string): ChatFileMentions | undefined }).get('chatFileMentions')
const mentions = service?.forClosing(owner)
mentions?.resolve('report.html')?.open()
expect(opened).toEqual(['site/report.html'])
// A turn that produced nothing yields no vocabulary at all.
expect(service?.forClosing({ ...owner, nodes: [user(1, 'hi'), assistant(2, 'ok', 1)], seq: 2 })).toBeUndefined()
await fiber.dispose()
expect(ctx.slots.entries('conversation.chat.turnTail')).toHaveLength(0)
// Fiber teardown retracts the service: the consumer's ctx.get sees the off state.
expect((ctx as unknown as { get(name: string): unknown }).get('chatFileMentions')).toBeUndefined()
})
})

View File

@@ -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-primitives/README.md
README.md: fae49d5764d4001f1852cb43aab730064febf2d2
README.zh.md: 37984b9020df08b8804306111ca13ec0e92e5ce7
README.md: 098a202a4ac9ee263ce1beaaee7a8624ebf26b80
README.zh.md: 2b33af3316dede35cb5a226e41365f692a8b25d3

View File

@@ -10,7 +10,7 @@ Pure React atoms (zero cordis): StateDot, DisclosureRow, ic_ds_* icons, Button/P
## Markdown rendering
`MarkdownText` renders GFM and `$…$`, `$$…$$`, `\(…\)`, and `\[…\]` TeX math from untrusted assistant output through React elements, with math typeset by KaTeX and trusted commands disabled; block-level same-line `$$…$$` is display math, including `\tag{}`. A narrow micromark extension lets asterisk strong emphasis ending in punctuation close before adjacent CJK text, where prose normally omits the whitespace CommonMark requires; single-asterisk emphasis, non-CJK adjacency, escapes, code, and math retain upstream parsing. It omits raw HTML, neutralizes relative and non-HTTP(S)/mailto links, opens HTTP(S) links with safe external-link attributes, and renders absolute HTTP(S) images without a referrer; relative paths, absolute local paths, `file:` URLs, and unsupported schemes retain their alt text. Inline code whose complete value is an absolute HTTP(S) URL keeps its code styling and gains the same safe external anchor; commands, partial URLs, other schemes, and fenced code remain inert. While a reply streams, `MarkdownText` parses incrementally: all but the trailing two blocks freeze as cached React elements and only the source tail behind them re-parses per chunk, so per-chunk work tracks the tail instead of the whole reply ([mechanism and DOM-parity contract](../../../.agents/notes/implemented/architecture/2026-08-06-web-markdown-incremental-ast-renderer.md)). `MessageText` remains the literal-text primitive for user-authored content. `extractMarkdownPlainText` removes Markdown presentation markup for compact labels while preserving raw HTML as literal text. Element spacing, responsive images, tables, links, and inline code use the same `--dsw-alias-markdown-*` / `--dsw-font-markdown-*` tokens as deepsuite `@deepseek/md`. Fenced blocks render through `CodeBlock` (language banner, copy control, shiki for the registered grammars).
`MarkdownText` renders GFM and `$…$`, `$$…$$`, `\(…\)`, and `\[…\]` TeX math from untrusted assistant output through React elements, with math typeset by KaTeX and trusted commands disabled; block-level same-line `$$…$$` is display math, including `\tag{}`. A narrow micromark extension lets asterisk strong emphasis ending in punctuation close before adjacent CJK text, where prose normally omits the whitespace CommonMark requires; single-asterisk emphasis, non-CJK adjacency, escapes, code, and math retain upstream parsing. It omits raw HTML, neutralizes relative and non-HTTP(S)/mailto links, opens HTTP(S) links with safe external-link attributes, and renders absolute HTTP(S) images without a referrer; relative paths, absolute local paths, `file:` URLs, and unsupported schemes retain their alt text. Inline code whose complete value is an absolute HTTP(S) URL keeps its code styling and gains the same safe external anchor; commands, partial URLs, other schemes, and fenced code remain inert. An optional `fileMentions` resolver lets the owning view link inline code that names a real file: the token keeps code styling and gains a button wired to the resolved opener, with the resolver's accessible label and full-path `title`. The renderer never guesses at what looks like a path — an unresolved token stays inert, mentions apply to settled renders only (the streaming cache must not bake in handlers that could go stale), and a token inside an anchor stays inert because a button cannot nest there. While a reply streams, `MarkdownText` parses incrementally: all but the trailing two blocks freeze as cached React elements and only the source tail behind them re-parses per chunk, so per-chunk work tracks the tail instead of the whole reply ([mechanism and DOM-parity contract](../../../.agents/notes/implemented/architecture/2026-08-06-web-markdown-incremental-ast-renderer.md)). `MessageText` remains the literal-text primitive for user-authored content. `extractMarkdownPlainText` removes Markdown presentation markup for compact labels while preserving raw HTML as literal text. Element spacing, responsive images, tables, links, and inline code use the same `--dsw-alias-markdown-*` / `--dsw-font-markdown-*` tokens as deepsuite `@deepseek/md`. Fenced blocks render through `CodeBlock` (language banner, copy control, shiki for the registered grammars).
## Terminal output

View File

@@ -10,7 +10,7 @@
## Markdown 渲染
`MarkdownText` 通过 React 元素渲染来自不受信任 assistant 输出的 GFM 与 `$…$``$$…$$``\(…\)``\[…\]` TeX 公式,公式由 KaTeX 排版并禁用受信任命令;块级同一行 `$$…$$` 是显示公式并支持 `\tag{}`。一个小范围的 micromark 扩展允许由星号标记、以标点结尾的粗体在紧邻的 CJK 文本前闭合,以适应 CJK 文本通常省略 CommonMark 所要求空格的写法;单星号强调、紧邻非 CJK 文本的情况、转义、代码与数学公式仍沿用上游解析行为。它会省略原始 HTML使相对链接及非 HTTP(S)/mailto 链接失效,以安全的外部链接属性打开 HTTP(S) 链接,并在不发送 referrer 的情况下渲染采用绝对 HTTP(S) URL 的图片;相对路径、绝对本地路径、`file:` URL 与不受支持的 scheme 会保留其 alt 文本。完整内容为绝对 HTTP(S) URL 的行内代码会保留代码样式,并获得同样安全的外部链接;命令、非完整 URL、其他 scheme 与围栏代码仍不会成为链接。回复流式输出期间,`MarkdownText` 增量解析:除末尾两个块外全部冻结为缓存的 React 元素,每个分片只重新解析其后的源文本尾部,因此每分片的工作量跟随尾部而非整个回复([机制与 DOM 一致性契约](../../../.agents/notes/implemented/architecture/2026-08-06-web-markdown-incremental-ast-renderer.md))。`MessageText` 仍是用户创作内容使用的字面文本原语。`extractMarkdownPlainText` 会移除 Markdown 呈现标记以用于紧凑标签,同时将原始 HTML 保留为字面文本。元素间距、响应式图片、表格、链接与行内代码使用与 deepsuite `@deepseek/md` 相同的 `--dsw-alias-markdown-*` / `--dsw-font-markdown-*` token。围栏代码块通过 `CodeBlock` 渲染(语言横幅、复制控件,以及对已注册语法使用 shiki
`MarkdownText` 通过 React 元素渲染来自不受信任 assistant 输出的 GFM 与 `$…$``$$…$$``\(…\)``\[…\]` TeX 公式,公式由 KaTeX 排版并禁用受信任命令;块级同一行 `$$…$$` 是显示公式并支持 `\tag{}`。一个小范围的 micromark 扩展允许由星号标记、以标点结尾的粗体在紧邻的 CJK 文本前闭合,以适应 CJK 文本通常省略 CommonMark 所要求空格的写法;单星号强调、紧邻非 CJK 文本的情况、转义、代码与数学公式仍沿用上游解析行为。它会省略原始 HTML使相对链接及非 HTTP(S)/mailto 链接失效,以安全的外部链接属性打开 HTTP(S) 链接,并在不发送 referrer 的情况下渲染采用绝对 HTTP(S) URL 的图片;相对路径、绝对本地路径、`file:` URL 与不受支持的 scheme 会保留其 alt 文本。完整内容为绝对 HTTP(S) URL 的行内代码会保留代码样式,并获得同样安全的外部链接;命令、非完整 URL、其他 scheme 与围栏代码仍不会成为链接。可选的 `fileMentions` 解析器让持有视图把命名真实文件的行内代码变成链接token 保留代码样式并获得接到所解析 opener 的按钮,带解析器给出的无障碍标签与完整路径 `title`。渲染器绝不猜测什么长得像路径——未解析的 token 保持原样,提及只作用于已定稿的渲染(流式缓存不能烘进可能过期的 handler锚点内部的 token 也保持原样,因为按钮不能嵌套在链接里。回复流式输出期间,`MarkdownText` 增量解析:除末尾两个块外全部冻结为缓存的 React 元素,每个分片只重新解析其后的源文本尾部,因此每分片的工作量跟随尾部而非整个回复([机制与 DOM 一致性契约](../../../.agents/notes/implemented/architecture/2026-08-06-web-markdown-incremental-ast-renderer.md))。`MessageText` 仍是用户创作内容使用的字面文本原语。`extractMarkdownPlainText` 会移除 Markdown 呈现标记以用于紧凑标签,同时将原始 HTML 保留为字面文本。元素间距、响应式图片、表格、链接与行内代码使用与 deepsuite `@deepseek/md` 相同的 `--dsw-alias-markdown-*` / `--dsw-font-markdown-*` token。围栏代码块通过 `CodeBlock` 渲染(语言横幅、复制控件,以及对已注册语法使用 shiki
## 终端输出

View File

@@ -42,7 +42,7 @@ export { CodeBlock } from './markdown/CodeBlock.tsx'
export type { CodeBlockProps } from './markdown/CodeBlock.tsx'
export { JsonBlock } from './markdown/JsonBlock.tsx'
export { MarkdownText } from './markdown/MarkdownText.tsx'
export type { MarkdownCodeLabels } from './markdown/MarkdownText.tsx'
export type { MarkdownCodeLabels, MarkdownFileMentions } from './markdown/MarkdownText.tsx'
export { MessageText } from './markdown/MessageText.tsx'
export { extractMarkdownPlainText } from './markdown/plain-text.ts'
export type { MarkdownPlainTextMode, MarkdownPlainTextOptions } from './markdown/plain-text.ts'

View File

@@ -241,3 +241,25 @@
background: var(--dsw-alias-bg-base);
object-fit: contain;
}
/* Inline file mention: the same link language this sheet gives anchors (and
thereby URL-promoted inline code) — link-blue at rest, underline only on
hover/focus. An underline at rest reads badly inside the code chip, where
it collides with monospace descenders and the pill background. */
.fileMention {
margin: 0;
padding: 0;
border: none;
background: none;
font: inherit;
color: var(--dsw-alias-state-business-primary);
text-decoration: none;
cursor: pointer;
}
.fileMention:hover,
.fileMention:focus {
outline: none;
text-decoration: underline var(--dsw-alias-state-business-primary);
text-underline-offset: 3px;
}

View File

@@ -19,20 +19,25 @@ import {
collectReferenceTargets, createReferenceTargets, renderBlocks, renderFootnoteSection,
wrapBlockChildren,
} from './render.tsx'
import type { MarkdownCodeLabels, MarkdownRenderContext, ReferenceTargets } from './render.tsx'
import type { MarkdownCodeLabels, MarkdownFileMentions, MarkdownRenderContext, ReferenceTargets } from './render.tsx'
import 'katex/dist/katex.min.css'
import css from './MarkdownText.module.css'
export type { MarkdownCodeLabels } from './render.tsx'
export type { MarkdownCodeLabels, MarkdownFileMentions } from './render.tsx'
/** One settled full render: parse with math, resolve references, append the footnote section. */
function renderSettled(text: string, codeLabels: MarkdownCodeLabels | undefined): ReactNode[] {
function renderSettled(
text: string,
codeLabels: MarkdownCodeLabels | undefined,
fileMentions: MarkdownFileMentions | undefined,
): ReactNode[] {
const root = parseGfmWithMath(text)
const targets = createReferenceTargets()
collectReferenceTargets(root.children, targets)
const context: MarkdownRenderContext = {
streaming: false,
codeLabels,
fileMentions,
targets,
footnoteOrder: [],
footnoteCounts: new Map(),
@@ -96,6 +101,7 @@ class StreamingRenderer {
const frozenContext: MarkdownRenderContext = {
streaming: true,
codeLabels: this.codeLabels,
fileMentions: undefined,
targets: frameTargets,
footnoteOrder: this.frozenFootnoteOrder,
footnoteCounts: this.frozenFootnoteCounts,
@@ -113,6 +119,7 @@ class StreamingRenderer {
const tailContext: MarkdownRenderContext = {
streaming: true,
codeLabels: this.codeLabels,
fileMentions: undefined,
targets: frameTargets,
footnoteOrder: [...this.frozenFootnoteOrder],
footnoteCounts: new Map(this.frozenFootnoteCounts),
@@ -137,28 +144,33 @@ class StreamingRenderer {
* the finalize swap) and parses incrementally across chunks; `codeLabels`
* forwards localized copy-button labels to fence CodeBlocks — pass a
* reference-stable object (memoized per locale revision), because a new
* identity discards the streaming render cache mid-message.
* identity discards the streaming render cache mid-message. `fileMentions`
* links inline-code tokens its resolver recognizes as real files; this is
* the single streaming gate — it applies to settled renders only, because a
* streaming message's vocabulary is not final and frozen cached elements
* must not bake in handlers that could go stale.
* @returns A GFM document with TeX math rendered through KaTeX; raw HTML,
* relative links, and unsafe protocols are disabled, while absolute HTTP(S)
* images render directly.
*/
export const MarkdownText = memo(function MarkdownText({ text, streaming = false, codeLabels }: {
export const MarkdownText = memo(function MarkdownText({ text, streaming = false, codeLabels, fileMentions }: {
text: string
streaming?: boolean
codeLabels?: MarkdownCodeLabels | undefined
fileMentions?: MarkdownFileMentions | undefined
}) {
const streamRef = useRef<StreamingRenderer | null>(null)
const streamLabelsRef = useRef<MarkdownCodeLabels | undefined>(codeLabels)
const children = useMemo(() => {
if (!streaming) {
streamRef.current = null
return renderSettled(text, codeLabels)
return renderSettled(text, codeLabels, fileMentions)
}
if (streamRef.current === null || streamLabelsRef.current !== codeLabels) {
streamRef.current = new StreamingRenderer(codeLabels)
streamLabelsRef.current = codeLabels
}
return streamRef.current.render(text)
}, [text, streaming, codeLabels])
}, [text, streaming, codeLabels, fileMentions])
return <div className={css.markdown}>{children}</div>
})

View File

@@ -99,6 +99,21 @@ export function collectReferenceTargets(
}
}
/**
* File-mention affordance for inline code: the owner resolves an authored
* token to the file it names, using its own vocabulary of real files — the
* renderer never guesses at what looks like a path.
*/
export interface MarkdownFileMentions {
/**
* Resolve one inline-code token.
* @param value - The authored token, exactly as written.
* @returns The opener with its accessible label and full-path title, or
* undefined when the token names no known file — it then stays inert code.
*/
resolve(value: string): { open: () => void; label: string; title: string } | undefined
}
/**
* One render pass's state: immutable options and targets plus the footnote
* numbering accumulated in document order while references render.
@@ -108,6 +123,10 @@ export interface MarkdownRenderContext {
readonly streaming: boolean
/** Localized fence copy-button labels. */
readonly codeLabels: MarkdownCodeLabels | undefined
/** Inline-code file mentions; absent wherever no opener vocabulary exists. */
readonly fileMentions: MarkdownFileMentions | undefined
/** Inside an anchor's children: interactive mentions must not nest there. */
readonly inLink?: boolean
/** Reference targets visible to this pass. */
readonly targets: ReferenceTargets
/** Footnote identifiers in first-reference order; a footnote's number is its 1-based index here. */
@@ -217,7 +236,27 @@ function renderNode(node: Md.RootContent, key: Key, context: MarkdownRenderConte
// authored text, not a parsed destination, so no normalizeUri: port,
// path, and query render unchanged.
const href = inlineCodeHttpUrl(value)
return <code key={key}>{href === undefined ? value : renderSafeLink(href, [value], 'link')}</code>
if (href !== undefined) return <code key={key}>{renderSafeLink(href, [value], 'link')}</code>
// A token the owner's file-mention vocabulary recognizes opens that
// file; the resolver, not this renderer, decides what names a file.
// Inside an anchor the token stays inert — a button cannot nest there.
const mention = context.inLink === true ? undefined : context.fileMentions?.resolve(value)
if (mention !== undefined) {
return (
<code key={key}>
<button
type="button"
className={css.fileMention}
title={mention.title}
aria-label={mention.label}
onClick={mention.open}
>
{value}
</button>
</code>
)
}
return <code key={key}>{value}</code>
}
case 'html':
// No HTML parser enters the pipeline: raw HTML stays literal text.
@@ -236,7 +275,7 @@ function renderNode(node: Md.RootContent, key: Key, context: MarkdownRenderConte
case 'table':
return renderTable(node, key, context)
case 'link':
return renderAnchor(node.url, renderChildren(node.children, context), key)
return renderAnchor(node.url, renderChildren(node.children, { ...context, inLink: true }), key)
case 'linkReference':
return renderLinkReference(node, key, context)
case 'image':
@@ -460,14 +499,14 @@ function renderLinkReference(
context: MarkdownRenderContext,
): ReactNode {
const definition = context.targets.definitions.get(node.identifier.toUpperCase())
const children = renderChildren(node.children, context)
if (definition === undefined) {
// The grammar only emits references whose definitions exist somewhere in
// the same parse, but incremental segments and hand-built trees may still
// present unresolved ones: revert to the bracketed source text.
return <Fragment key={key}>{'['}{children}{referenceSuffix(node)}</Fragment>
// present unresolved ones: revert to the bracketed source text — which is
// not an anchor, so mentions inside it stay live.
return <Fragment key={key}>{'['}{renderChildren(node.children, context)}{referenceSuffix(node)}</Fragment>
}
return renderAnchor(definition.url, children, key)
return renderAnchor(definition.url, renderChildren(node.children, { ...context, inLink: true }), key)
}
function renderImageReference(

View File

@@ -20,6 +20,7 @@ function makeContext(): MarkdownRenderContext {
return {
streaming: false,
codeLabels: undefined,
fileMentions: undefined,
targets: createReferenceTargets(),
footnoteOrder: [],
footnoteCounts: new Map(),

View File

@@ -148,6 +148,49 @@ describe('MarkdownText', () => {
expect(container.querySelector('pre code a')).toBeNull()
})
it('links inline code through the file-mention resolver: URL first, settled only, never inside links', () => {
const opened: string[] = []
const fileMentions = {
resolve: (value: string) => value === 'index.html' || value === 'out/index.html'
? { open: () => { opened.push(value) }, label: 'Open out/index.html', title: 'out/index.html' }
: undefined,
}
const source = [
'`index.html`',
'`other.css`',
'`https://example.com/`',
// Inside an anchor the mention stays inert code: a button cannot nest there.
'[see `out/index.html`](https://example.com/doc)',
'[ref `out/index.html`][target]',
'[target]: https://example.com/ref',
'```',
'index.html',
'```',
].join('\n\n')
const { container } = render(<MarkdownText text={source} fileMentions={fileMentions} />)
const mention = screen.getByRole('button', { name: 'Open out/index.html' })
expect(mention.closest('code')).not.toBeNull()
// The full path rides title, the same disambiguator the row's chips carry.
expect(mention.getAttribute('title')).toBe('out/index.html')
fireEvent.click(mention)
expect(opened).toEqual(['index.html'])
// Exactly one live mention: the two inside anchors declined, and an
// unresolved token plus fenced code stay inert.
expect(container.querySelectorAll('code button')).toHaveLength(1)
expect(container.querySelectorAll('a code button, a button')).toHaveLength(0)
expect(screen.getByText('other.css').closest('button')).toBeNull()
// URL promotion wins before the resolver sees a token.
expect(screen.getByText('https://example.com/').closest('a')).not.toBeNull()
// Streaming renders keep mentions off — the one gate lives here: cached
// frozen elements must not bake in handlers that could go stale.
const streamed = render(
<MarkdownText text={'`index.html`\n\nmore\n\n'} streaming fileMentions={fileMentions} />,
)
expect(streamed.container.querySelector('button')).toBeNull()
})
it('exposes the CJK strong syntax as a micromark extension needing CommonMark attention markers', () => {
const extension = cjkFriendlyStrong()
expect(cjkFriendlyStrong()).toBe(extension)

View File

@@ -28,13 +28,14 @@ function settled(over: Partial<ToolResultNode> = {}): ToolResultNode {
isError: false,
callView: null,
resultView: null,
subCalls: [],
...over,
}
}
function running(argsRaw = '{"name":"dsh-manage-issues"}'): RunningToolCall {
return {
callId: 'call-skill', name: 'skill', argsRaw, turn: 1, step: 1, time: 2_000, callView: null,
callId: 'call-skill', name: 'skill', argsRaw, turn: 1, step: 1, time: 2_000, callView: null, subCalls: [],
}
}

View File

@@ -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: d6bc0f248cffaf4c65ecf6d97c7a4afb17fc8941
README.zh.md: f4e8044fe6e10e1fbdfb70987efe07c884fa9ac8

View File

@@ -4,11 +4,11 @@ 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.
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.
@@ -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.
- The Host excludes `run_code` from Code Mode program bindings, so production events currently produce one dispatch level; the recursive Runtime/UI contract is ready for future nested producers.
- 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.

View File

@@ -4,11 +4,11 @@
Client Tool 展示插件。`ui-conversation` 通过 `conversation.chat.tool` 交付一个已经排好位置的 root call本包渲染该 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 使用。
@@ -44,6 +44,6 @@ owner 载荷为 `ToolCallOwnerProps``callId`、`toolName`、冻结的 `block`
## 已知限制与后续工作
- Runtime 当前只暴露一层 Code Dispatch 子调用。renderer 会让 root 和 child 经过同一个原子分发路径,但不宣称 wire 拓扑已经支持任意递归
- Host 不把 `run_code` 暴露为 Code Mode 程序 binding因此生产事件目前只能产生一层分发递归的运行时/UI 契约已为未来的嵌套生产者做好准备
- 现有第一方 Tool 视图初期仍集中在本包,之后可以通过 keyed slot 独立迁回各自业务包。
- Tool 文案暂时复用 `ui-conversation` locale namespace。

View File

@@ -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,26 @@ 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, block, selectedCallId, cwd, openFile, inspectCall, t,
}: ToolTreeProps) {
return (
<ToolCallBranch
renderSlot={renderSlot}
block={block}
selectedCallId={selectedCallId}
cwd={cwd}
openFile={openFile}
inspectCall={inspectCall}
t={t}
/>
)
}

View File

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

View File

@@ -40,7 +40,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 +50,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,
})

View File

@@ -13,8 +13,8 @@ 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,
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'
@@ -48,27 +48,35 @@ 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, 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,
@@ -165,11 +173,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 +201,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 +222,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 +238,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 +249,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 +266,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 +280,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 +298,9 @@ describe('run_code sub-calls through the real chat machinery', () => {
kind: 'tool-result', seq: 10, time: 10_000, callId: parent,
call: { name: 'mystery', argsRaw: '{"n":1}' },
callTime: 9_500,
content: [], isError: false, callView: null, resultView: null,
content: [], isError: false, callView: null, resultView: null, subCalls: [],
}
const b = await bench(snapshotWith([plain], new Map()))
const b = await bench(snapshotWith([plain], []))
const view = mountApp(b.slots)
expect(view.container.querySelector('[data-subcalls]')).toBeNull()
})

View File

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

View File

@@ -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', () => {
@@ -343,7 +343,7 @@ describe('DetailsPanel diff Output section', () => {
function snapshot(over: Partial<ConversationSnapshot> = {}): ConversationSnapshot {
return {
sessionId: SID, nodes: [], turnTimings: new Map(), turnEnds: new Map(), partial: null, runningCalls: [], codeDispatches: new Map(),
sessionId: SID, nodes: [], turnTimings: new Map(), turnEnds: new Map(), partial: null, runningCalls: [],
pending: [], queue: [], running: false, composerPhase: 'active', removed: false,
openState: 'open', openError: null, hasMore: false, loadingOlder: false,
promptError: null, blank: false, subagent: null, lastAgentError: null, ...over,

View File

@@ -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', () => {
@@ -289,7 +289,7 @@ describe('DetailsPanel Output section (read)', () => {
function snapshot(over: Partial<ConversationSnapshot> = {}): ConversationSnapshot {
return {
sessionId: SID, nodes: [], turnTimings: new Map(), turnEnds: new Map(), partial: null, runningCalls: [], codeDispatches: new Map(),
sessionId: SID, nodes: [], turnTimings: new Map(), turnEnds: new Map(), partial: null, runningCalls: [],
pending: [], queue: [], running: false, composerPhase: 'active', removed: false,
openState: 'open', openError: null, hasMore: false, loadingOlder: false,
promptError: null, blank: false, subagent: null, lastAgentError: null, ...over,

View File

@@ -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', () => {
@@ -405,7 +405,7 @@ describe('DetailsPanel Output section (search)', () => {
function snapshot(over: Partial<ConversationSnapshot> = {}): ConversationSnapshot {
return {
sessionId: SID, nodes: [], turnTimings: new Map(), turnEnds: new Map(), partial: null, runningCalls: [], codeDispatches: new Map(),
sessionId: SID, nodes: [], turnTimings: new Map(), turnEnds: new Map(), partial: null, runningCalls: [],
pending: [], queue: [], running: false, composerPhase: 'active', removed: false,
openState: 'open', openError: null, hasMore: false, loadingOlder: false,
promptError: null, blank: false, subagent: null, lastAgentError: null, ...over,

View File

@@ -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', () => {
@@ -480,7 +480,7 @@ describe('DetailsPanel Output section', () => {
function snapshot(over: Partial<ConversationSnapshot> = {}): ConversationSnapshot {
return {
sessionId: SID, nodes: [], turnTimings: new Map(), turnEnds: new Map(), partial: null, runningCalls: [], codeDispatches: new Map(),
sessionId: SID, 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 +568,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 +590,10 @@ describe('DetailsPanel Output section', () => {
it('a running run_code sub-dispatch resolves through the running material', () => {
const view = mount(snapshot({
// The leading non-matching sub-call exercises the scan's skip.
codeDispatches: new Map([['p1', [running({ callId: 'other' }), running()]]]),
runningCalls: [running({
callId: 'p1',
subCalls: [running({ callId: 'other' }), running()],
})],
}), target)
expect(view.getByText('ls -la')).toBeTruthy()
})

View File

@@ -61,7 +61,7 @@ describe('planSummary', () => {
const resultNode = (argsRaw: string, over?: Partial<ToolResultNode>): ToolResultNode => ({
kind: 'tool-result', seq: 10, time: 2_000, callTime: 1_000, callId: 'c1',
call: { name: 'todo_write', argsRaw },
content: [], isError: false, callView: null, resultView: null, ...over,
content: [], isError: false, callView: null, resultView: null, subCalls: [], ...over,
})
function rowProps(block: unknown): TodoRowProps {
@@ -106,7 +106,7 @@ describe('TodoRow', () => {
it('keeps non-ok execution states visible through the shared row states', () => {
const args = JSON.stringify({ todos: LIST })
const running = render(<TodoRow {...rowProps({ callId: 'c1', name: 'todo_write', argsRaw: args, turn: 1, step: 1, time: 1_000, callView: null })} />)
const running = render(<TodoRow {...rowProps({ callId: 'c1', name: 'todo_write', argsRaw: args, turn: 1, step: 1, time: 1_000, callView: null, subCalls: [] })} />)
expect(running.container.querySelector('[data-state="running"]')).not.toBeNull()
expect(running.container.querySelector('[data-state="running"] svg')).not.toBeNull()
running.unmount()

View File

@@ -2,7 +2,7 @@
/** ToolCallTree-owned root/subcall markers and selection projection. */
import { afterEach, describe, expect, it, vi } from 'vitest'
import { cleanup, render } from '@testing-library/react'
import type { CodeSubCall, ConversationSnapshot, ToolResultNode } from '@deepseek-ai/dsh-client-runtime/client'
import type { ConversationSnapshot, ToolResultNode } from '@deepseek-ai/dsh-client-runtime/client'
import { makeTranslate } from '@deepseek-ai/dsh-client-test-runtime'
import { zh as commonZh } from '@deepseek-ai/dsh-client-locale/src/locales/zh.ts'
import type { ToolTreeProps } from '../src/client/contract/slots.ts'
@@ -15,15 +15,14 @@ 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']
@@ -43,7 +42,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 +50,23 @@ describe('ToolCallTree', () => {
expect(view.getByText('w1')).toBeTruthy()
})
it('marks a selected subcall without selecting its root', () => {
const block = root('parent', { name: 'run_code', argsRaw: '{"code":"return 1"}' })
const child: CodeSubCall = root('parent:code:1', { name: 'read', argsRaw: '{"path":"a.ts"}' })
const view = render(
<ToolCallTree {...props(block, new Map([['parent', [child]]]), child.callId)} />,
)
expect(view.container.querySelector('[data-subcalls]')?.parentElement)
.toBe(view.container.querySelector('[data-chat-call-id="parent"]'))
it('recursively renders a selected leaf without selecting its ancestors', () => {
const leaf = root('parent:code:1:code:1', { name: 'read', argsRaw: '{"path":"a.ts"}' })
const child = {
...root('parent:code:1', { name: 'run_code', argsRaw: '{"code":"return 1"}' }),
subCalls: [leaf],
}
const block = {
...root('parent', { name: 'run_code', argsRaw: '{"code":"return 1"}' }),
subCalls: [child],
}
const view = render(<ToolCallTree {...props(block, leaf.callId)} />)
const nests = view.container.querySelectorAll('[data-subcalls]')
expect(nests[0]?.parentElement).toBe(view.container.querySelector('[data-chat-call-id="parent"]'))
expect(nests[1]?.parentElement).toBe(view.container.querySelector('[data-chat-call-id="parent:code:1"]'))
expect(view.container.querySelector('[data-chat-call-id="parent"]')?.hasAttribute('data-selected')).toBe(false)
expect(view.container.querySelector('[data-chat-call-id="parent:code:1"]')?.getAttribute('data-selected')).toBe('true')
expect(view.container.querySelector('[data-chat-call-id="parent:code:1"]')?.hasAttribute('data-selected')).toBe(false)
expect(view.container.querySelector('[data-chat-call-id="parent:code:1:code:1"]')?.getAttribute('data-selected')).toBe('true')
expect(nests).toHaveLength(2)
})
})

View File

@@ -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', () => {

View File

@@ -42,7 +42,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. */

View File

@@ -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', () => {
@@ -235,7 +235,7 @@ describe('DetailsPanel web Output section', () => {
function snapshot(over: Partial<ConversationSnapshot> = {}): ConversationSnapshot {
return {
sessionId: SID, nodes: [], turnTimings: new Map(), turnEnds: new Map(), partial: null, runningCalls: [], codeDispatches: new Map(),
sessionId: SID, nodes: [], turnTimings: new Map(), turnEnds: new Map(), partial: null, runningCalls: [],
pending: [], queue: [], running: false, composerPhase: 'active', removed: false,
openState: 'open', openError: null, hasMore: false, loadingOlder: false,
promptError: null, blank: false, subagent: null, lastAgentError: null, ...over,

View File

@@ -211,7 +211,6 @@ export function TrajectoryView({
const nodes = inspection.eventNodes
const partial = inspection.partial
const runningCalls = inspection.runningCalls
const codeDispatches = inspection.codeDispatches
const loadHistoryTailRef = useRef(loadHistoryTail)
loadHistoryTailRef.current = loadHistoryTail
const historyControllerRef = useRef<AbortController | null>(null)
@@ -366,12 +365,11 @@ export function TrajectoryView({
runningCalls,
requests: selectedRequests,
callSchemas,
codeDispatches,
})
return { turns, lastIndex: lastCellIndex(turns) }
}, [
selectedNodes, partialTurn, partialStep,
runningCalls, selectedRequests, callSchemas, codeDispatches,
runningCalls, selectedRequests, callSchemas,
])
const timelinePartialSignature = partialStructureSignature(partial)
const timelinePartial = useMemo<ConversationSnapshot['partial']>(() => partial === null

View File

@@ -5,11 +5,11 @@
import type {
AssistantBlock,
AssistantMessageNode,
CodeSubCall,
ConversationSnapshot,
RequestInspectionSnapshot,
RequestPromptChange,
RequestView,
ToolCallBlock,
ToolResultNode,
} from '@deepseek-ai/dsh-client-runtime/client'
import { extractMarkdownPlainText } from '@deepseek-ai/dsh-client-ui-primitives'
@@ -39,8 +39,6 @@ export interface TrajectoryLayoutInput {
runningCalls: ConversationSnapshot['runningCalls']
requests?: readonly RequestView[]
callSchemas?: RequestInspectionSnapshot['callSchemas']
/** run_code sub-dispatches by parent callId (sub-cells nest under the parent Tool cell). */
codeDispatches: ConversationSnapshot['codeDispatches']
}
interface UsageLike {
@@ -57,6 +55,7 @@ interface LaidCell {
absTime: number | null
toolName?: string
callId?: string
subCalls?: readonly ToolCallBlock[]
}
interface LaidGroup {
@@ -131,9 +130,11 @@ function inputCellDetail(node: InputNode): Pick<
*/
export function deriveTrajectoryLayout(input: TrajectoryLayoutInput): readonly TrajectoryTurnModel[] {
const {
nodes, partial, runningCalls, requests = [], callSchemas, codeDispatches,
nodes, partial, runningCalls, requests = [], callSchemas,
} = input
const resultByCall = indexResults(nodes)
const callById = new Map<string, ToolCallBlock>(resultByCall)
for (const call of runningCalls) callById.set(call.callId, call)
const emittedCallIds = indexAssistantCallIds(nodes)
const callStartById = new Map<string, number>()
for (const result of resultByCall.values()) {
@@ -344,8 +345,7 @@ export function deriveTrajectoryLayout(input: TrajectoryLayoutInput): readonly T
}
if (node.kind === 'assistant') {
const laidList = withSubCalls(
expandAssistant(node, index + 1, prevAbsTime, resultByCall, callStartById),
codeDispatches,
expandAssistant(node, index + 1, prevAbsTime, resultByCall, callStartById, callById),
)
if (node.step > 0) pushStep(node.turn, node.step, laidList)
else for (const laid of laidList) pushMessage(node.turn, laid)
@@ -381,6 +381,7 @@ export function deriveTrajectoryLayout(input: TrajectoryLayoutInput): readonly T
absTime: finiteTime(node.callTime ?? node.time),
...(toolName !== undefined ? { toolName } : {}),
callId: node.callId,
subCalls: node.subCalls,
cell: {
index: ++index,
kind: 'tool',
@@ -398,7 +399,7 @@ export function deriveTrajectoryLayout(input: TrajectoryLayoutInput): readonly T
startedAt: finiteTime(node.callTime),
},
}]
for (const laid of expandSubCalls(codeDispatches.get(node.callId), index)) {
for (const laid of expandSubCalls(node.subCalls, index)) {
laidList.push(laid)
index = laid.cell.index
}
@@ -413,14 +414,15 @@ export function deriveTrajectoryLayout(input: TrajectoryLayoutInput): readonly T
kind: 'assistant', seq: Number.MAX_SAFE_INTEGER, time: 0,
turn: partial.turn, step: partial.step, blocks: partial.blocks,
}
const laidList = expandAssistant(
const laidList = withSubCalls(expandAssistant(
fake,
index + 1,
prevAbsTime,
resultByCall,
callStartById,
callById,
{ streaming: true },
)
))
if (partial.step > 0) pushStep(partial.turn, partial.step, laidList)
else for (const laid of laidList) pushMessage(partial.turn, laid)
const last = laidList[laidList.length - 1]
@@ -434,6 +436,7 @@ export function deriveTrajectoryLayout(input: TrajectoryLayoutInput): readonly T
absTime: null,
toolName: call.name,
callId: call.callId,
subCalls: call.subCalls,
cell: {
index: ++index,
kind: 'tool',
@@ -444,7 +447,7 @@ export function deriveTrajectoryLayout(input: TrajectoryLayoutInput): readonly T
startedAt: finiteTime(call.time),
},
}]
for (const laid of expandSubCalls(codeDispatches.get(call.callId), index)) {
for (const laid of expandSubCalls(call.subCalls, index)) {
laidList.push(laid)
index = laid.cell.index
}
@@ -491,7 +494,6 @@ export function appendTrajectoryPartialLayout(
nodes: [],
partial,
runningCalls: [],
codeDispatches: new Map(),
}).at(0)
if (partialTurn === undefined) return turns
const streamed: TrajectoryTurnModel = {
@@ -622,6 +624,7 @@ function expandAssistant(
prevAbsTime: number | null,
results: Map<string, ToolResultNode>,
callStarts: ReadonlyMap<string, number>,
calls: ReadonlyMap<string, ToolCallBlock>,
opts?: { streaming?: boolean },
): LaidCell[] {
if (opts?.streaming === true && node.blocks.length === 0) return []
@@ -677,10 +680,12 @@ function expandAssistant(
? null
: durationSeconds(result.time, result.callTime)
const callAbs = finiteTime(callStarts.get(block.callId))
const call = calls.get(block.callId)
out.push({
absTime: callAbs,
toolName: block.name,
callId: block.callId,
...(call === undefined ? {} : { subCalls: call.subCalls }),
cell: {
index: ++index, kind: 'tool',
text: summarizeCall(block.name, block.argsRaw),
@@ -883,15 +888,14 @@ function collectCallIds(
/** Interleave each tool cell's run_code sub-dispatch cells right after it, reindexing followers. */
function withSubCalls(laidList: LaidCell[], codeDispatches: ConversationSnapshot['codeDispatches']): LaidCell[] {
if (codeDispatches.size === 0) return laidList
/** Interleave each tool cell's nested child calls right after it, reindexing followers. */
function withSubCalls(laidList: LaidCell[]): LaidCell[] {
if (!laidList.some(laid => laid.subCalls !== undefined && laid.subCalls.length > 0)) return laidList
const out: LaidCell[] = []
let index = laidList[0] !== undefined ? laidList[0].cell.index - 1 : 0
for (const laid of laidList) {
out.push({ ...laid, cell: { ...laid.cell, index: ++index } })
if (laid.callId === undefined) continue
for (const sub of expandSubCalls(codeDispatches.get(laid.callId), index)) {
for (const sub of expandSubCalls(laid.subCalls, index)) {
out.push(sub)
index = sub.cell.index
}
@@ -901,7 +905,7 @@ function withSubCalls(laidList: LaidCell[], codeDispatches: ConversationSnapshot
/** Sub-dispatch cells for one run_code parent, in start order (running = null duration). */
function expandSubCalls(
subs: readonly CodeSubCall[] | undefined,
subs: readonly ToolCallBlock[] | undefined,
startIndex: number,
): LaidCell[] {
if (subs === undefined || subs.length === 0) return []
@@ -909,7 +913,7 @@ function expandSubCalls(
let index = startIndex
for (const sub of subs) {
const settled = 'kind' in sub
out.push({
const laid: LaidCell = {
absTime: settled ? finiteTime(sub.callTime ?? sub.time) : finiteTime(sub.time),
toolName: settled ? sub.call?.name ?? sub.callId : sub.name,
callId: sub.callId,
@@ -938,7 +942,12 @@ function expandSubCalls(
? finiteTime(sub.callTime)
: finiteTime(sub.time),
},
})
}
out.push(laid)
for (const child of expandSubCalls(sub.subCalls, index)) {
out.push(child)
index = child.cell.index
}
}
return out
}

View File

@@ -74,7 +74,7 @@ describe('deriveTrajectoryLayout', () => {
content: [{ type: 'text', text: 'a.txt' }], isError: false, callView: null, resultView: null,
},
] as unknown as ConversationSnapshot['nodes']
const turns = deriveTrajectoryLayout({ codeDispatches: new Map(), nodes, partial: null, runningCalls: [] })
const turns = deriveTrajectoryLayout({ nodes, partial: null, runningCalls: [] })
expect(turns).toHaveLength(1)
expect(turns[0]?.turn).toBe(1)
const kinds = turns[0]?.groups.flatMap(g => g.cells.map(c => c.kind))
@@ -90,12 +90,11 @@ describe('deriveTrajectoryLayout', () => {
it('adds runningCalls not already present and leaves their time blank', () => {
const turns = deriveTrajectoryLayout({
codeDispatches: new Map(),
nodes: [],
partial: null,
runningCalls: [{
callId: 'r1', name: 'bash', argsRaw: '{"command":"pwd"}',
turn: 1, step: 2, time: 9_000, callView: null,
turn: 1, step: 2, time: 9_000, callView: null, subCalls: [],
}],
})
expect(turns[0]?.groups.map(g => g.title)).toEqual(['Step 2'])
@@ -119,7 +118,6 @@ describe('deriveTrajectoryLayout', () => {
startedAt: 3_000, completedAt: null, status: 'running',
} as unknown as RequestView
const base = deriveTrajectoryLayout({
codeDispatches: new Map(),
nodes,
partial: { ...partial, blocks: [] },
requests: [request],
@@ -152,12 +150,11 @@ describe('deriveTrajectoryLayout', () => {
}],
}
const base = deriveTrajectoryLayout({
codeDispatches: new Map(),
nodes: [],
partial: { ...partial, blocks: [] },
runningCalls: [{
callId: 'c1', name: 'bash', argsRaw: '{"command":"pwd"}',
turn: 1, step: 1, time: 9_000, callView: null,
turn: 1, step: 1, time: 9_000, callView: null, subCalls: [],
}],
})
@@ -180,7 +177,7 @@ describe('deriveTrajectoryLayout', () => {
usage: { inputTokens: 1, outputTokens: 2, reasoningTokens: 3 },
},
] as unknown as ConversationSnapshot['nodes']
const turns = deriveTrajectoryLayout({ codeDispatches: new Map(), nodes, partial: null, runningCalls: [] })
const turns = deriveTrajectoryLayout({ nodes, partial: null, runningCalls: [] })
const cells = turns[0]?.groups.flatMap(g => g.cells) ?? []
expect(cells.find(c => c.kind === 'message')?.timeSeconds).toBeNull()
expect(turns[0]?.groups.find(g => g.title === 'Step 1')?.description).toBeUndefined()
@@ -206,7 +203,7 @@ describe('deriveTrajectoryLayout', () => {
content: [], isError: false, callView: null, resultView: null,
},
] as unknown as ConversationSnapshot['nodes']
const turns = deriveTrajectoryLayout({ codeDispatches: new Map(), nodes, partial: null, runningCalls: [] })
const turns = deriveTrajectoryLayout({ nodes, partial: null, runningCalls: [] })
expect(turns[0]?.groups[0]?.description).toBe('3,000 ms bash×2')
})
@@ -223,7 +220,7 @@ describe('deriveTrajectoryLayout', () => {
blocks: [{ kind: 'text', text: 'ok2' }],
},
] as unknown as ConversationSnapshot['nodes']
const turns = deriveTrajectoryLayout({ codeDispatches: new Map(), nodes, partial: null, runningCalls: [] })
const turns = deriveTrajectoryLayout({ nodes, partial: null, runningCalls: [] })
expect(turns.map(t => t.turn)).toEqual([1, 2])
expect(turns[0]?.groups.flatMap(g => g.cells.map(c => c.text))).toEqual(['first', 'ok1'])
expect(turns[1]?.groups.flatMap(g => g.cells.map(c => c.text))).toEqual(['second', 'ok2'])
@@ -254,7 +251,6 @@ describe('deriveTrajectoryLayout', () => {
}
const turns = deriveTrajectoryLayout({
codeDispatches: new Map(),
nodes,
partial: null,
runningCalls: [],
@@ -280,7 +276,7 @@ describe('deriveTrajectoryLayout', () => {
usage: { inputTokens: 11, outputTokens: 22, reasoningTokens: 3 },
},
] as unknown as ConversationSnapshot['nodes']
const turns = deriveTrajectoryLayout({ codeDispatches: new Map(), nodes, partial: null, runningCalls: [] })
const turns = deriveTrajectoryLayout({ nodes, partial: null, runningCalls: [] })
const message = turns[0]?.groups.flatMap(g => g.cells).find(c => c.kind === 'message')
expect(message).toMatchObject({
text: '…', input: 11, output: 22, think: 3,
@@ -295,7 +291,7 @@ describe('deriveTrajectoryLayout', () => {
}] as unknown as ConversationSnapshot['nodes']
const turns = deriveTrajectoryLayout({
codeDispatches: new Map(), nodes, partial: null, runningCalls: [],
nodes, partial: null, runningCalls: [],
})
const message = turns[0]?.groups.flatMap(group => group.cells)
.find(cell => cell.kind === 'message')
@@ -333,7 +329,7 @@ describe('deriveTrajectoryLayout', () => {
blocks: [{ kind: 'text', text: 'done' }],
},
] as unknown as ConversationSnapshot['nodes']
const turns = deriveTrajectoryLayout({ codeDispatches: new Map(), nodes, partial: null, runningCalls: [] })
const turns = deriveTrajectoryLayout({ nodes, partial: null, runningCalls: [] })
const cells = turns[0]?.groups.flatMap(g => g.cells) ?? []
const message = cells.find(c => c.kind === 'message' && c.text === 'done')
// From the compaction marker at 9.5s, not from context at 9s or the earlier surfaces.
@@ -352,7 +348,7 @@ describe('deriveTrajectoryLayout', () => {
},
] as unknown as ConversationSnapshot['nodes']
const turns = deriveTrajectoryLayout({
codeDispatches: new Map(), nodes, partial: null, runningCalls: [],
nodes, partial: null, runningCalls: [],
})
const message = turns[0]?.groups.flatMap(group => group.cells)
.find(cell => cell.kind === 'message')
@@ -372,6 +368,7 @@ describe('run_code sub-dispatch cells', () => {
kind: 'tool-result', seq: 3, time: 9_000, callId: 'p1',
call: { name: 'run_code', argsRaw: '{"code":"…","description":"批量读取"}' }, callTime: 6_200,
content: [{ type: 'text', text: 'done' }], isError: false, callView: null, resultView: null,
subCalls: [],
},
] as unknown as ConversationSnapshot['nodes']
@@ -380,14 +377,18 @@ describe('run_code sub-dispatch cells', () => {
callId: `p1:code:${n}`,
call: { name, argsRaw: '{"x":1}' }, callTime: start,
content: [{ type: 'text' as const, text: 'ok' }], isError: false, callView: null, resultView: null,
subCalls: [],
})
const withSubCalls = (subCalls: readonly ReturnType<typeof settledSub>[] | readonly object[]) =>
runCodeNodes.map(node => node.kind === 'tool-result' ? { ...node, subCalls } : node) as ConversationSnapshot['nodes']
it('nests settled sub-cells after their parent Tool cell with real durations', () => {
const codeDispatches = new Map([['p1', [
const subCalls = [
settledSub(1, 'bash', 6_300, 7_300),
settledSub(2, 'read', 7_300, 7_800),
]]]) as unknown as ConversationSnapshot['codeDispatches']
const turns = deriveTrajectoryLayout({ codeDispatches, nodes: runCodeNodes, partial: null, runningCalls: [] })
]
const turns = deriveTrajectoryLayout({ nodes: withSubCalls(subCalls), partial: null, runningCalls: [] })
const cells = turns[0]!.groups.flatMap(g => g.cells)
expect(cells.map(c => c.kind)).toEqual(['message', 'tool', 'subtool', 'subtool'])
expect(cells[0]?.text).toBe('Tool call only')
@@ -400,11 +401,29 @@ describe('run_code sub-dispatch cells', () => {
it('a running (unsettled) sub-call renders a subtool cell with blank time', () => {
const running = {
callId: 'p1:code:1', name: 'grep', argsRaw: '{"pattern":"x"}',
turn: 0, step: 0, time: 6_400, callView: null,
turn: 0, step: 0, time: 6_400, callView: null, subCalls: [],
}
const codeDispatches = new Map([['p1', [running]]]) as unknown as ConversationSnapshot['codeDispatches']
const turns = deriveTrajectoryLayout({ codeDispatches, nodes: runCodeNodes, partial: null, runningCalls: [] })
const turns = deriveTrajectoryLayout({ nodes: withSubCalls([running]), partial: null, runningCalls: [] })
const sub = turns[0]!.groups.flatMap(g => g.cells).find(c => c.kind === 'subtool')
expect(sub).toMatchObject({ text: 'grep · {"pattern":"x"}', timeSeconds: null })
})
it('recursively flattens nested child calls immediately after their parent', () => {
const leaf = {
...settledSub(2, 'read', 7_300, 7_800),
callId: 'p1:code:1:code:1',
}
const child = {
...settledSub(1, 'run_code', 6_300, 8_000),
subCalls: [leaf],
}
const turns = deriveTrajectoryLayout({ nodes: withSubCalls([child]), partial: null, runningCalls: [] })
const cells = turns[0]!.groups.flatMap(group => group.cells)
expect(cells.map(cell => cell.kind)).toEqual(['message', 'tool', 'subtool', 'subtool'])
expect(cells.slice(2).map(cell => cell.callId)).toEqual([
'p1:code:1',
'p1:code:1:code:1',
])
expect(cells.map(cell => cell.index)).toEqual([1, 2, 3, 4])
})
})

View File

@@ -82,7 +82,6 @@ function historySnapshot(
interruptedNodes: [],
partial: null,
runningCalls: [],
codeDispatches: new Map(),
...inspection,
},
}
@@ -115,7 +114,7 @@ function standaloneDuration(): Pick<
function fakeSession(nodes: ConversationSnapshot['nodes']) {
const store = createSnapshotStore({
nodes, pending: [], partial: null,
runningCalls: [] as ConversationSnapshot['runningCalls'], codeDispatches: new Map(),
runningCalls: [] as ConversationSnapshot['runningCalls'],
})
return { store, useSession: bindSnapshotSelector(store) as unknown as UseSession<ConversationSnapshot> }
}
@@ -186,7 +185,7 @@ function mount(slots: SlotsService, nodes: ConversationSnapshot['nodes'] = NODES
running: false, removed: false, promptError: null, nodes,
pending: [],
openState: 'open' as const, hasMore: true, loadingOlder: false,
partial: null, runningCalls: [] as ConversationSnapshot['runningCalls'], codeDispatches: new Map(),
partial: null, runningCalls: [] as ConversationSnapshot['runningCalls'],
})
const useSession = bindSnapshotSelector(sessionSnapshot) as unknown as UseSession<ConversationSnapshot>
const chat = createChatStore().create()

View File

@@ -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/core/agent-loop/README.md
README.md: ec1948506bbaf7a3416c2031fb472a9b513b500f
README.zh.md: 5828da301b35c95719286fb942ac239769539b67
README.md: 2fdc60086bebc924089b5b8bd12f4b4456b1ead5
README.zh.md: df0ba44ec5b010666737c0568bc5eeb220ff0d96

View File

@@ -67,7 +67,7 @@ Every provider call that reaches a successful finish appends exactly one `assist
After `agent/request` returns a provider/model call config, the loop asks `ctx.llm.prepareCall()` to validate adapter-owned fields and materialize configured reasoning-effort and output-token defaults under the active turn signal. The prepared call retains the exact adapter registration across this asynchronous resolution, `request/header` logging, and terminal dispatch, so HMR cannot mix one adapter's capability result with another adapter's request. The header records the effective config and which fields came from the adapter. Before the next waterfall, the loop removes those marked fields from the proposal so the current exact route rematerializes its own defaults; unmarked explicit settings persist across steps and route changes. A route with no registered adapter preserves the proposed config so an `llm/stream` listener can own and short-circuit it; unhandled terminal dispatch still fails with `NO_ADAPTER`. A new loop instance applies the same provenance rule when resuming.
Plugin failure ends the current turn, not the loop. Final adapter selection, dispatch, and iteration failures arrive from `ctx.llm` as terminal error or aborted finishes and enter `agent/request-error`; middleware, result processing, tools, and other extension failures remain thrown and close directly. Recovery receives request coordinates, immutable provider facts, the immutable retry policy captured by the prepared adapter registration, and the turn signal; the policy is absent when middleware owns an unprepared route. A handling listener returns `{ kind: 'retry' }`; an unhandled failure is terminal. AgentLoop owns one cancellation signal for the current admission or turn. An effective `cancel(cause)` clears pending work unless `keepInbox` is set and cooperatively aborts that signal; idle cancellation is a no-op. Durable `turn/end` records `aborted` for `user` and `parent`, while disposal records `disposed`; undispatched model tool calls receive synthetic `tool/call` and `ABORTED_BEFORE_DISPATCH` result pairs. The cancellation cause changes reporting, not how result context finalized after cancellation is handled. Disposal waits for signal-ignoring work before registry removal. The [explicit-cancellation decision](../../../.agents/notes/implemented/architecture/2026-07-16-explicit-turn-cancellation.md) owns the lifecycle and race contract.
Plugin failure ends the current turn, not the loop. Final adapter selection, dispatch, and iteration failures arrive from `ctx.llm` as terminal error or aborted finishes and enter `agent/request-error`; middleware, result processing, tools, and other extension failures remain thrown and close directly. Recovery receives request coordinates, immutable provider facts, the immutable retry policy captured by the prepared adapter registration, and the turn signal; the policy is absent when middleware owns an unprepared route. A handling listener returns `{ kind: 'retry' }`; an unhandled failure is terminal. AgentLoop owns one cancellation signal for the current admission or turn. An effective `cancel(cause)` clears pending work unless `keepInbox` is set and cooperatively aborts that signal; idle cancellation is a no-op. Waking input that lands after the abort fires but before the activity converges to idle is latched (`wakeRequested`) and replayed at the driver's own convergence boundary, so it runs without a further waking send; a `disposed` cancel never latches, and a wake submitted while already idle always opens its turn boundary (status shows a transient `idle → running → idle` pair even when the message was cleared). Durable `turn/end` records `aborted` for `user` and `parent`, while disposal records `disposed`; undispatched model tool calls receive synthetic `tool/call` and `ABORTED_BEFORE_DISPATCH` result pairs. The cancellation cause changes reporting, not how result context finalized after cancellation is handled. Disposal waits for signal-ignoring work before registry removal. The [explicit-cancellation decision](../../../.agents/notes/implemented/architecture/2026-07-16-explicit-turn-cancellation.md) and the [cancel-convergence wake latch](../../../.agents/notes/implemented/bug-fix/2026-08-07-cancel-convergence-wake-latch.md) own the lifecycle and race contract.
Within a step, exclusive calls form barriers; parallel-safe calls use a bounded rolling pool and are reclassified before start. Only dispatch/body overlaps. Policy, durable results, and result context remain model-ordered. Abort stops new calls, drains started results, and retains their finalized result context without distinguishing the cancellation cause. An internal scheduler failure stops new dispatches, waits for already-started dispatches, and reaches the turn error boundary without fabricating tool results.

View File

@@ -67,7 +67,7 @@ interface Config {
`agent/request` 返回提供方/模型调用配置后,循环会调用 `ctx.llm.prepareCall()`在活跃轮次信号的控制下校验由适配器持有的字段并填入配置的推理reasoning强度和输出 token 默认值。准备完成的调用会在这次异步解析、`request/header` 日志记录和最终分派期间保留同一项确切的适配器注册,因此 HMR热模块替换不会把某个适配器的能力解析结果与另一适配器的请求混用。请求 header 会记录生效配置以及哪些字段来自适配器。下一次 waterfall瀑布式事件循环会从提议中移除这些带标记字段使当前精确路由重新填入自身默认值未带标记的显式设置会跨步骤和路由变化保留。没有已注册适配器的路由会保留原定配置使 `llm/stream` 监听器可以接管并短路该请求;最终分派仍会以 `NO_ADAPTER` 拒绝未得到处理的路由。新循环实例在恢复时会应用同一来源规则。
插件失败会结束当前轮次,而不是结束循环。最终适配器选择、分发与迭代失败会由 `ctx.llm` 作为终止 error 或 aborted finish 返回,并进入 `agent/request-error`middleware、结果处理、工具及其他扩展失败仍会抛出并直接关闭轮次。恢复逻辑会接收请求坐标、不可变的提供方事实、准备完成的适配器注册所捕获的不可变重试策略以及轮次信号middleware 接管未准备路由时,该策略缺失。处理失败的监听器返回 `{ kind: 'retry' }`未被处理的失败是终态。AgentLoop 为当前接纳或轮次拥有一个取消信号。有效的 `cancel(cause)` 在未设置 `keepInbox` 时清除待处理工作,并以协作方式中止该信号;空闲取消是空操作。持久 `turn/end``user``parent` 记录 `aborted`dispose资源释放则记录 `disposed`;未分发的模型工具调用会收到合成的 `tool/call``ABORTED_BEFORE_DISPATCH` 结果对。取消原因只改变报告方式不改变对取消后已定案结果上下文的处理。dispose 会等待忽略信号的工作完成,然后才从注册表移除。[显式取消决策](../../../.agents/notes/implemented/architecture/2026-07-16-explicit-turn-cancellation.md)规定生命周期与竞态契约。
插件失败会结束当前轮次,而不是结束循环。最终适配器选择、分发与迭代失败会由 `ctx.llm` 作为终止 error 或 aborted finish 返回,并进入 `agent/request-error`middleware、结果处理、工具及其他扩展失败仍会抛出并直接关闭轮次。恢复逻辑会接收请求坐标、不可变的提供方事实、准备完成的适配器注册所捕获的不可变重试策略以及轮次信号middleware 接管未准备路由时,该策略缺失。处理失败的监听器返回 `{ kind: 'retry' }`未被处理的失败是终态。AgentLoop 为当前接纳或轮次拥有一个取消信号。有效的 `cancel(cause)` 在未设置 `keepInbox` 时清除待处理工作,并以协作方式中止该信号;空闲取消是空操作。abort 触发后、活动收敛到空闲前到达的唤醒输入会被锁存(`wakeRequested`),并在 driver 自身的收敛边界重放,无需再发一条唤醒 send 即可执行;`disposed` 取消从不锁存,而 agent 已处于空闲时发送的唤醒总是打开自己的 turn 边界(即使消息已被清除,状态也会显示瞬态 `idle → running → idle` 对)。持久 `turn/end``user``parent` 记录 `aborted`dispose资源释放则记录 `disposed`;未分发的模型工具调用会收到合成的 `tool/call``ABORTED_BEFORE_DISPATCH` 结果对。取消原因只改变报告方式不改变对取消后已定案结果上下文的处理。dispose 会等待忽略信号的工作完成,然后才从注册表移除。[显式取消决策](../../../.agents/notes/implemented/architecture/2026-07-16-explicit-turn-cancellation.md)与[取消收敛窗口唤醒锁存](../../../.agents/notes/implemented/bug-fix/2026-08-07-cancel-convergence-wake-latch.md)规定生命周期与竞态契约。
在步骤内独占调用形成屏障并行安全调用使用有界滚动池并在启动前重新分类。只有分发主体会重叠。策略、持久结果和结果上下文仍保持模型顺序。中止会停止新调用drain 已启动的结果,并保留其已定案的结果上下文,不区分取消原因。内部调度器故障会停止新的分发,等待已启动的分发,然后在不虚构工具结果的情况下到达轮次错误边界。

View File

@@ -43,7 +43,7 @@ type Phase =
lastTurn: number
wakeRequested: boolean
}
| { kind: 'running'; abort: AbortController; turn: number; step: number }
| { kind: 'running'; abort: AbortController; turn: number; step: number; wakeRequested: boolean }
type StepEndReason = Extract<TurnEndReason, { kind: 'completed' | 'max-tokens' }>
@@ -112,10 +112,11 @@ export class ReactLoopAgent implements Agent {
send(message: UserMessage, target: InboxTarget, wakeup: boolean): void {
// Waking input cannot join an aborted activity, so it starts the next turn.
// Captured before the insertion so a reentrant cancel from a splice observer cannot reclassify it.
const wakingAfterAbort = wakeup && this.phase.kind !== 'idle' && this.phase.abort.signal.aborted
const resolvedTarget = wakingAfterAbort ? 'next-turn' : target
this.inbox.splice(resolvedTarget, Infinity, 0, [message])
if (wakeup) this.wakeDriver()
if (wakeup) this.wakeDriver(wakingAfterAbort)
}
followup(input: UserMessage): void {
@@ -133,7 +134,7 @@ export class ReactLoopAgent implements Agent {
cancel(cause: AgentCancelCause, options: CancelOptions = {}): void {
if (!options.keepInbox) {
this.inbox.clear()
if (this.phase.kind === 'maintenance') this.phase.wakeRequested = false
if (this.phase.kind !== 'idle') this.phase.wakeRequested = false
}
if (this.phase.kind !== 'idle') this.phase.abort.abort(cause)
}
@@ -154,22 +155,40 @@ export class ReactLoopAgent implements Agent {
return await task(maintenance.abort.signal)
} finally {
this.setPhase({ kind: 'idle', lastTurn: maintenance.lastTurn })
if (maintenance.wakeRequested) this.wakeDriver()
if (maintenance.wakeRequested && this.inbox.hasPending) this.wakeDriver()
done.resolve()
}
})()
}
/** Start one driver, or remember its wake behind maintenance. */
private wakeDriver(): void {
if (this.phase.kind === 'maintenance') {
if (!this.phase.abort.signal.aborted) this.phase.wakeRequested = true
/**
* Start one driver, or latch its wake behind maintenance or an aborted
* activity. A wake sent while idle always opens its turn boundary, even
* when its message was cleared; only a latched replay is suppressed when
* the queue no longer holds the wake.
* @param wakeAfterAbort - the {@link send} classification, captured before
* the inbox insertion so a reentrant cancel cannot reclassify it.
*/
private wakeDriver(wakeAfterAbort = false): void {
if (this.phase.kind !== 'idle') {
// Maintenance and aborted drivers cannot deliver the wake: latch it for
// replay at convergence. Live drivers claim queued work themselves;
// disposal never latches, so teardown waits on no model turn.
const reason = this.phase.abort.signal.reason as AgentCancelCause | undefined
if (reason?.kind !== 'disposed' && (this.phase.kind === 'maintenance' || wakeAfterAbort)) {
this.phase.wakeRequested = true
}
return
}
if (this.phase.kind !== 'idle') return
const driver = Promise.withResolvers<void>()
this.activityDone = driver.promise
this.setPhase({ kind: 'running', abort: new AbortController(), turn: this.phase.lastTurn, step: 0 })
this.setPhase({
kind: 'running',
abort: new AbortController(),
turn: this.phase.lastTurn,
step: 0,
wakeRequested: false,
})
this.loopCtx.agents.withInitiator(this, () => this.kick()).then(driver.resolve, driver.reject)
}
@@ -196,7 +215,9 @@ export class ReactLoopAgent implements Agent {
} finally {
/* v8 ignore next -- kick owns a running phase until this driver boundary */
if (this.phase.kind === 'running') {
this.setPhase({ kind: 'idle', lastTurn: this.phase.turn })
const { turn, wakeRequested } = this.phase
this.setPhase({ kind: 'idle', lastTurn: turn })
if (wakeRequested && this.inbox.hasPending) this.wakeDriver()
}
}
}
@@ -302,6 +323,8 @@ export class ReactLoopAgent implements Agent {
}
if (!this.inbox.hasPending) return false
phase.abort = new AbortController()
// A fresh controller makes a latch set on the old one stale: the live driver claims the queue itself.
phase.wakeRequested = false
phase.step = 0
return true
}

View File

@@ -126,6 +126,121 @@ describe('Agent.cancel()', () => {
expect(adapter.requests).toHaveLength(3)
})
it('cancel({ keepInbox: true }) latches a waking send landing in the abort-to-idle window', async () => {
const adapter = new MockAdapter(['hang', textResponse('B reply')])
const ctx = await harness(adapter)
const agent = ctx.agentLoop.create(SessionId('latch-window'), { provider: 'mock', model: 'mock' })
send(agent, 'active')
await new Promise(resolve => setTimeout(resolve, 30))
// The abort signal is set but the driver has not converged to idle yet:
// the waking send must be latched, not parked until another wake.
agent.cancel({ kind: 'user' }, { keepInbox: true })
send(agent, 'B')
await agent.whenIdle()
expect(userTexts(agent)).toEqual(['active', 'B'])
expect(adapter.requests).toHaveLength(2)
expect(agent.inbox.nextTurn).toHaveLength(0)
expect(agent.session.events.filter(e => e.type === 'turn/end').map(e =>
e.type === 'turn/end' ? e.data.reason : null)).toEqual([
{ kind: 'aborted', reason: { kind: 'user' } },
{ kind: 'completed' },
])
})
it('cancel() without keepInbox clears a latched wake alongside the inbox', async () => {
const adapter = new MockAdapter(['hang', textResponse('C reply')])
const ctx = await harness(adapter)
const agent = ctx.agentLoop.create(SessionId('latch-cleared'), { provider: 'mock', model: 'mock' })
send(agent, 'active')
await new Promise(resolve => setTimeout(resolve, 30))
agent.cancel({ kind: 'user' }, { keepInbox: true })
send(agent, 'B') // latched behind the aborted activity
agent.cancel({ kind: 'user' }) // drops the inbox and the latch with it
await agent.whenIdle()
expect(userTexts(agent)).toEqual(['active'])
expect(agent.inbox.nextTurn).toHaveLength(0)
expect(adapter.requests).toHaveLength(1)
send(agent, 'C')
await agent.whenIdle()
expect(userTexts(agent)).toEqual(['active', 'C'])
expect(adapter.requests).toHaveLength(2)
})
it('removing the latched wake before convergence suppresses the replay', async () => {
const adapter = new MockAdapter(['hang'])
const ctx = await harness(adapter)
const agent = ctx.agentLoop.create(SessionId('removed-latched-wake'), { provider: 'mock', model: 'mock' })
send(agent, 'active')
await new Promise(resolve => setTimeout(resolve, 30))
agent.cancel({ kind: 'user' }, { keepInbox: true })
const steer = createUserMessage({ content: [{ type: 'text', text: 'steer me' }], source: { kind: 'user' } })
agent.steer(steer) // latched behind the aborted activity
agent.inbox.remove(steer.id) // the wake is retracted before convergence
await agent.whenIdle()
expect(userTexts(agent)).toEqual(['active'])
expect(adapter.requests).toHaveLength(1)
expect(agent.inbox.nextTurn).toHaveLength(0)
expect(agent.status).toBe('idle')
// No replay with nothing to run: the latched message is gone, so no
// empty follow-up turn is recorded.
expect(agent.session.events.filter(e => e.type === 'turn/start')).toHaveLength(1)
})
it('latches a wake arriving deep into a slow abort convergence', async () => {
// The stream notices the abort only after 50ms, so the driver stays in
// the abort-to-idle window long after `cancel()` returned: the wake must
// be latched across the whole window, not just the same-tick case.
const adapter = new MockAdapter(['hang-slow', textResponse('B reply')])
const ctx = await harness(adapter)
const agent = ctx.agentLoop.create(SessionId('slow-convergence'), { provider: 'mock', model: 'mock' })
send(agent, 'A')
await new Promise(resolve => setTimeout(resolve, 30))
agent.cancel({ kind: 'user' }, { keepInbox: true })
await new Promise(resolve => setTimeout(resolve, 10))
send(agent, 'B')
await agent.whenIdle()
expect(userTexts(agent)).toEqual(['A', 'B'])
expect(adapter.requests).toHaveLength(2)
expect(agent.inbox.nextTurn).toHaveLength(0)
})
it('does not latch a wake landing after disposal begins', async () => {
const adapter = new MockAdapter(['hang-slow', textResponse('late reply')])
const ctx = await harness(adapter)
const handle = await ctx.agents.create({
sessionId: SessionId('dispose-window-wake'),
agentOptions: { provider: 'mock', model: 'mock' },
})
const agent = handle.agent
send(agent, 'active')
await new Promise(resolve => setTimeout(resolve, 30))
// Dispose cancels with `{ kind: 'disposed' }`; a wake landing in the
// abort-to-idle window must not latch, so `whenIdle()` does not wait on
// a model turn over the session being torn down.
const disposal = handle.dispose()
setTimeout(() => { send(agent, 'late wake') }, 10)
await disposal
expect(adapter.requests).toHaveLength(1)
expect(userTexts(agent)).toEqual(['active'])
})
it('cancel after waking send closes its synchronously opened turn without a step', async () => {
const adapter = new MockAdapter([textResponse('should not run')])
const ctx = await harness(adapter)
@@ -228,7 +343,7 @@ describe('Agent.cancel()', () => {
expect(userTexts(agent)).toEqual(['first', 'later'])
})
it('replacement work queued after idle-listener cancellation waits for another wakeup', async () => {
it('replacement work queued after idle-listener cancellation replays at convergence', async () => {
const adapter = new MockAdapter([
textResponse('first reply'),
textResponse('replacement reply'),
@@ -253,9 +368,11 @@ describe('Agent.cancel()', () => {
if (replacementIdle === undefined) throw new Error('idle listener did not register replacement work')
await replacementIdle
expect(adapter.requests).toHaveLength(1)
expect(userTexts(agent)).toEqual(['first'])
expect(agent.inbox.nextTurn).toHaveLength(1)
// The wake sent after the cancel fired is latched: the surviving
// replacement runs at convergence without a third message.
expect(adapter.requests).toHaveLength(2)
expect(userTexts(agent)).toEqual(['first', 'surviving replacement'])
expect(agent.inbox.nextTurn).toHaveLength(0)
const idle = waitForIdle(ctx, agent)
send(agent, 'wake it')
@@ -479,7 +596,7 @@ describe('Agent.cancel()', () => {
expect(agent.session.events.some(e => e.type === 'turn/start')).toBe(false)
})
it('a running-listener cancellation parks replacement work until another wakeup', async () => {
it('a running-listener cancellation replays replacement work at convergence', async () => {
const adapter = new MockAdapter([textResponse('A reply'), textResponse('B reply')])
const ctx = await harness(adapter)
const agent = ctx.agentLoop.create(SessionId('a1'), { provider: 'mock', model: 'mock' })
@@ -497,17 +614,20 @@ describe('Agent.cancel()', () => {
await idle
dispose()
expect(userTexts(agent)).toEqual([])
expect(agent.inbox.nextTurn).toHaveLength(1)
// B's wake was latched behind the cancelled driver: it runs on its own.
expect(userTexts(agent)).toEqual(['B'])
expect(agent.inbox.nextTurn).toHaveLength(0)
expect(adapter.requests).toHaveLength(1)
const replacementIdle = waitForIdle(ctx, agent)
send(agent, 'C')
await replacementIdle
expect(userTexts(agent)).toEqual(['B', 'C'])
expect(adapter.requests).toHaveLength(2)
expect(agent.session.events.filter(event => event.type === 'turn/end')).toHaveLength(2)
})
it('a prompt queued during pre-step cancellation waits for another wakeup', async () => {
it('a prompt queued during pre-step cancellation replays at convergence', async () => {
const adapter = new MockAdapter([textResponse('A reply'), textResponse('B reply')])
const ctx = await harness(adapter)
const agent = ctx.agentLoop.create(SessionId('a1'), { provider: 'mock', model: 'mock' })
@@ -518,13 +638,15 @@ describe('Agent.cancel()', () => {
send(agent, 'B')
await idle
expect(userTexts(agent)).toEqual([])
expect(agent.inbox.nextTurn).toHaveLength(1)
expect(userTexts(agent)).toEqual(['B'])
expect(agent.inbox.nextTurn).toHaveLength(0)
expect(adapter.requests).toHaveLength(1)
const replacementIdle = waitForIdle(ctx, agent)
send(agent, 'C')
await replacementIdle
expect(userTexts(agent)).toEqual(['B', 'C'])
expect(adapter.requests).toHaveLength(2)
expect(agent.session.events.filter(event => event.type === 'turn/end')).toHaveLength(3)
})
@@ -556,7 +678,7 @@ describe('Agent.cancel()', () => {
expect(flat).not.toContain('steer text')
})
it('parks replacement work queued synchronously by an abort observer', async () => {
it('replays replacement work queued synchronously by an abort observer', async () => {
const adapter = new MockAdapter([
'hang',
textResponse('replacement reply'),
@@ -586,13 +708,15 @@ describe('Agent.cancel()', () => {
}),
])
expect(adapter.requests).toHaveLength(1)
expect(userTexts(agent)).toEqual(['original'])
expect(agent.inbox.nextTurn).toHaveLength(1)
// The abort-observer wake was latched: replacement runs at convergence,
// so the original turn is followed by a completed replacement turn.
expect(adapter.requests).toHaveLength(2)
expect(userTexts(agent)).toEqual(['original', 'replacement'])
expect(agent.inbox.nextTurn).toHaveLength(0)
const reasons = agent.session.events
.filter(event => event.type === 'turn/end')
.map(event => event.type === 'turn/end' ? event.data.reason : undefined)
expect(reasons).toEqual([{ kind: 'aborted', reason: { kind: 'user' } }])
expect(reasons).toEqual([{ kind: 'aborted', reason: { kind: 'user' } }, { kind: 'completed' }])
const replacementIdle = waitForIdle(ctx, agent)
send(agent, 'wake it')

View File

@@ -41,6 +41,14 @@ function send(agent: Agent, text: string) {
agent.followup(createUserMessage({ content: [{ type: 'text', text }], source: { kind: 'user' } }))
}
/** All user-message texts recorded in the log (to assert what actually ran). */
function userTexts(agent: Agent): string[] {
return agent.session.events
.filter(e => e.type === 'user/message')
.flatMap(e => e.type === 'user/message' ? e.data.content : [])
.flatMap(b => b.type === 'text' ? [b.text] : [])
}
describe('agent loop', () => {
it.each([0, -1, 1.5, Number.NaN, Number.MAX_SAFE_INTEGER + 1])(
'rejects invalid AgentOptions.maxTokens %s before publication',
@@ -70,7 +78,7 @@ describe('agent loop', () => {
})
it('cancels queued wakeup work together with an active maintenance task', async () => {
const adapter = new MockAdapter([textResponse('unused')])
const adapter = new MockAdapter([textResponse('park reply')])
const ctx = await harness(adapter)
const agent = ctx.agentLoop.create(SessionId('cancel-maintenance-wakeup'), {
provider: 'mock',
@@ -87,15 +95,68 @@ describe('agent loop', () => {
})
await started.promise
send(agent, 'discard this wakeup')
agent.cancel({ kind: 'user' })
send(agent, 'park after cancellation')
send(agent, 'discard this wakeup') // latched behind the live maintenance task
agent.cancel({ kind: 'user' }) // drops the queue and the latch, aborts maintenance
send(agent, 'park after cancellation') // newer intent: re-latched, replays at convergence
await expect(maintenance).rejects.toThrow('maintenance aborted')
await agent.whenIdle()
expect(agent.inbox.nextTurn).toHaveLength(1)
// The pre-cancel wakeup is gone; the post-cancel wake replays at convergence.
expect(userTexts(agent)).toEqual(['park after cancellation'])
expect(agent.inbox.nextTurn).toHaveLength(0)
expect(adapter.requests).toHaveLength(1)
})
it('replays a wake latched behind maintenance at convergence', async () => {
const adapter = new MockAdapter([textResponse('wake reply')])
const ctx = await harness(adapter)
const agent = ctx.agentLoop.create(SessionId('maintenance-wake-replay'), {
provider: 'mock',
model: 'mock',
})
const started = Promise.withResolvers<undefined>()
const finish = Promise.withResolvers<undefined>()
const maintenance = agent.runMaintenance(async () => {
started.resolve(undefined)
await finish.promise
})
await started.promise
send(agent, 'wake behind maintenance')
finish.resolve(undefined)
await maintenance
await agent.whenIdle()
expect(userTexts(agent)).toEqual(['wake behind maintenance'])
expect(adapter.requests).toHaveLength(1)
})
it('suppresses the replay when a latched maintenance wake is removed', async () => {
const adapter = new MockAdapter([])
const ctx = await harness(adapter)
const agent = ctx.agentLoop.create(SessionId('maintenance-wake-removed'), {
provider: 'mock',
model: 'mock',
})
const started = Promise.withResolvers<undefined>()
const finish = Promise.withResolvers<undefined>()
const maintenance = agent.runMaintenance(async () => {
started.resolve(undefined)
await finish.promise
})
await started.promise
const wake = createUserMessage({ content: [{ type: 'text', text: 'removed wake' }], source: { kind: 'user' } })
agent.followup(wake)
agent.inbox.remove(wake.id)
finish.resolve(undefined)
await maintenance
await agent.whenIdle()
expect(userTexts(agent)).toEqual([])
expect(adapter.requests).toEqual([])
agent.cancel({ kind: 'user' })
expect(agent.session.events.filter(e => e.type === 'turn/start')).toHaveLength(0)
})
it('runs a simple turn: queued message → model → idle, with ordered events', async () => {

View File

@@ -58,14 +58,16 @@ export function toolCallResponse(rawCallId: string, name: string, args: object,
/**
* Mock adapter driven by a script: each model call consumes the next entry.
* Records every request it receives for assertions. An entry may be a
* function to compute chunks from the request, or a 'hang' marker that
* streams one chunk then waits until aborted.
* function to compute chunks from the request, a 'hang' marker that
* streams one chunk then waits until aborted, or 'hang-slow' which takes
* 50ms to notice the abort — a stand-in for slow real-world teardown
* (LLM stream cancellation, tool unwinding).
*/
export class MockAdapter extends LlmAdapter {
requests: GenerateOptions[] = []
constructor(
private script: (StreamChunk[] | ((options: GenerateOptions) => StreamChunk[]) | 'hang')[],
private script: (StreamChunk[] | ((options: GenerateOptions) => StreamChunk[]) | 'hang' | 'hang-slow')[],
private readonly reasoning?: LlmModelReasoningInfo,
private readonly defaultMaxTokens?: number,
) {
@@ -98,6 +100,16 @@ export class MockAdapter extends LlmAdapter {
})
return
}
if (entry === 'hang-slow') {
yield { type: 'block-start', index: 0, blockType: 'text' }
yield { type: 'text-delta', index: 0, text: 'partial' }
await new Promise<void>((_resolve, reject) => {
const fail = (): void => { reject(new Error('aborted')) }
if (options.signal?.aborted) { setTimeout(fail, 50); return }
options.signal?.addEventListener('abort', () => { setTimeout(fail, 50) }, { once: true })
})
return
}
const chunks = typeof entry === 'function' ? entry(options) : entry
for (const chunk of chunks) {
if (options.signal?.aborted) throw new Error('aborted')

View File

@@ -104,7 +104,11 @@ export interface Agent {
/**
* Route identified input to an inbox boundary and optionally wake the driver.
* Waking input submitted after active cancellation is queued for the next turn.
* Waking input submitted after active cancellation is queued for the next
* turn and runs when the aborted activity converges to idle; a `disposed`
* cancel leaves it parked. A wake submitted while already idle always opens
* its turn boundary, even when its message is cleared before the driver
* claims ([cancel-convergence wake latch](../../../../.agents/notes/implemented/bug-fix/2026-08-07-cancel-convergence-wake-latch.md)).
* @param message - identified content and its producer provenance.
* @param target - the preferred next-turn or next-step inbox boundary.
* @param wakeup - whether delivery may wake the driver.