Merge branch 'stack/agent-profiles-5-web-ui' into stack/agent-profiles-8-authoring

This commit is contained in:
Yichen Jiang
2026-08-08 22:54:07 +08:00
910 changed files with 17011 additions and 6270 deletions

View File

@@ -2,5 +2,5 @@
# side as of the last confirmed-consistent state. Both languages carry equal authority;
# after editing either side, bring the other along and re-record with:
# pnpm run verify-translation-pairing --write packages/client/runtime/README.md
README.md: 8ac29a4258bbd7456b20c61e547d48c570e84d27
README.zh.md: 0e065e43ecc571e68d3976d2100eb43959cb2e3d
README.md: a9b604974595b1b7856f74b72d36093491ec1bd1
README.zh.md: 6eee14bdbe7a9fb86b0a12355e7d0a45cd500774

View File

@@ -22,6 +22,8 @@ Workspace and Session lists have independent monotone `pending` → `ready` base
SlotsService gives the renderer separate bare observables for `useSessions` and `useWorkspaces`; web-react creates the hooks. Workspace business state does not enter `SessionListState` or an entry store.
`indexSubagentDescendants()` derives per-parent total and running descendant counts from the retained list mirror. It follows only uninterrupted `origin: 'subagent'` ancestry, so an ordinary fork starts a separate ownership subtree; cycles stop without throwing, and a missing parent remains a harmless key until its summary arrives.
`SessionsService.search(query, signal)` is a stateless one-shot action over the `session.search` RPC. It returns ranked session/snippet pairs without putting query, loading, or error state into the shared Session list, so each UI owner controls debounce, cancellation, stale-response suppression, and fallback presentation. `searchResultLimit` re-exposes `SESSION_SEARCH_RESULT_LIMIT` — the bound the response schema itself enforces — as injected presentation data, so client plugins do not duplicate it. It is a protocol constant rather than per-connection state, so the connection handle does not carry it.
## New Session and the blank mirror
@@ -36,15 +38,15 @@ SlotsService gives the renderer separate bare observables for `useSessions` and
`ConversationSnapshot.nodes` is the human transcript, not the model surface. `TranscriptAdapter` projects the raw window in log order — every append-origin surface event (`isAppendSurfaceEvent`) at its own log position, plus one `CompactionSummaryNode` marker per landed compaction checkpoint — and never consults surface order. `SteeringHistory` replays the durable `agent/inbox/spliced` records in that window: a user-origin message claimed from `next-step` becomes a `SteeringMessageNode` when its matching `user/message` lands, a `next-turn` claim stays a user node, and non-user next-step input stays context. `ConversationSnapshot.turnEnds` maps each completed turn in that window to its `turn/end` seq, retaining turn completion independently from the transcript so presentation can require a real boundary before enabling an action. A landed compaction therefore keeps the conversation it shadowed on the model side: the marker reports where the model stopped seeing that history instead of erasing it. Model-only replacement copies stay out: a pruned `tool/result` and a regenerated `assistant/message` rewrite one node for the model and mark no boundary. A checkpoint is a `user/message` carrying the compaction seam's plugin source that **replaced** a surface range; an appending plugin-sourced `user/message` is injected context, not a compaction. Each context node also carries a `provenance` view: `contextProvenance()` reads the durable source alone to decide whether the row is an `inject` or a cross-session `recall`, and to name its producer from the instruction paths, referenced session titles, or plugin id that source already records. The client holds no table of plugin ids, so a renamed or newly mounted producer stays identifiable without a client release and a resumed or foreign log projects exactly like a live one; a source with no readable kind degrades to an unnamed injection. Beside it, `contextForm()` reads the producer-declared `ContextForm` — the second, independent axis: `kind` says who produced the context, `form` says what shape of information it is, so several producers may share one form. A form this UI version does not present projects as null and renders opaque. The adapter's plugin literal is pinned to the seam's own declaration by a type-only import of the cordis-free [`dsh-compact/checkpoint`](../../compact/compact/README.md) leaf, so renaming it there fails `tsc` here; a **value** import of the package would fail the client purity gate, and the package **root** is unreachable even as a type (it reaches `dsh-session`'s root, whose `Context` merge collides the host `sessions` with this program's).
Because the projection is log-ordered, the node array is seq-monotonic by construction: log-only `command/run` / `command/done` nodes splice in by seq, `Session` merges interrupted frozen nodes by their fractional seqs, and a window whose checkpoint cites a shadowed range outside it renders the marker with nothing logged. The marker's summary text comes from the checkpoint's `compact/summary` provenance; a window cut that left the provenance outside makes the row non-expandable rather than empty, and a later page that supplies it resolves the text. Performance contract: one append materializes at most one node and copies the projection only when it adds that node; an event that changes no node keeps the previous array reference (a chunk storm costs nothing), and unchanged nodes keep their object identity.
Because the projection is log-ordered, the node array is seq-monotonic by construction: log-only `command/run` / `command/done` nodes splice in by seq, `Session` merges interrupted frozen nodes by their fractional seqs, and a window whose checkpoint cites a shadowed range outside it renders the marker with nothing logged. The marker's summary text, replaced-item count, and estimated shadowed-token count come from the checkpoint's `compact/summary` provenance; a window cut that left the provenance outside makes those fields unavailable, and a later page that supplies it resolves them. `CommandNode.outcome.sourceEventSeq` preserves a successful command's explicit reference to that summary event, allowing the presentation layer to pair `/compact` with its checkpoint without parsing settlement copy or assuming the two rows are adjacent. Performance contract: one append materializes at most one node and copies the projection only when it adds that node; an event that changes no node keeps the previous array reference (a chunk storm costs nothing), and unchanged nodes keep their object identity.
## Request inspection
`SessionHistoryInspection.requests` is one chronological, purpose-discriminated provider-request stream. Assistant requests always carry their numeric `turn` and `step`; compaction requests carry `step: 0` and a `turn` owner that may be `null`. That null owner means a manual compaction ran standalone between turns, not that it belongs to either adjacent turn. A `session/end-seed` boundary closes an unmatched compaction request as an error at the boundary time with `Compaction was interrupted before completion.`; a later start projects as an independent request instead of overwriting the orphan.
## Code Mode sub-dispatch index
## Code Mode child-call tree
`ConversationSnapshot.codeDispatches` groups a `run_code` call's sub-dispatches under their parent callId, in start order, using the native call-block shapes: a `tool/code-dispatch-start` event lands the `RunningToolCall` form (rows derive the running ring from the shape) and its `tool/code-dispatch` settlement replaces it in place with the `ToolResultNode` form, `callTime` carrying the paired start's time. A settle whose start fell outside the replay window appends directly with `callTime: null` (duration unknown — never a fabricated zero). Live mux frames and history replay build the identical index; sub-calls never join the transcript `nodes` flow; per-parent array and map references are memo-stable across unrelated snapshot swaps.
Every `ToolCallBlock` recursively owns its children through `subCalls`, in start order. Runtime's `ToolCallTree` privately maintains the parent-callId-to-children index: a `tool/code-dispatch-start` event lands as a `RunningToolCall`, and the matching `tool/code-dispatch` settlement replaces it in place with a `ToolResultNode` whose `callTime` comes from the paired start. When the start fell outside the replay window, the settlement appends directly with `callTime: null`; Runtime never fabricates a zero duration. Live mux frames and history replay share this fold and tree projection, and child calls never become independent roots in transcript `nodes`. A child update copies only its ancestor path to the owning root; unchanged siblings and other roots retain object identity. Wire or history edges that would introduce a cycle or exceed the fixed 256-call recursive-depth safety limit are consumed without mutating the tree, so the rest of the session remains renderable.
## Session title projection

View File

@@ -22,6 +22,8 @@ Workspace 和 Session 列表各自具有单调的 `pending` → `ready` 基线
SlotsService 分别为 renderer 提供 `useSessions``useWorkspaces` 的裸 observableweb-react 创建钩子。Workspace 业务状态不会进入 `SessionListState` 或配置项 store。
`indexSubagentDescendants()` 从保留的列表镜像中派生每个 parent 的后代总数与运行中后代数。它只沿不间断的 `origin: 'subagent'` 祖先链追踪,因此普通 fork 会开启独立的归属子树;遇到环时,追踪会停止但不会抛出异常,缺失的 parent 则会保留为无害的键,直至其摘要到达。
`SessionsService.search(query, signal)` 是基于 `session.search` RPC 的无状态单次操作。它返回经过排序的会话snippet 对,但不会将查询条件、加载状态或错误状态写入共享 Session 列表,因此每个 UI 所有者都自行负责防抖、取消、抑制陈旧响应和回退呈现。`searchResultLimit``SESSION_SEARCH_RESULT_LIMIT`——即响应 schema 自身强制执行的上限——作为注入的呈现数据重新公开,使客户端插件无需复制该值。它是协议常量而非逐连接状态,因此连接 handle 不携带它。
## New Session 与 blank 镜像
@@ -36,15 +38,15 @@ SlotsService 分别为 renderer 提供 `useSessions` 与 `useWorkspaces` 的裸
`ConversationSnapshot.nodes` 是面向人的 transcript不是模型 surface。`TranscriptAdapter` 按日志顺序投影原始窗口。每个 append 来源的 surface 事件(`isAppendSurfaceEvent`落在它自己的日志位置上每次落地的压缩compaction检查点还会贡献一个 `CompactionSummaryNode` 标记;适配器从不查询 surface 顺序。`SteeringHistory` 会重放该窗口中的持久 `agent/inbox/spliced` 记录:用户来源的消息从 `next-step` 被领取,并以相同身份落成 `user/message` 时,会投影为 `SteeringMessageNode`;从 `next-turn` 领取的消息仍是用户节点,非用户来源的 next-step 输入仍是上下文。`ConversationSnapshot.turnEnds` 把该窗口中的每个已完成轮次映射到其 `turn/end` seq它独立于 transcript 保留轮次完成状态,使呈现层能够在启用操作前要求存在真实边界。于是一次落地的压缩会保留它在模型侧遮蔽掉的对话:标记报告模型从哪里开始看不见那段历史,而不是把它抹掉。仅模型可见的 replacement 副本不进入记录:被裁剪的 `tool/result` 和重新生成的 `assistant/message` 只为模型重写一个节点,不标记任何边界。检查点是携带压缩 seam 插件来源、且**替换**了一段 surface 范围的 `user/message`;一条 append 的插件来源 `user/message` 是注入上下文,不是压缩。每个上下文节点还携带一份 `provenance` 视图:`contextProvenance()` 只读取持久来源,据此判定该行是 `inject`(注入)还是跨会话的 `recall`(召回),并用该来源已经记录的指令文件路径、被引用会话标题或插件 id 命名其生产者。客户端不保存任何插件 id 表,因此重命名或新挂载的生产者无需客户端发版即可保持可辨识,恢复的会话日志与外部日志的投影结果和实时会话完全一致;没有可读 kind 的来源则降级为无名注入。与之并列的 `contextForm()` 读取生产方声明的 `ContextForm`,这是相互独立的第二根轴:`kind` 说明上下文由谁产生,`form` 说明它是何种形态的信息,因此多个生产方可以共用一种形态。本 UI 版本不呈现的形态投影为 null按 opaque 渲染。适配器的插件字面量通过对无 cordis 的 [`dsh-compact/checkpoint`](../../compact/compact/README.md) 叶子做仅类型导入,钉在压缩 seam 自己的声明上:在那里改名会让此处 `tsc` 失败而对该包package做**值**导入会被客户端纯度门禁拒绝,包的**根**即便作为类型也无法到达(它会到达 `dsh-session` 的根,其 `Context` 合并会让 host 的 `sessions` 与本程序的冲突)。
由于投影按日志顺序,节点数组天然按 seq 单调:仅日志的 `command/run` / `command/done` 节点按 seq 插入,`Session` 按分数 seq 归并被打断的冻结节点,而检查点所引范围落在窗口之外的窗口会渲染出标记且不打印任何日志。标记的摘要文本来自检查点的 `compact/summary` 溯源;窗口切分把溯源留在窗口外时该行不可展开而非空白,后续补上溯源的分页会解析出文本。性能契约:一次追加最多物化一个节点,并且仅在加入该节点时复制投影;不改变任何节点的事件保持上一次的数组引用(分片风暴零成本),未变化的节点保持其对象标识。
由于投影按日志顺序,节点数组天然按 seq 单调:仅日志的 `command/run` / `command/done` 节点按 seq 插入,`Session` 按分数 seq 归并被打断的冻结节点,而检查点所引范围落在窗口之外的窗口会渲染出标记且不打印任何日志。标记的摘要文本、被替换条目数量和估算的被遮蔽 token 数量都来自检查点的 `compact/summary` 溯源;窗口切分把溯源留在窗口外时这些字段不可用,后续补上溯源的分页会解析出它们。`CommandNode.outcome.sourceEventSeq` 保留成功命令对该摘要事件的显式引用,使呈现层能够配对 `/compact` 与其检查点,而无须解析结算文案或假定两行相邻。性能契约:一次追加最多物化一个节点,并且仅在加入该节点时复制投影;不改变任何节点的事件保持上一次的数组引用(分片风暴零成本),未变化的节点保持其对象标识。
## 请求检查
`SessionHistoryInspection.requests` 是一条按时间顺序排列、以用途为判别字段的提供方请求流。助手请求始终携带数值型 `turn``step`;压缩请求携带 `step: 0`,其 `turn` 所有者可以是 `null`。这个 null 所有者表示手动压缩独立运行在两个轮次之间,并不表示它属于任一相邻轮次。`session/end-seed` 边界会在边界时刻将未匹配的压缩请求以错误状态结束,错误固定为 `Compaction was interrupted before completion.`;后续 start 会投影为独立请求,而不会覆盖这项遗留的未匹配请求。
## Code Mode 子调用索引
## Code Mode 子调用
`ConversationSnapshot.codeDispatches` 按父调用的 callId 和启动顺序,用原生调用块形状组织一个 `run_code` 调用的子调用`tool/code-dispatch-start` 事件落成 `RunningToolCall` 形状(行组件从该形状推导运行中的转圈状态),其 `tool/code-dispatch` 完结事件原位替换为 `ToolResultNode` 形状`callTime` 携带成对 start 事件的时间。start 落在回放窗口之外完结事件则直接追加,`callTime: null`(耗时未知——绝不伪造零耗时。live mux 帧与历史回放构建相同的索引;子调用永不进入 transcript `nodes` 流;无关快照交换不会改变每个父调用对应的数组引用和映射引用,两者均保持 memo 稳定
每个 `ToolCallBlock` 都通过 `subCalls` 按启动顺序递归拥有自己的子调用。Runtime 的 `ToolCallTree` 私下维护 parent callId 到 child 的索引`tool/code-dispatch-start` 事件落成 `RunningToolCall`,对应的 `tool/code-dispatch` 完结事件原位替换为 `ToolResultNode``callTime` 来自成对 start 事件start 落在回放窗口之外时,完结事件会以 `callTime: null` 直接追加,绝不伪造零耗时。live mux 帧与历史回放共用这套 fold 和树投影;子调用不会成为 transcript `nodes` 中的独立 root。一次 child 变化只会复制从该 child 到所属 root 的祖先链,未变化的 sibling 和其他 root 保持对象引用稳定。会引入环,或使递归深度超过 256 个调用这一固定安全上限的协议或历史记录边会被视为已消费,但不会修改树,因此会话其余部分仍可渲染
## Session 标题投影

View File

@@ -8,13 +8,15 @@ 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'
export type { RootOwnerProps } from './slots.ts'
export { SessionCreateError, SessionsService, scopeOf, workspaceTitleOf } from './sessions/service.ts'
export { SessionHistoryService } from './session-history/service.ts'
export { indexSubagentDescendants } from './sessions/subagent-lineage.ts'
export type { SubagentDescendantSummary } from './sessions/subagent-lineage.ts'
// The provide channel is shared with the client test runtime (one
// materialization/projection implementation; no test-side mirror to drift).
export { SessionProvideChannel } from './sessions/provide.ts'
@@ -22,6 +24,7 @@ export type { SessionProvideChannelHost } from './sessions/provide.ts'
export { createScope } from './agents/scope.ts'
export type { AgentScopeHandle } from './agents/scope.ts'
export { DirectoryBrowseError, WorkspaceCreateError, WorkspacesService } from './workspaces/service.ts'
export { resolveWorkspacePath } from './workspaces/path.ts'
export type { Session } from './sessions/session.ts'
export type { ISession, ProjectionsFace, SessionFace } from './contract/session.ts'
export type {
@@ -46,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,
@@ -83,16 +86,9 @@ declare module '@deepseek-ai/dsh-type-meta' {
}
}
/** The conversation-snapshot selector hook (ConvViewProps/ToolRowProps take this). */
/** The conversation-snapshot selector hook supplied to session-scoped UI entries. */
export type UseConversationSession = SnapshotSelectorHook<ConversationSnapshot>
/**
* One tool call as the chat flow renders it: still-running (spinner card) or
* settled (result node). The fold produces both shapes; toolview components
* narrow on the discriminant fields.
*/
export type ToolCallBlock = RunningToolCall | ToolResultNode
declare module '@deepseek-ai/dsh-client-ui-slots' {
/**
* Session standard kit, real members (ui-slots declares the empty seat;

View File

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

View File

@@ -83,6 +83,9 @@ export function contextProvenance(source: unknown): ContextProvenanceView {
return { role: 'inject', label: joined(collect(record, 'changes', 'path')) ?? kind }
case 'plugin':
return { role: 'inject', label: readString(record, 'plugin') ?? kind }
// A user-explicit skill invocation names the skill it injected.
case 'skill-invocation':
return { role: 'inject', label: readString(record, 'name') ?? kind }
// Documented default arm of the merge-extensible source map: an unknown
// producer still identifies itself by its own durable kind.
default:

View File

@@ -174,6 +174,8 @@ export interface ToolResultNode {
callView: ToolCallView | null
/** Host-computed render intent from this tool/result's wire view; null = same default. */
resultView: ToolResultView | null
/** Child calls owned by this call, in dispatch order. */
subCalls: readonly ToolCallBlock[]
}
/**
@@ -192,6 +194,12 @@ export interface CompactionSummaryNode {
/** Summary text from the checkpoint's `compact/summary` provenance; null when
* the window cut left that provenance outside (the marker is then not expandable). */
summary: string | null
/** Seq of the loaded `compact/summary` event, or null when that provenance is outside the window. */
summaryEventSeq: number | null
/** Number of surface items replaced, or null when summary provenance is unavailable or malformed. */
shadowedItemCount: number | null
/** Estimated token price of the replaced items, or null when summary provenance is unavailable or malformed. */
shadowedTokenCount: number | null
}
/**
@@ -236,7 +244,12 @@ export interface CommandNode {
*/
args: string | null
/** Settlement outcome (done payload); null while the command is still executing. */
outcome: { kind: 'success' | 'error'; text?: string } | null
outcome: {
kind: 'success' | 'error'
text?: string
/** Earlier authoritative domain event for a richer client-computed presentation. */
sourceEventSeq?: number
} | null
}
/** Finalized conversation node union (kind discriminates; seq is the React key). */
@@ -252,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
@@ -278,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 {
@@ -344,13 +346,6 @@ export interface ConversationSnapshot {
turnEnds: ReadonlyMap<number, number>
partial: PartialAssistant | null
runningCalls: readonly RunningToolCall[]
/**
* `run_code` sub-dispatches grouped under their parent callId, in dispatch
* order. Populated from in-window `tool/code-dispatch` events (live and
* replay identically); the per-parent array reference is stable across
* unrelated snapshot swaps (memo premise, same regime as `nodes`).
*/
codeDispatches: ReadonlyMap<string, readonly CodeSubCall[]>
pending: readonly PendingInteraction[]
/** Authoritative transient inbox snapshot, including queued and steering placements. */
queue: readonly QueuedMessage[]

View File

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

View File

@@ -13,7 +13,7 @@ import type {
import { transportError } from '@deepseek-ai/dsh-host-apiproxy/api'
import type { SessionFace } from '../contract/session.ts'
import type {
CodeSubCall, ComposerPhase, ConversationNode, ConversationSnapshot, ModelRetryNode,
ComposerPhase, ConversationNode, ConversationSnapshot, ModelRetryNode,
OpenState, PromptError, QueuedMessage, RunningToolCall,
} from './conversation.ts'
import type { PendingInteraction } from './pending.ts'
@@ -24,6 +24,7 @@ import { Notifier } from './notifier.ts'
import { isVisibleAssistantChunk, PartialAccumulator } from './partial.ts'
import { ProjectionValueStore } from './projection-store.ts'
import type { ProjectionsBaseline } from './projection-store.ts'
import { ToolCallTree } from './tool-call-tree.ts'
/** Messages requested per history page. */
export const PAGE_MESSAGES = 50
@@ -129,11 +130,8 @@ export class Session implements SessionFace {
private queued: QueuedMessage[] = []
private queueRev = 0
private queueCache: { rev: number; value: QueuedMessage[] } | null = null
/** `run_code` sub-dispatches by parent callId (window-derived, like openCalls). Appends
* copy-on-write the per-parent array so published snapshot references never mutate. */
private codeDispatches = new Map<string, readonly CodeSubCall[]>()
private dispatchesRev = 0
private dispatchesCache: { rev: number; value: ReadonlyMap<string, readonly CodeSubCall[]> } | null = null
/** Window-derived child-call lifecycle and immutable tree projection. */
private readonly toolCallTree = new ToolCallTree()
private running = false
private address: SubagentAddress | undefined
private parentAvailable = false
@@ -283,17 +281,22 @@ export class Session implements SessionFace {
/**
* Stop the active turn while the Host preserves pending inbox work; failures
* land in promptError (same error-strip display slot).
* land in promptError (same error-strip display slot). A continuable
* subagent address routes through `subagent.interrupt`, whose durable
* parent-address authority works without a live parent Agent; a one-shot
* address stays uncancellable (the UI offers no stop action, so this arm is
* defensive).
* @returns the cancel result.
*/
async cancel(): Promise<RpcResult<{ accepted: true }>> {
if (this.address !== undefined) {
const address = this.address
if (address !== undefined && address.mode === 'one-shot') {
const result: RpcResult<{ accepted: true }> = {
ok: false,
error: {
code: 'subagent-delivery-unavailable',
message: 'subagent activation cancellation is unavailable',
details: { childSessionId: this.address.childSessionId },
details: { childSessionId: address.childSessionId },
},
}
this.promptError = { op: 'stop', error: result.error }
@@ -302,7 +305,9 @@ export class Session implements SessionFace {
}
let result: RpcResult<{ accepted: true }>
try {
result = (await this.api.sessions.cancel({ sessionId: this.sessionId })).result
result = address !== undefined
? (await this.api.subagents.interrupt(address)).result
: (await this.api.sessions.cancel({ sessionId: this.sessionId })).result
} catch (error) {
result = transportError(error)
}
@@ -746,65 +751,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 +784,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 +852,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 +899,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 +938,18 @@ export class Session implements SessionFace {
if (this.pendingCache === null || this.pendingCache.rev !== this.pendingRev) {
this.pendingCache = { rev: this.pendingRev, value: [...this.pending.values()] }
}
if (this.dispatchesCache === null || this.dispatchesCache.rev !== this.dispatchesRev) {
this.dispatchesCache = { rev: this.dispatchesRev, value: new Map(this.codeDispatches) }
}
if (this.queueCache === null || this.queueCache.rev !== this.queueRev) {
this.queueCache = { rev: this.queueRev, value: this.queued }
}
const partial = this.partial?.toPartial() ?? null
return {
sessionId: this.sessionId,
nodes,
nodes: this.toolCallTree.projectNodes(nodes),
turnTimings: this.turnTimingsCache.value,
turnEnds: this.turnEndsCache.value,
partial,
runningCalls: this.callsCache.value,
runningCalls: this.toolCallTree.projectRunningCalls(this.callsCache.value),
pending: this.pendingCache.value,
codeDispatches: this.dispatchesCache.value,
queue: this.queueCache.value,
running: this.running,
subagent: this.address === undefined

View File

@@ -0,0 +1,50 @@
/**
* Pure subagent-lineage aggregation over the retained session-list mirror.
* Ordinary forks terminate propagation so each visible session owns only its
* uninterrupted subagent subtree.
* @module @deepseek-ai/dsh-client-runtime/client/sessions/subagent-lineage
*/
import type { SessionId } from '@deepseek-ai/dsh-client-connection/client'
import type { SessionSummary } from './service.ts'
/** Descendant counts projected for one possible parent session. */
export interface SubagentDescendantSummary {
/** All descendants connected through uninterrupted subagent-origin lineage. */
readonly count: number
/** Descendants whose exact session summary is currently running. */
readonly runningCount: number
}
/**
* Index every subagent descendant under each ancestor it reaches through an
* uninterrupted subagent-origin chain. Cycles fail soft and orphan owners
* remain harmless map keys until their summaries arrive.
* @param summaries - retained session summaries keyed by id.
* @returns descendant totals and running totals keyed by possible parent id.
*/
export function indexSubagentDescendants(
summaries: Readonly<Record<SessionId, SessionSummary>>,
): ReadonlyMap<SessionId, SubagentDescendantSummary> {
const indexed = new Map<SessionId, { count: number; runningCount: number }>()
for (const descendant of Object.values(summaries)) {
if (descendant.origin !== 'subagent') continue
const seen = new Set<SessionId>()
let current: SessionSummary | undefined = descendant
while (current?.origin === 'subagent' && current.parentId !== undefined
&& !seen.has(current.id)) {
seen.add(current.id)
const aggregate = indexed.get(current.parentId)
if (aggregate === undefined) {
indexed.set(current.parentId, {
count: 1,
runningCount: descendant.running ? 1 : 0,
})
} else {
aggregate.count += 1
if (descendant.running) aggregate.runningCount += 1
}
current = summaries[current.parentId]
}
}
return indexed
}

View File

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

View File

@@ -57,10 +57,11 @@ function materializeNode(
stepTimings: ReadonlyMap<string, AssistantStepMetadata>,
): ConversationNode {
switch (event.type) {
case 'user/message':
// Injected context (plugin/goal source) folds to a context node, not a
// user message; only a direct human prompt is a user node. A compaction
// checkpoint never reaches here (isCompactCheckpoint routes it away).
case 'user/message': {
// Injected context (plugin/goal/skill-invocation source) folds to a
// context node, not a user message; only a direct human prompt is a
// user node. A compaction checkpoint never reaches here
// (isCompactCheckpoint routes it away).
if (event.data.source.kind !== 'user') {
return {
kind: 'context', seq: event.seq, time: event.time,
@@ -80,6 +81,7 @@ function materializeNode(
kind: 'user', seq: event.seq, time: event.time,
content: event.data.content, source: event.data.source,
}
}
case 'assistant/message':
return {
kind: 'assistant', seq: event.seq, time: event.time,
@@ -101,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
@@ -156,6 +159,29 @@ function compactSummaryText(event: SessionEvent): string | null {
return text.trim() === '' ? null : text
}
interface CompactSummaryDetails {
readonly summary: string | null
readonly shadowedItemCount: number | null
readonly shadowedTokenCount: number | null
}
/** Recover human-facing summary material from one structurally narrowed wire event. */
function compactSummaryDetails(event: SessionEvent): CompactSummaryDetails {
const data = event.data as unknown as { shadowedSeqs?: unknown; shadowedTokenCount?: unknown }
const shadowedSeqs = data.shadowedSeqs
const tokenCount = data.shadowedTokenCount
return {
summary: compactSummaryText(event),
shadowedItemCount: Array.isArray(shadowedSeqs)
&& shadowedSeqs.every((seq: unknown) => Number.isSafeInteger(seq) && (seq as number) >= 0)
? shadowedSeqs.length
: null,
shadowedTokenCount: Number.isSafeInteger(tokenCount) && (tokenCount as number) >= 0
? tokenCount as number
: null,
}
}
/**
* One landed checkpoint -> the human-facing compaction marker. The summary text
* comes from the checkpoint's own provenance (`sourceEventSeqs` names the
@@ -170,13 +196,28 @@ function materializeCompaction(
): CompactionSummaryNode {
const sources = (checkpoint as SessionEvent & { sourceEventSeqs?: number[] }).sourceEventSeqs
let summary: string | null = null
let summaryEventSeq: number | null = null
let shadowedItemCount: number | null = null
let shadowedTokenCount: number | null = null
for (const seq of sources ?? []) {
const candidate = eventIndex.get(seq)
if (candidate === undefined || (candidate.type as string) !== 'compact/summary') continue
summary = compactSummaryText(candidate)
const details = compactSummaryDetails(candidate)
summary = details.summary
summaryEventSeq = candidate.seq
shadowedItemCount = details.shadowedItemCount
shadowedTokenCount = details.shadowedTokenCount
break
}
return { kind: 'compaction', seq: checkpoint.seq, time: checkpoint.time, summary }
return {
kind: 'compaction',
seq: checkpoint.seq,
time: checkpoint.time,
summary,
summaryEventSeq,
shadowedItemCount,
shadowedTokenCount,
}
}
/** Log-ordered human transcript over a paged raw event window (never consults surface order). */
@@ -321,9 +362,22 @@ export class TranscriptAdapter {
return true
}
if ((event.type as string) !== 'command/done') return false
const data = event.data as unknown as { commandId: CommandId; kind: 'success' | 'error'; text?: string }
const data = event.data as unknown as {
commandId: CommandId
kind: 'success' | 'error'
text?: string
sourceEventSeq?: number
}
const run = this.commandIdx.get(data.commandId)
const outcome = { kind: data.kind, ...data.text === undefined ? {} : { text: data.text } }
const sourceEventSeq = data.kind === 'success'
&& Number.isSafeInteger(data.sourceEventSeq) && (data.sourceEventSeq as number) >= 0
? data.sourceEventSeq as number
: undefined
const outcome = {
kind: data.kind,
...data.text === undefined ? {} : { text: data.text },
...sourceEventSeq === undefined ? {} : { sourceEventSeq },
}
if (run === undefined) {
// Cross-window cut: the run page fell out of the window — build the
// node from the done alone (same soft-fall as a call-less tool result).

View File

@@ -19,7 +19,7 @@ import type { Context } from 'cordis'
import { SlotCore } from '@deepseek-ai/dsh-client-ui-slots'
import type {
LocaleFace, OwnerOf, SlotEntryDef, SlotMap, SlotRenderer, SlotRendererHost,
SlotScope, SlotSpec, StoreDecl, StoredEntry, StoreInstanceLike,
SlotScope, SlotSpec, StoreDecl, StoreFactory, StoredEntry, StoreInstanceLike,
} from '@deepseek-ai/dsh-client-ui-slots'
declare module '@deepseek-ai/dsh-client-ui-slots' {
@@ -35,16 +35,11 @@ export interface RootOwnerProps { children?: never }
/** Instance key for root-scoped store records (session records key by session id, so the literal cannot collide). */
const ROOT_INSTANCE_KEY = 'root'
// FIXME(slot-parity): the engine's arbitrated persist extensions — create()
// takes the scope key (per-session localStorage suffix) and instances expose
// clearPersisted() — are not yet on ui-slots' StoreHandle/StoreInstanceLike;
// these local structural faces bridge until fw-slots lifts them.
/** Canonical type-erased store handle used by the runtime lifecycle map. */
type EngineStoreHandle = Exclude<StoreDecl, StoreFactory>
/** Store handle face as the engine actually ships it (scope-key-aware create). */
interface EngineStoreHandle { create(scopeKey?: string): EngineStoreInstance }
/** Engine instance face: the host-contract shape plus persisted-state cleanup. */
interface EngineStoreInstance extends StoreInstanceLike { clearPersisted(): void }
/** Canonical engine instance derived from the handle's create contract. */
type EngineStoreInstance = ReturnType<EngineStoreHandle['create']>
/** Store axis record: one per live handle, dropped when the last holding entry unloads. */
interface StoreAxisRecord {

View File

@@ -0,0 +1,13 @@
/**
* Resolve a workspace-relative path into the Host-facing spelling used by openPath.
* @param cwd - session workspace root, when known.
* @param path - absolute or workspace-relative path.
* @returns an absolute path when a workspace root is available, otherwise the original path.
*/
export function resolveWorkspacePath(cwd: string | undefined, path: string): string {
if (path.startsWith('/') || /^[A-Za-z]:[/\\]/.test(path) || path.startsWith('\\\\')) return path
if (cwd === undefined || cwd === '') return path
const base = cwd.replace(/[/\\]+$/, '')
const rel = path.replace(/^[/\\]+/, '')
return `${base}/${rel}`
}

View File

@@ -36,7 +36,10 @@ describe('compaction checkpoint recognition', () => {
it('recognizes a checkpoint carrying the seam-canonical source', () => {
const adapter = new TranscriptAdapter()
adapter.reset([canonicalCheckpoint(1)])
expect(adapter.nodes()).toEqual([{ kind: 'compaction', seq: 1, time: 1_700_000_000_001, summary: null }])
expect(adapter.nodes()).toEqual([{
kind: 'compaction', seq: 1, time: 1_700_000_000_001, summary: null,
summaryEventSeq: null, shadowedItemCount: null, shadowedTokenCount: null,
}])
})
it("agrees with the seam's own predicate on the source it recognizes", () => {

View File

@@ -92,8 +92,19 @@ export const ev = {
at(seq, { type: 'command/run', data: { commandId, name, args, source: { kind: 'user' } } }),
commandRunWithoutInput: (seq: number, commandId: string, name: string): SessionEvent =>
at(seq, { type: 'command/run', data: { commandId, name, source: { kind: 'user' } } }),
commandDone: (seq: number, commandId: string, kind: 'success' | 'error' = 'success', text?: string): SessionEvent =>
at(seq, { type: 'command/done', data: { commandId, kind, ...text === undefined ? {} : { text } } }),
commandDone: (
seq: number,
commandId: string,
kind: 'success' | 'error' = 'success',
text?: string,
sourceEventSeq?: number,
): SessionEvent =>
at(seq, { type: 'command/done', data: {
commandId,
kind,
...text === undefined ? {} : { text },
...sourceEventSeq === undefined ? {} : { sourceEventSeq },
} }),
/** A compaction's log-only `compact/summary` provenance record. */
compactSummary: (seq: number, summary: string, start: number, end: number): SessionEvent =>
at(seq, { type: 'compact/summary', data: {

View File

@@ -140,10 +140,14 @@ export class FakeApiClient implements IApiClient {
onSubagentPrompt: (payload: unknown) => Promise<RpcResponse<{ messageId: never }>>
= () => Promise.resolve(ok({ messageId: 'fake-message' as never }))
onSubagentInterrupt: (payload: unknown) => Promise<RpcResponse<{ accepted: true }>>
= () => Promise.resolve(ok({ accepted: true as const }))
readonly subagents: IApiClient['subagents'] = {
list: (payload: unknown) => this.record('subagent.list', payload, this.onSubagentList(payload)),
history: (payload: unknown) => this.record('subagent.history', payload, this.onSubagentHistory(payload)),
prompt: (payload: unknown) => this.record('subagent.prompt', payload, this.onSubagentPrompt(payload)),
interrupt: (payload: unknown) => this.record('subagent.interrupt', payload, this.onSubagentInterrupt(payload)),
}
readonly host: IApiClient['host'] = {
@@ -198,6 +202,7 @@ export class FakeApiClient implements IApiClient {
onSkillList: (payload: unknown) => Promise<RpcResponse<{ skills: SkillEntry[] }>>
= () => Promise.resolve(ok({ skills: [] }))
readonly commands: IApiClient['commands'] = {
list: (payload: unknown) => this.record('command.list', payload, this.onCommandList(payload)),
execute: (payload: unknown) => this.record('command.execute', payload, this.onCommandExecute(payload)),

View File

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

View File

@@ -639,7 +639,7 @@ describe('paging', () => {
})
describe('prompt and cancel errors', () => {
it('routes an addressed child through non-activating history and continuation prompt only', async () => {
it('routes an addressed child through non-activating history, continuation prompt, and interrupt only', async () => {
const api = new FakeApiClient()
const session = new Session(SID, api, {
address: { parentSessionId: PARENT, childSessionId: SID, mode: 'continuable' },
@@ -650,7 +650,7 @@ describe('prompt and cancel errors', () => {
const cancelled = await session.cancel()
expect(prompted).toEqual({ ok: true, value: { accepted: true } })
expect(cancelled).toMatchObject({ ok: false, error: { code: 'subagent-delivery-unavailable' } })
expect(cancelled).toEqual({ ok: true, value: { accepted: true } })
expect(api.callsOf('subagent.history')).toEqual([
{ parentSessionId: PARENT, childSessionId: SID, mode: 'continuable', maxMessages: 50 },
])
@@ -660,15 +660,37 @@ describe('prompt and cancel errors', () => {
content: [{ type: 'text', text: '继续' }],
},
])
expect(api.callsOf('subagent.interrupt')).toEqual([
{ parentSessionId: PARENT, childSessionId: SID, mode: 'continuable' },
])
expect(api.callsOf('session.history')).toEqual([])
expect(api.callsOf('session.prompt')).toEqual([])
expect(api.callsOf('session.cancel')).toEqual([])
// A successful interrupt leaves no stop error behind.
expect(session.getSnapshot().promptError).toBeNull()
expect(session.getSnapshot().subagent).toEqual({
address: { parentSessionId: PARENT, childSessionId: SID, mode: 'continuable' },
parentAvailable: true,
})
})
it('lands an interrupt business failure in promptError with op=stop', async () => {
const api = new FakeApiClient()
api.onSubagentInterrupt = () => Promise.resolve(err({
code: 'subagent-unauthorized', message: 'nope', details: { childSessionId: SID },
}) as never)
const session = new Session(SID, api, {
address: { parentSessionId: PARENT, childSessionId: SID, mode: 'continuable' },
parentAvailable: true,
})
await session.open()
const cancelled = await session.cancel()
expect(cancelled).toMatchObject({ ok: false, error: { code: 'subagent-unauthorized' } })
expect(session.getSnapshot().promptError).toMatchObject({
op: 'stop', error: { code: 'subagent-unauthorized' },
})
})
it('keeps one-shot history readable without exposing prompt or cancel transport', async () => {
const api = new FakeApiClient()
const session = new Session(SID, api, {
@@ -676,12 +698,16 @@ describe('prompt and cancel errors', () => {
})
await session.open()
const prompted = await session.prompt([{ type: 'text', text: '继续' }], 'queue')
const cancelled = await session.cancel()
expect(prompted).toMatchObject({ ok: false, error: { code: 'subagent-not-resumable' } })
expect(cancelled).toMatchObject({ ok: false, error: { code: 'subagent-delivery-unavailable' } })
expect(api.callsOf('subagent.history')).toEqual([
{ parentSessionId: PARENT, childSessionId: SID, mode: 'one-shot', maxMessages: 50 },
])
expect(api.callsOf('subagent.prompt')).toEqual([])
expect(api.callsOf('subagent.interrupt')).toEqual([])
expect(api.callsOf('session.cancel')).toEqual([])
})
it('sends content through session.prompt; composerPhase steps blank → engaging synchronously at send entry', async () => {
@@ -1151,7 +1177,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 +1197,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 +1223,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 +1241,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 +1272,48 @@ describe('run_code sub-dispatch indexing', () => {
feed(ev.toolCall(7, 1, 'p1', 'run_code', '{"code":"1","description":"d"}'))
feed(ev.codeDispatch(8, 'p1', 1, 'bash', { command: 'ls' }, 'x'))
const before = session.getSnapshot()
const beforeRoot = before.runningCalls.find(call => call.callId === 'p1')!
feed(ev.chunkStart(9, 1))
feed(ev.chunkText(10, 1, '流式'))
const after = session.getSnapshot()
expect(after.codeDispatches).toBe(before.codeDispatches)
const afterRoot = after.runningCalls.find(call => call.callId === 'p1')!
expect(afterRoot).toBe(beforeRoot)
feed(ev.codeDispatch(11, 'p1', 2, 'read', { path: 'a' }, 'y'))
expect(session.getSnapshot().codeDispatches).not.toBe(after.codeDispatches)
expect(session.getSnapshot().codeDispatches.get('p1')).toHaveLength(2)
const changedRoot = session.getSnapshot().runningCalls.find(call => call.callId === 'p1')!
expect(changedRoot).not.toBe(afterRoot)
expect(changedRoot.subCalls[0]).toBe(afterRoot.subCalls[0])
expect(changedRoot.subCalls).toHaveLength(2)
})
it('path-copies only the owning branch when a nested child changes', async () => {
const { api, session } = makeSession()
api.onHistory = () => histResponse(plainTurn(0, 0, '树', '结构'))
await session.open()
const feed = (event: SessionEvent) => { session.handleMuxEnvelope('r' as never, { type: 'session/event', sessionId: SID, event }) }
feed(ev.turnStart(6, 1))
feed(ev.toolCall(7, 1, 'p1', 'run_code', '{"code":"1","description":"first"}'))
feed(ev.toolCall(8, 1, 'p2', 'run_code', '{"code":"2","description":"second"}'))
feed(ev.codeDispatch(9, 'p1', 1, 'run_code', { code: 'nested' }, 'child'))
feed(ev.codeDispatch(10, 'p1', 2, 'read', { path: 'sibling' }, 'sibling'))
feed(ev.codeDispatch(11, 'p2', 1, 'bash', { command: 'pwd' }, 'root two'))
const before = session.getSnapshot()
const beforeFirst = before.runningCalls.find(call => call.callId === 'p1')!
const beforeSecond = before.runningCalls.find(call => call.callId === 'p2')!
const beforeChild = beforeFirst.subCalls[0]!
const beforeSibling = beforeFirst.subCalls[1]!
feed(ev.codeDispatch(12, 'p1:code:1', 1, 'read', { path: 'nested' }, 'leaf'))
const after = session.getSnapshot()
const afterFirst = after.runningCalls.find(call => call.callId === 'p1')!
const afterSecond = after.runningCalls.find(call => call.callId === 'p2')!
expect(afterFirst).not.toBe(beforeFirst)
expect(afterSecond).toBe(beforeSecond)
expect(afterFirst.subCalls[0]).not.toBe(beforeChild)
expect(afterFirst.subCalls[1]).toBe(beforeSibling)
expect(afterFirst.subCalls[0]?.subCalls).toMatchObject([
{ callId: 'p1:code:1:code:1', call: { name: 'read' } },
])
})
})

View File

@@ -0,0 +1,53 @@
import { describe, expect, it } from 'vitest'
import type { SessionId, SessionSummary } from '@deepseek-ai/dsh-client-runtime/client'
import { indexSubagentDescendants } from '@deepseek-ai/dsh-client-runtime/client'
const sid = (id: string) => id as SessionId
function summary(
id: string,
parentId?: SessionId,
origin?: 'subagent',
running = false,
): SessionSummary {
return {
id: sid(id), displayTitle: id, running, blank: false, updatedAt: 0,
...(parentId === undefined ? {} : { parentId }),
...(origin === undefined ? {} : { origin }),
}
}
function index(...summaries: SessionSummary[]) {
return indexSubagentDescendants(Object.fromEntries(
summaries.map(item => [item.id, item]),
))
}
describe('indexSubagentDescendants', () => {
it('counts every nested descendant and its exact running state', () => {
const owner = summary('owner')
const child = summary('child', owner.id, 'subagent')
const grandchild = summary('grandchild', child.id, 'subagent', true)
const result = index(owner, child, grandchild)
expect(result.get(owner.id)).toEqual({ count: 2, runningCount: 1 })
expect(result.get(child.id)).toEqual({ count: 1, runningCount: 1 })
})
it('stops at ordinary forks and fails soft on cycles and missing parents', () => {
const owner = summary('owner')
const child = summary('child', owner.id, 'subagent', true)
const fork = summary('fork', child.id)
const forkChild = summary('fork-child', fork.id, 'subagent', true)
const orphan = summary('orphan', sid('missing'), 'subagent', true)
const cycleA = summary('cycle-a', sid('cycle-b'), 'subagent')
const cycleB = summary('cycle-b', sid('cycle-a'), 'subagent')
const result = index(owner, child, fork, forkChild, orphan, cycleA, cycleB)
expect(result.get(owner.id)).toEqual({ count: 1, runningCount: 1 })
expect(result.get(fork.id)).toEqual({ count: 1, runningCount: 1 })
expect(result.get(sid('missing'))).toEqual({ count: 1, runningCount: 1 })
expect(result.get(cycleA.id)).toEqual({ count: 2, runningCount: 0 })
expect(result.get(cycleB.id)).toEqual({ count: 2, runningCount: 0 })
})
})

View File

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

View File

@@ -164,6 +164,28 @@ describe('TranscriptAdapter', () => {
expect(adapter.nodes().map(node => node.kind)).toEqual(['user', 'user', 'context'])
})
it('materializes a skill-invocation injection as a named instructions context', () => {
const adapter = new TranscriptAdapter()
adapter.reset([
at(0, { type: 'user/message', surfaceOp: 'append', data: createUserMessage({
content: [{ type: 'text', text: '/hidden-demo check the fixture' }],
source: { kind: 'user' },
}) }),
at(1, { type: 'user/message', surfaceOp: 'append', data: createUserMessage({
content: [{ type: 'text', text: '<skill_content name="hidden-demo">body</skill_content>' }],
source: { kind: 'skill-invocation', name: 'hidden-demo', form: 'instructions' } as never,
}) }),
])
const nodes = adapter.nodes()
// The gesture stays a user bubble; the injected body folds to a context
// row named after the skill, presented as instructions.
expect(nodes.map(node => node.kind)).toEqual(['user', 'context'])
expect(nodes[1]).toMatchObject({
provenance: { role: 'inject', label: 'hidden-demo' },
form: 'instructions',
})
})
it('skips events core does not call surface-eligible, marker or not', () => {
// The transcript is the append-origin surface, so log-only events (a chunk,
// a turn boundary, a compact/* provenance record) and a future type core
@@ -223,8 +245,14 @@ describe('TranscriptAdapter', () => {
checkpoint(5, 4, { start: 2, end: 3, sourceEventSeqs: [4, 2, 3] }),
])
expect(adapter.nodes().filter(n => n.kind === 'compaction')).toEqual([
{ kind: 'compaction', seq: 2, time: 1_700_000_000_002, summary: 'first' },
{ kind: 'compaction', seq: 5, time: 1_700_000_000_005, summary: 'second' },
{
kind: 'compaction', seq: 2, time: 1_700_000_000_002, summary: 'first',
summaryEventSeq: 1, shadowedItemCount: 2, shadowedTokenCount: 100,
},
{
kind: 'compaction', seq: 5, time: 1_700_000_000_005, summary: 'second',
summaryEventSeq: 4, shadowedItemCount: 2, shadowedTokenCount: 100,
},
])
})
@@ -296,7 +324,7 @@ describe('TranscriptAdapter', () => {
...(summary === undefined ? [] : [summary]),
checkpoint(2, 1, { start: 0, end: 0, sourceEventSeqs: [1, 0] }),
])
expect(adapter.nodes()).toEqual([
expect(adapter.nodes()).toMatchObject([
{ kind: 'compaction', seq: 2, time: 1_700_000_000_002, summary: null },
])
})
@@ -310,7 +338,10 @@ describe('TranscriptAdapter', () => {
checkpoint(2, 1, { start: 0, end: 0, sourceEventSeqs: [1, 0] }),
])
expect(adapter.nodes()).toEqual([
{ kind: 'compaction', seq: 2, time: 1_700_000_000_002, summary: '可用摘要' },
{
kind: 'compaction', seq: 2, time: 1_700_000_000_002, summary: '可用摘要',
summaryEventSeq: 1, shadowedItemCount: 2, shadowedTokenCount: 100,
},
])
})
@@ -324,7 +355,10 @@ describe('TranscriptAdapter', () => {
source: { kind: 'plugin', plugin: 'compact' },
}),
})])
expect(adapter.nodes()).toEqual([{ kind: 'compaction', seq: 2, time: 1_700_000_000_002, summary: null }])
expect(adapter.nodes()).toEqual([{
kind: 'compaction', seq: 2, time: 1_700_000_000_002, summary: null,
summaryEventSeq: null, shadowedItemCount: null, shadowedTokenCount: null,
}])
})
it('skips a non-summary provenance seq before reaching the real one', () => {
@@ -468,20 +502,22 @@ describe('TranscriptAdapter', () => {
expect(adapter.nodes().map(n => n.kind)).toEqual(['user', 'command'])
})
it('renders the /compact row alongside the marker its own command produced', () => {
// The row that reports the compaction is a command node; dropping command
// folding would delete it together with every other slash-command row.
it('preserves the domain-event link for the UI to fold a /compact row into its marker', () => {
const adapter = new TranscriptAdapter()
adapter.reset([
ev.user(0, '压缩前的问题'),
ev.commandRun(1, 'cmd-compact', 'compact'),
compactSummary(2, [{ type: 'text', text: '手动压缩摘要' }]),
checkpoint(3, 2, { start: 0, end: 0, sourceEventSeqs: [2, 0] }),
ev.commandDone(4, 'cmd-compact', 'success', '已压缩'),
ev.commandDone(4, 'cmd-compact', 'success', '已压缩', 2),
])
const nodes = adapter.nodes()
expect(nodes.map(n => [n.kind, n.seq])).toEqual([['user', 0], ['command', 1], ['compaction', 3]])
expect(nodes[1]).toMatchObject({ name: 'compact', outcome: { kind: 'success', text: '已压缩' } })
expect(nodes[1]).toMatchObject({
name: 'compact',
outcome: { kind: 'success', text: '已压缩', sourceEventSeq: 2 },
})
expect(nodes[2]).toMatchObject({ kind: 'compaction', summaryEventSeq: 2 })
})
})