Merge remote-tracking branch 'origin/feat/windows-pwsh-default' into feat/windows-acl-sandbox
# Conflicts: # apps/cli/reference/README.i18n.yaml
This commit is contained in:
@@ -2,5 +2,5 @@
|
||||
# side as of the last confirmed-consistent state. Both languages carry equal authority;
|
||||
# after editing either side, bring the other along and re-record with:
|
||||
# pnpm run verify-translation-pairing --write packages/client/runtime/README.md
|
||||
README.md: bb85d3c7b45eb0d82d789a9133bba787f1d6c5e9
|
||||
README.zh.md: 18aedbb487a490c85ce68ccc460fd82218157843
|
||||
README.md: a9b604974595b1b7856f74b72d36093491ec1bd1
|
||||
README.zh.md: 6eee14bdbe7a9fb86b0a12355e7d0a45cd500774
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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 标题投影
|
||||
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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[]
|
||||
|
||||
@@ -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
|
||||
},
|
||||
|
||||
@@ -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
|
||||
|
||||
212
packages/client/runtime/src/client/sessions/tool-call-tree.ts
Normal file
212
packages/client/runtime/src/client/sessions/tool-call-tree.ts
Normal 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
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -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'),
|
||||
|
||||
@@ -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' } },
|
||||
])
|
||||
})
|
||||
})
|
||||
|
||||
|
||||
89
packages/client/runtime/tests/tool-call-tree.spec.ts
Normal file
89
packages/client/runtime/tests/tool-call-tree.spec.ts
Normal 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}`)
|
||||
})
|
||||
})
|
||||
@@ -50,7 +50,6 @@ export function conversationSnapshot(sessionId: SessionId): ConversationSnapshot
|
||||
turnEnds: new Map(),
|
||||
partial: null,
|
||||
runningCalls: [],
|
||||
codeDispatches: new Map(),
|
||||
pending: [],
|
||||
queue: [],
|
||||
running: false,
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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` service(ctx.get;由同一插件提供)索取收尾消息的行内代码词表,并把结果接进 MarkdownText 的 `fileMentions` seam——service 缺席时正文保持死文本。
|
||||
|
||||
## 模型体验
|
||||
|
||||
|
||||
@@ -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(() => {
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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}
|
||||
/>
|
||||
|
||||
@@ -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. */
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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 }],
|
||||
|
||||
@@ -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 }],
|
||||
})
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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` 则不行)保持死文本;放宽匹配器等真实的收尾消息形态需要时再做。
|
||||
|
||||
@@ -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[]
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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()
|
||||
})
|
||||
})
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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)。
|
||||
|
||||
## 终端输出
|
||||
|
||||
|
||||
@@ -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'
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
})
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -20,6 +20,7 @@ function makeContext(): MarkdownRenderContext {
|
||||
return {
|
||||
streaming: false,
|
||||
codeLabels: undefined,
|
||||
fileMentions: undefined,
|
||||
targets: createReferenceTargets(),
|
||||
footnoteOrder: [],
|
||||
footnoteCounts: new Map(),
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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: [],
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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。
|
||||
|
||||
@@ -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}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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,
|
||||
})
|
||||
|
||||
|
||||
@@ -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()
|
||||
})
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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()
|
||||
})
|
||||
|
||||
@@ -61,7 +61,7 @@ describe('planSummary', () => {
|
||||
const resultNode = (argsRaw: string, over?: Partial<ToolResultNode>): ToolResultNode => ({
|
||||
kind: 'tool-result', seq: 10, time: 2_000, callTime: 1_000, callId: 'c1',
|
||||
call: { name: 'todo_write', argsRaw },
|
||||
content: [], isError: false, callView: null, resultView: null, ...over,
|
||||
content: [], isError: false, callView: null, resultView: null, subCalls: [], ...over,
|
||||
})
|
||||
|
||||
function rowProps(block: unknown): TodoRowProps {
|
||||
@@ -106,7 +106,7 @@ describe('TodoRow', () => {
|
||||
|
||||
it('keeps non-ok execution states visible through the shared row states', () => {
|
||||
const args = JSON.stringify({ todos: LIST })
|
||||
const running = render(<TodoRow {...rowProps({ callId: 'c1', name: 'todo_write', argsRaw: args, turn: 1, step: 1, time: 1_000, callView: null })} />)
|
||||
const running = render(<TodoRow {...rowProps({ callId: 'c1', name: 'todo_write', argsRaw: args, turn: 1, step: 1, time: 1_000, callView: null, subCalls: [] })} />)
|
||||
expect(running.container.querySelector('[data-state="running"]')).not.toBeNull()
|
||||
expect(running.container.querySelector('[data-state="running"] svg')).not.toBeNull()
|
||||
running.unmount()
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
/** ToolCallTree-owned root/subcall markers and selection projection. */
|
||||
import { afterEach, describe, expect, it, vi } from 'vitest'
|
||||
import { cleanup, render } from '@testing-library/react'
|
||||
import type { CodeSubCall, ConversationSnapshot, ToolResultNode } from '@deepseek-ai/dsh-client-runtime/client'
|
||||
import type { ConversationSnapshot, ToolResultNode } from '@deepseek-ai/dsh-client-runtime/client'
|
||||
import { makeTranslate } from '@deepseek-ai/dsh-client-test-runtime'
|
||||
import { zh as commonZh } from '@deepseek-ai/dsh-client-locale/src/locales/zh.ts'
|
||||
import type { ToolTreeProps } from '../src/client/contract/slots.ts'
|
||||
@@ -15,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)
|
||||
})
|
||||
})
|
||||
|
||||
@@ -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', () => {
|
||||
|
||||
@@ -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. */
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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])
|
||||
})
|
||||
})
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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.
|
||||
|
||||
|
||||
@@ -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 已启动的结果,并保留其已定案的结果上下文,不区分取消原因。内部调度器故障会停止新的分发,等待已启动的分发,然后在不虚构工具结果的情况下到达轮次错误边界。
|
||||
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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')
|
||||
|
||||
@@ -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 () => {
|
||||
|
||||
@@ -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')
|
||||
|
||||
@@ -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.
|
||||
|
||||
Reference in New Issue
Block a user