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

@@ -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}`
}