refactor(client): isolate trajectory history reads

This commit is contained in:
_Kerman
2026-07-28 13:50:46 +08:00
parent 5fc9a041ba
commit 028ac5c2f6
18 changed files with 909 additions and 670 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 .agents/notes/implemented/feature/2026-07-27-trajectory-inspection-ledger.md
2026-07-27-trajectory-inspection-ledger.md: feefac9098a9ddc15b1d636ac9edc010655bd7a7
2026-07-27-trajectory-inspection-ledger.zh.md: 50da33604a35be2eefe4d6cb4d7b4321427eae99
2026-07-27-trajectory-inspection-ledger.md: 9994eae4e6e9267a998daa05a631f35f58f28304
2026-07-27-trajectory-inspection-ledger.zh.md: e6d64f7a010e68a0ba7c08ff29c6c23c2cbdd9b1

View File

@@ -16,6 +16,8 @@ Trajectory has to make prose, machine payloads, token usage, timing, and nested
- Event kind and content form the two stable columns. Role tags align toward the content, nested subtools receive a small indentation, and CSS truncation preserves the available preview width. Token usage and duration stay in the inspector.
- Product prose uses the existing sans stack. Turn ids, token counts, durations, tool calls, raw payloads, and other machine data use the existing code stack.
- Existing theme tokens own both light and dark rendering. Neutral borders and surfaces form the structure; distinct low-emphasis role hues support scanning without carrying success or failure meaning, while business blue identifies selection, links, and focus.
- Session exposes a separate read-only history source containing raw log entries and paging state; the ordinary conversation snapshot remains the folded Chat projection. Trajectory subscribes to that source, exhausts its paging only while mounted, and lazily derives its event order, context lineage, schema index, and Requests instead of imposing those structures on every conversation consumer.
- Ordinary generation and compaction calls form one chronological Request projection, distinguished by purpose rather than separate collections. Effective prompt state and its change ride the Request that introduced them; compaction and prompt changes are not independent inspection entities. Complete history makes global Request numbering and cumulative usage session-wide rather than tail-window-relative.
- Selecting a record or Request opens an inspector inside Trajectory. Tabs and Summary sections follow the selected entity: Markdown messages expose rendered, source, provenance, and hierarchy views; tools add JSON payload/result and schema views; Requests add options, usage, timing, and result navigation. Images render as media rather than serialized data.
- Turn folding removes all rows after its first record and replaces them with a compact step/tool-call count; Assistant folding applies the same interaction to its tool-call descendants. Global controls fold or expand both levels.
- This local inspector remains independent from the conversation-wide Chat details column. At narrow widths it overlays the ledger and remains dismissible by keyboard or pointer.

View File

@@ -16,6 +16,8 @@ Status: implemented
- 事件类型与内容构成两个稳定列。角色标签朝内容侧对齐,嵌套子工具略微缩进,内容预览使用 CSS 截断以适应可用宽度。token 用量和耗时留在检查器中。
- 产品正文使用现有无衬线字体栈。轮次 id、token 数、耗时、工具调用、原始载荷和其他机器数据使用现有代码字体栈。
- 现有主题 token 同时负责亮色和暗色渲染。中性边框与表面构成整体结构;区分度较低的角色色帮助扫读而不表达成功或失败语义,业务蓝色则标识选择状态、链接和焦点。
- Session 提供独立的只读历史数据源,其中包含原始日志条目和分页状态;普通会话快照仍然只是 Chat 所需的折叠投影。Trajectory 订阅该数据源仅在挂载期间补齐全部历史并按需派生事件顺序、上下文谱系、schema 索引和请求,避免让所有会话消费者承担这些结构。
- 普通生成调用与压缩调用形成一条按时间排序的请求投影,以 purpose 区分而不是放入不同集合。生效的提示词状态及其变化附着在引入它们的请求上;压缩和提示词变化都不是独立检查实体。完整历史使全局请求编号和累计用量以整个会话为范围,而不是相对于末尾窗口。
- 选择记录或请求后轨迹视图内部会打开检查器其标签页和概览区域随实体类型变化Markdown 消息提供渲染、源码、来源和层级视图;工具提供 JSON 载荷/结果和 schema 视图;请求提供选项、用量、计时和结果跳转。图片以媒体形式渲染,而不是显示为序列化数据。
- 折叠轮次时保留其第一条记录,将后续行替换为紧凑的步骤和工具调用数量;折叠助手时对其工具调用后代应用相同交互。全局控件可以分别折叠或展开这两个层级。
- 此局部检查器与会话级 Chat 详情栏相互独立。在窄屏下,检查器会覆盖记录表,并且仍可通过键盘或指针关闭。

View File

@@ -28,16 +28,21 @@ export type {
EngineStoreHandle, EngineStoreInstance, ObservableSnapshot, SnapshotStore,
} from './contract/store.ts'
export type {
AssistantBlock, AssistantMessageNode, AssistantTiming, CodeSubCall, ComposerPhase, ContextMessageNode,
ConversationNode,
AssistantBlock, AssistantMessageNode, AssistantProvenanceView, AssistantRequestConfig,
AssistantTiming, CodeSubCall, ComposerPhase, ContextMessageNode, ConversationNode,
ConversationSnapshot, QueuedMessage, RunningToolCall,
SteeringMessageNode, TodoItem, ToolResultNode, UnknownSurfaceNode, UserMessageNode,
} from './sessions/conversation.ts'
export type {
AssistantProvenanceView, AssistantRequestConfig, CompactionRequestView,
ConversationContext, ConversationContextOriginKind, ConversationPromptChange,
ConversationPromptSnapshot, ModelRequestView,
} from './sessions/inspection.ts'
ConversationContext, ConversationContextOriginKind,
} from './sessions/conversation-context.ts'
export type {
ConversationPromptSnapshot, RequestInspectionSnapshot, RequestPromptChange, RequestView,
} from './sessions/request-inspection.ts'
export { inspectRequests } from './sessions/request-inspection.ts'
export { projectConversationHistory } from './sessions/fold-adapter.ts'
export type { ConversationHistoryProjection } from './sessions/fold-adapter.ts'
export type { SessionHistory, SessionHistorySnapshot } from './sessions/history.ts'
export { PendingWait } from './sessions/pending.ts'
export type { PendingInteraction, PendingKind, PendingPayloads } from './sessions/pending.ts'
export type { SessionId } from '@deepseek-ai/dsh-client-connection/client'

View File

@@ -0,0 +1,23 @@
import type { ConversationNode } from './conversation.ts'
import type { ConversationPromptSnapshot } from './request-inspection.ts'
/** Operation that started a new append-only model context. */
export type ConversationContextOriginKind = 'compaction' | 'rewind' | 'rewrite'
/** One immutable model-context generation reconstructed from surface replacements. */
export interface ConversationContext {
/** Zero-based generation within the session; stable across later appends. */
id: number
/** Previous generation in this session; absent for the initial context. */
parentId?: number
/** Why this generation exists; absent for the initial context. */
origin?: ConversationContextOriginKind
/** Event seq of the replacement that created this generation. */
originSeq?: number
/** Unix epoch ms of the replacement that created this generation. */
createdAt?: number
/** Latest request header observed in this generation, inherited until a later header replaces it. */
prompt?: ConversationPromptSnapshot
/** Final frozen nodes for historical generations, or current folded nodes for the tail. */
nodes: readonly ConversationNode[]
}

View File

@@ -9,12 +9,26 @@ import type {
RpcError, SessionId, ToolCallView, ToolResultView,
} from '@deepseek-ai/dsh-client-connection/client'
import type { PendingInteraction } from './pending.ts'
import type {
AssistantProvenanceView, AssistantRequestConfig, ConversationContext, SessionInspectionSnapshot,
} from './inspection.ts'
export type { TodoItem }
/** Request configuration recorded for one provider call. */
export interface AssistantRequestConfig {
provider: string
model: string
purpose?: string
thinking?: string
reasoningEffort?: string
temperature?: number
maxTokens?: number
stop?: readonly string[]
}
/** Stable provider/model identity reported for one completed request. */
export interface AssistantProvenanceView {
provider: string
model: string
}
/** Assistant content blocks sorted by what the UI cares about
* (text body / collapsible reasoning / tool-call card head / other fallback). */
export type AssistantBlock =
@@ -224,14 +238,6 @@ export interface ConversationSnapshot {
sessionId: SessionId
/** Surface fold product (finalized conversation nodes in surface order). */
nodes: readonly ConversationNode[]
/** Append-only context generations split at every model-surface replacement. */
contexts?: readonly ConversationContext[]
/** Auxiliary compaction requests, including those without an assistant/message surface node. */
compactionRequests?: SessionInspectionSnapshot['compactionRequests']
/** Ordinary provider requests, including failed attempts that produced no assistant message. */
requestAttempts?: SessionInspectionSnapshot['requestAttempts']
/** System-prompt/tool-catalog changes in request order. */
promptChanges?: SessionInspectionSnapshot['promptChanges']
/** Fold degradation flag (cross-window replace defense): when true, nodes come from the lenient linear scan. */
foldDegraded: boolean
partial: PartialAssistant | null
@@ -243,8 +249,6 @@ export interface ConversationSnapshot {
* unrelated snapshot swaps (memo premise, same regime as `nodes`).
*/
codeDispatches: ReadonlyMap<string, readonly CodeSubCall[]>
/** Model-visible tool schema captured for each recorded call id. */
callSchemas?: SessionInspectionSnapshot['callSchemas']
pending: readonly PendingInteraction[]
/** Read-only inbox mirror (session/queued frames + mux-open baseline; cleared by the leave-running flip). */
queue: readonly QueuedMessage[]

View File

@@ -10,13 +10,45 @@ import type { SessionEvent } from '@deepseek-ai/dsh-session/types'
import {
SurfaceManager, isSurfaceEligibleType, isSurfaceEvent,
} from '@deepseek-ai/dsh-session/surface'
import type { ToolCallView, ToolEventView, ToolResultView } from '@deepseek-ai/dsh-client-connection/client'
import type { AssistantTiming, ConversationNode } from './conversation.ts'
import type {
HistoryEntry, ToolCallView, ToolEventView, ToolResultView,
} from '@deepseek-ai/dsh-client-connection/client'
import type {
AssistantRequestConfig, AssistantTiming, ConversationNode,
} from './conversation.ts'
import { toAssistantBlocks } from './conversation.ts'
import type {
AssistantRequestConfig, ConversationContext, ConversationContextOriginKind,
ConversationPromptSnapshot,
} from './inspection.ts'
ConversationContext, ConversationContextOriginKind,
} from './conversation-context.ts'
import type { ConversationPromptSnapshot } from './request-inspection.ts'
/** Lazy event-order and context-generation projection for history consumers. */
export interface ConversationHistoryProjection {
eventNodes: readonly ConversationNode[]
contexts: readonly ConversationContext[]
}
/**
* Project an immutable raw history window into event-order nodes and context
* generations. Session's chat snapshot never computes this projection.
* @param entries - Contiguous history entries in sequence order.
* @returns Inspection-oriented conversation projections.
*/
export function projectConversationHistory(
entries: readonly HistoryEntry[],
): ConversationHistoryProjection {
const adapter = new FoldAdapter(true)
const events = entries.map(entry => entry.event)
adapter.reset(
events,
events[0]?.seq ?? 0,
entries.map(entry => entry.view),
)
return {
eventNodes: adapter.eventNodes(),
contexts: adapter.contexts(),
}
}
/** In-window tool/call index entry (result-card backfill + runningCalls material). */
export interface CallIndexEntry {
@@ -155,6 +187,7 @@ export class FoldAdapter {
* reference-stability contract (§A.9.4) starts here. */
private rev = 0
private nodesResult: { rev: number; value: { nodes: ConversationNode[]; degraded: boolean } } | null = null
private eventNodesResult: { rev: number; value: readonly ConversationNode[] } | null = null
/** Revision of context structure or its request header; unrelated log-only events do not rebuild contexts. */
private contextRev = 0
private contextsResult: { rev: number; value: readonly ConversationContext[] } | null = null
@@ -162,6 +195,12 @@ export class FoldAdapter {
private activePrompt: ConversationPromptSnapshot | undefined
private promptsByContext = new Map<number, ConversationPromptSnapshot>()
/**
* @param projectContexts - Whether to maintain context-generation indexes
* for a later history projection. The live chat fold leaves this disabled.
*/
constructor(private readonly projectContexts = false) {}
/** In-window tool/call index (Session uses it for runningCalls and result-card backfill). */
get callIndex(): ReadonlyMap<string, CallIndexEntry> {
return this.callIdx
@@ -176,7 +215,7 @@ export class FoldAdapter {
*/
reset(events: readonly SessionEvent[], baseSeq: number, views?: readonly (ToolEventView | undefined)[]): void {
this.rev++
this.contextRev++
if (this.projectContexts) this.contextRev++
this.baseSeq = baseSeq
this.padded = []
for (let i = 0; i < baseSeq; i++) this.padded.push(paddingEvent(i))
@@ -194,7 +233,7 @@ export class FoldAdapter {
/* v8 ignore next -- dense-array guard: i stays within events.length, so the undefined arm needs a sparse array no caller builds. */
if (event !== undefined) {
this.indexCall(event, views?.[i])
this.indexContextPrompt(event)
if (this.projectContexts) this.indexContextPrompt(event)
}
}
}
@@ -207,10 +246,12 @@ export class FoldAdapter {
*/
append(event: SessionEvent, view?: ToolEventView): void {
this.rev++
if (isSurfaceEvent(event) || event.type === 'request/header') this.contextRev++
if (this.projectContexts && (isSurfaceEvent(event) || event.type === 'request/header')) {
this.contextRev++
}
this.padded.push(event)
this.indexCall(event, view)
this.indexContextPrompt(event)
if (this.projectContexts) this.indexContextPrompt(event)
}
/**
@@ -243,11 +284,33 @@ export class FoldAdapter {
return value
}
/**
* Every in-window message-producing event in original sequence order, without surface replacement folding.
* @returns append-only event projection for history inspection.
*/
eventNodes(): readonly ConversationNode[] {
if (this.eventNodesResult !== null && this.eventNodesResult.rev === this.rev) {
return this.eventNodesResult.value
}
const nodes: ConversationNode[] = []
for (let seq = this.baseSeq; seq < this.padded.length; seq++) {
const event = this.padded[seq]
if (event === undefined || !isSurfaceEligibleType(event.type)) continue
const node = this.materialize(seq)
if (node !== undefined) nodes.push(node)
}
this.eventNodesResult = { rev: this.rev, value: nodes }
return nodes
}
/**
* Append-only context generations reconstructed from canonical surface replacements.
* @returns Frozen historical contexts followed by the current context.
*/
contexts(): readonly ConversationContext[] {
if (!this.projectContexts) {
throw new Error('FoldAdapter context projection was not enabled')
}
if (this.contextsResult !== null && this.contextsResult.rev === this.contextRev) {
return this.contextsResult.value
}

View File

@@ -0,0 +1,33 @@
import type {
HistoryEntry, RpcError, SessionId,
} from '@deepseek-ai/dsh-client-connection/client'
import type { ObservableSnapshot } from '../contract/store.ts'
import type { OpenState } from './conversation.ts'
/**
* Immutable read window over one session's durable event log.
*
* The conversation snapshot is a chat projection. Consumers that need event
* order or request lifecycle data read this source instead of widening that
* projection with inspection-only fields.
*/
export interface SessionHistorySnapshot {
sessionId: SessionId
/** Contiguous raw log entries in ascending sequence order. */
entries: readonly HistoryEntry[]
/** Sequence of the first entry, or zero while the window is empty. */
baseSeq: number
openState: OpenState
openError: RpcError | null
hasMore: boolean
loadingOlder: boolean
}
/** Read-only observable history plus explicit full-ledger paging. */
export interface SessionHistory extends ObservableSnapshot<SessionHistorySnapshot> {
/**
* Load every earlier page currently available.
* @returns When paging is exhausted or cannot advance.
*/
loadAll(): Promise<void>
}

View File

@@ -1,493 +0,0 @@
// Session inspection read models. These projections preserve durable request
// and prompt semantics for diagnostic UIs without making the conversation fold
// or the core SessionSurface own inspection-only history.
import type { ContentBlock, ToolSchema } from '@deepseek-ai/dsh-llm/types'
import type { SessionEvent } from '@deepseek-ai/dsh-session/types'
import type { ConversationNode } from './conversation.ts'
/** Request configuration recorded in the effective header for one assistant response. */
export interface AssistantRequestConfig {
provider: string
model: string
purpose?: string
thinking?: string
reasoningEffort?: string
temperature?: number
maxTokens?: number
stop?: readonly string[]
}
/** Stable provider/model identity attached to one assistant response. */
export interface AssistantProvenanceView {
provider: string
model: string
}
/** Operation that started a new append-only model context. */
export type ConversationContextOriginKind = 'compaction' | 'rewind' | 'rewrite'
/** Latest complete model request header in force within one context generation. */
export interface ConversationPromptSnapshot {
/** Provider/model and sampling configuration from the latest effective request header. */
config?: AssistantRequestConfig
/** Rendered system prompt text; empty when the request had no system prompt. */
system: string
/** Complete tool catalog sent with the request, including tools that were never called. */
tools: readonly ToolSchema[]
}
/** One system-prompt/tool-catalog state that became effective in the request timeline. */
export interface ConversationPromptChange {
/** Sequence of the request/header event that introduced this state. */
seq: number
/** Unix epoch ms from the request/header event. */
time: number
/** How the model-visible system configuration differs from the prior recorded state. */
kind: 'initial' | 'system' | 'tools' | 'system-and-tools'
/** Complete state effective from this event onward. */
prompt: ConversationPromptSnapshot
/** State immediately before this change; absent for the initial header. */
previous?: ConversationPromptSnapshot
}
/** One immutable model-context generation reconstructed from surface replacements. */
export interface ConversationContext {
/** Zero-based generation within the session; stable across later appends. */
id: number
/** Previous generation in this session; absent for the initial context. */
parentId?: number
/** Why this generation exists; absent for the initial context. */
origin?: ConversationContextOriginKind
/** Event seq of the replacement that created this generation. */
originSeq?: number
/** Unix epoch ms of the replacement that created this generation. */
createdAt?: number
/** Latest request header observed in this generation, inherited until a later header replaces it. */
prompt?: ConversationPromptSnapshot
/** Final frozen nodes for historical generations, or current folded nodes for the tail. */
nodes: readonly ConversationNode[]
}
/** One auxiliary compaction model request reconstructed from its durable lifecycle events. */
export interface CompactionRequestView {
startSeq: number
turn: number
startedAt: number
completedAt: number | null
status: 'running' | 'complete' | 'error'
error?: string
summarySeq?: number
replacementSeq?: number
summary?: readonly ContentBlock[]
rawOutput?: readonly ContentBlock[]
provenance?: AssistantProvenanceView
requestConfig?: AssistantRequestConfig
usage?: unknown
}
/** One ordinary provider-request attempt reconstructed from a durable step boundary. */
export interface ModelRequestView {
/** Sequence of the step/start event that opened this attempt. */
startSeq: number
turn: number
step: number
/** Unix epoch ms from step/start. */
startedAt: number
/** Assistant completion time, or the failed step/end time when no response completed. */
completedAt: number | null
status: 'running' | 'complete' | 'error'
error?: string
/** Assistant/message sequence when this attempt completed successfully. */
resultSeq?: number
provenance?: AssistantProvenanceView
requestConfig?: AssistantRequestConfig
usage?: unknown
/** Retry ordinal scheduled after this failed attempt. */
retry?: number
maxRetries?: number
retryDelayMs?: number
}
/** Stable inspection substructures attached to a conversation snapshot. */
export interface SessionInspectionSnapshot {
compactionRequests: readonly CompactionRequestView[]
requestAttempts: readonly ModelRequestView[]
promptChanges: readonly ConversationPromptChange[]
callSchemas: ReadonlyMap<string, ToolSchema>
}
interface RetryEvent {
type: 'llm/retry'
seq: number
time: number
data: {
turn: number
step: number
retry: number
maxRetries: number
delayMs: number
failure: { message: string }
}
}
interface CompactionStartEvent {
type: 'compact/start'
seq: number
time: number
data: { turn: number }
}
interface CompactionSummaryEvent {
type: 'compact/summary'
seq: number
time: number
data: {
summary: readonly ContentBlock[]
rawOutput?: readonly ContentBlock[]
provider: string
model: string
maxTokens?: number
usage?: unknown
}
}
interface CompactionEndEvent {
type: 'compact/end'
seq: number
time: number
data: { turn: number; error?: string }
}
/**
* Own incremental invalidation and call-time schema capture for inspection
* read models. Conversation state delegates events here but owns no request
* reconstruction details.
*/
export class SessionInspection {
private activeToolSchemas = new Map<string, ToolSchema>()
private callSchemas = new Map<string, ToolSchema>()
private callSchemasRev = 0
private callSchemasCache: { rev: number; value: ReadonlyMap<string, ToolSchema> } | null = null
private modelRequestsRev = 0
private modelRequestsCache: {
rev: number
value: readonly ModelRequestView[]
} | null = null
private compactionRequestsRev = 0
private compactionRequestsCache: {
rev: number
value: readonly CompactionRequestView[]
} | null = null
private promptChangesRev = 0
private promptChangesCache: {
rev: number
value: readonly ConversationPromptChange[]
} | null = null
/**
* Invalidate projections before replaying a rebuilt history window.
* @returns Nothing.
*/
reset(): void {
this.activeToolSchemas = new Map()
this.callSchemas = new Map()
this.callSchemasRev++
this.modelRequestsRev++
this.compactionRequestsRev++
this.promptChangesRev++
}
/**
* Apply inspection-specific incremental state for one durable event.
* @param event - Event entering the current history window.
* @returns Nothing.
*/
applyEvent(event: SessionEvent): void {
if (affectsModelRequests(event)) this.modelRequestsRev++
if (affectsCompactionRequests(event)) this.compactionRequestsRev++
if (event.type === 'request/header') {
this.promptChangesRev++
this.activeToolSchemas = new Map(
(event.data.header.tools ?? []).map(schema => [schema.name, schema]),
)
return
}
if (event.type === 'tool/call') {
this.captureCallSchema(String(event.data.callId), event.data.name)
}
}
/**
* Preserve the schema active when one native or nested call starts.
* @param callId - Durable or synthetic call identifier.
* @param name - Tool name used to resolve the active catalog entry.
* @returns Nothing.
*/
captureCallSchema(callId: string, name: string): void {
if (this.callSchemas.has(callId)) return
const schema = this.activeToolSchemas.get(name)
if (schema === undefined) return
this.callSchemas.set(callId, schema)
this.callSchemasRev++
}
/**
* Materialize reference-stable inspection projections for the current log.
* @param events - Current contiguous client history window.
* @returns Inspection projections with stable unchanged substructure references.
*/
snapshot(events: readonly SessionEvent[]): SessionInspectionSnapshot {
if (this.callSchemasCache === null || this.callSchemasCache.rev !== this.callSchemasRev) {
this.callSchemasCache = { rev: this.callSchemasRev, value: new Map(this.callSchemas) }
}
if (
this.modelRequestsCache === null
|| this.modelRequestsCache.rev !== this.modelRequestsRev
) {
this.modelRequestsCache = {
rev: this.modelRequestsRev,
value: deriveModelRequests(events),
}
}
if (
this.compactionRequestsCache === null
|| this.compactionRequestsCache.rev !== this.compactionRequestsRev
) {
this.compactionRequestsCache = {
rev: this.compactionRequestsRev,
value: deriveCompactionRequests(events),
}
}
if (
this.promptChangesCache === null
|| this.promptChangesCache.rev !== this.promptChangesRev
) {
this.promptChangesCache = {
rev: this.promptChangesRev,
value: derivePromptChanges(events),
}
}
return {
callSchemas: this.callSchemasCache.value,
requestAttempts: this.modelRequestsCache.value,
compactionRequests: this.compactionRequestsCache.value,
promptChanges: this.promptChangesCache.value,
}
}
}
function modelRequestKey(turn: number, step: number): string {
return `${turn}\u0000${step}`
}
function affectsModelRequests(event: SessionEvent): boolean {
switch (event.type) {
case 'request/header':
case 'step/start':
case 'assistant/message':
case 'step/end':
return true
case 'turn/end':
return event.data.reason.kind === 'error'
default:
return (event.type as string) === 'llm/retry'
}
}
function affectsCompactionRequests(event: SessionEvent): boolean {
const type = event.type as string
return type === 'compact/start'
|| type === 'compact/summary'
|| type === 'compact/end'
|| (event.type === 'user/message' && isCompactionSource(event.data.source))
}
/** Project every durable step into one provider request, retaining failed retry attempts. */
function deriveModelRequests(events: readonly SessionEvent[]): readonly ModelRequestView[] {
const requests: ModelRequestView[] = []
const byStep = new Map<string, number>()
let activeStep: string | undefined
let activeConfig: ConversationPromptSnapshot['config']
const update = (key: string, change: Partial<ModelRequestView>): void => {
const index = byStep.get(key)
if (index === undefined) return
const request = requests[index]
if (request !== undefined) requests[index] = { ...request, ...change }
}
for (const sourceEvent of events) {
if (sourceEvent.type === 'request/header') {
activeConfig = sourceEvent.data.header.config
if (activeStep !== undefined) update(activeStep, { requestConfig: activeConfig })
continue
}
if (sourceEvent.type === 'step/start') {
const { turn, step } = sourceEvent.data
const key = modelRequestKey(turn, step)
byStep.set(key, requests.length)
requests.push({
startSeq: sourceEvent.seq,
turn,
step,
startedAt: sourceEvent.time,
completedAt: null,
status: 'running',
...(activeConfig === undefined ? {} : { requestConfig: activeConfig }),
})
activeStep = key
continue
}
if (sourceEvent.type === 'assistant/message') {
const key = modelRequestKey(sourceEvent.data.turn, sourceEvent.data.step)
update(key, {
completedAt: sourceEvent.time,
status: 'complete',
resultSeq: sourceEvent.seq,
provenance: {
provider: sourceEvent.data.provenance.provider,
model: sourceEvent.data.provenance.model,
},
...(sourceEvent.data.usage === undefined ? {} : { usage: sourceEvent.data.usage }),
})
continue
}
if (sourceEvent.type === 'step/end') {
const key = modelRequestKey(sourceEvent.data.turn, sourceEvent.data.step)
const index = byStep.get(key)
const request = index === undefined ? undefined : requests[index]
if (index !== undefined && request !== undefined && request.status === 'running') {
requests[index] = {
...request,
completedAt: sourceEvent.time,
status: 'error',
}
}
if (activeStep === key) activeStep = undefined
continue
}
if ((sourceEvent.type as string) === 'llm/retry') {
const event = sourceEvent as unknown as RetryEvent
update(modelRequestKey(event.data.turn, event.data.step), {
status: 'error',
error: event.data.failure.message,
retry: event.data.retry,
maxRetries: event.data.maxRetries,
retryDelayMs: event.data.delayMs,
})
continue
}
if (sourceEvent.type !== 'turn/end' || sourceEvent.data.reason.kind !== 'error') continue
const reason = sourceEvent.data.reason
update(modelRequestKey(sourceEvent.data.turn, reason.step), {
status: 'error',
error: 'failure' in reason ? reason.failure.message : reason.message,
})
}
return requests
}
/** Project log-only compaction request brackets without coupling the client runtime to one backend package. */
function deriveCompactionRequests(events: readonly SessionEvent[]): readonly CompactionRequestView[] {
const requests: CompactionRequestView[] = []
let active: CompactionRequestView | undefined
for (const sourceEvent of events) {
const type = sourceEvent.type as string
if (type === 'compact/start') {
const event = sourceEvent as unknown as CompactionStartEvent
active = {
startSeq: event.seq,
turn: event.data.turn,
startedAt: event.time,
completedAt: null,
status: 'running',
}
continue
}
if (type === 'compact/summary' && active !== undefined) {
const event = sourceEvent as unknown as CompactionSummaryEvent
active = {
...active,
summarySeq: event.seq,
summary: event.data.summary,
...(event.data.rawOutput === undefined ? {} : { rawOutput: event.data.rawOutput }),
provenance: {
provider: event.data.provider,
model: event.data.model,
},
requestConfig: {
provider: event.data.provider,
model: event.data.model,
purpose: 'compaction',
...(event.data.maxTokens === undefined ? {} : { maxTokens: event.data.maxTokens }),
},
...(event.data.usage === undefined ? {} : { usage: event.data.usage }),
}
continue
}
if (
sourceEvent.type === 'user/message'
&& active?.summarySeq !== undefined
&& isCompactionSource(sourceEvent.data.source)
) {
active = { ...active, replacementSeq: sourceEvent.seq }
continue
}
if (type !== 'compact/end' || active === undefined) continue
const event = sourceEvent as unknown as CompactionEndEvent
active = {
...active,
completedAt: event.time,
status: event.data.error === undefined ? 'complete' : 'error',
...(event.data.error === undefined ? {} : { error: event.data.error }),
}
requests.push(active)
active = undefined
}
if (active !== undefined) requests.push(active)
return requests
}
/** Project request headers into model-visible system/tool changes only. */
function derivePromptChanges(events: readonly SessionEvent[]): readonly ConversationPromptChange[] {
const changes: ConversationPromptChange[] = []
let previous: ConversationPromptSnapshot | undefined
for (const event of events) {
if (event.type !== 'request/header') continue
const prompt: ConversationPromptSnapshot = {
config: event.data.header.config,
system: event.data.header.system ?? '',
tools: event.data.header.tools ?? [],
}
const systemChanged = previous !== undefined && previous.system !== prompt.system
const toolsChanged = previous !== undefined
&& JSON.stringify(previous.tools) !== JSON.stringify(prompt.tools)
if (previous === undefined || systemChanged || toolsChanged) {
changes.push({
seq: event.seq,
time: event.time,
kind: previous === undefined
? 'initial'
: systemChanged && toolsChanged
? 'system-and-tools'
: systemChanged
? 'system'
: 'tools',
prompt,
...(previous === undefined ? {} : { previous }),
})
}
previous = prompt
}
return changes
}
function isCompactionSource(source: unknown): boolean {
return typeof source === 'object'
&& source !== null
&& 'kind' in source
&& source.kind === 'plugin'
&& 'plugin' in source
&& source.plugin === 'compact'
}

View File

@@ -0,0 +1,352 @@
// Request-centric inspection read model. Ordinary generation and compaction
// calls share one chronological projection; presentation-specific grouping
// remains in the trajectory consumer.
import type { ContentBlock, ToolSchema } from '@deepseek-ai/dsh-llm/types'
import type { HistoryEntry } from '@deepseek-ai/dsh-client-connection/client'
import type { SessionEvent } from '@deepseek-ai/dsh-session/types'
import type {
AssistantProvenanceView, AssistantRequestConfig,
} from './conversation.ts'
export type {
AssistantProvenanceView, AssistantRequestConfig,
} from './conversation.ts'
/** Complete model-visible request header in force for an ordinary generation. */
export interface ConversationPromptSnapshot {
/** Provider/model and sampling configuration from the effective request header. */
config: AssistantRequestConfig
/** Rendered system prompt text; empty when the request had no system prompt. */
system: string
/** Complete tool catalog sent with the request, including tools that were never called. */
tools: readonly ToolSchema[]
}
/** System/tool change introduced while preparing one ordinary request. */
export interface RequestPromptChange {
/** Sequence of the request/header event that introduced this state. */
seq: number
/** Unix epoch ms from the request/header event. */
time: number
/** How the model-visible prompt differs from the previous recorded state. */
kind: 'initial' | 'system' | 'tools' | 'system-and-tools'
/** State immediately before this change; absent for the initial header. */
previous?: ConversationPromptSnapshot
}
/** One provider request reconstructed from durable request lifecycle events. */
export interface RequestView {
/** Request category; compaction is a purpose, not a separate projection. */
purpose: 'assistant' | 'compaction'
/** Sequence that opened the operation represented by this request. */
startSeq: number
turn: number
/** Agent-loop step, or zero for a direct compaction request. */
step: number
startedAt: number
completedAt: number | null
status: 'running' | 'complete' | 'error'
error?: string
/** Effective ordinary request input, inherited until a later header changes it. */
prompt?: ConversationPromptSnapshot
/** Prompt change logged while preparing this request. */
promptChange?: RequestPromptChange
provenance?: AssistantProvenanceView
requestConfig?: AssistantRequestConfig
usage?: unknown
/** Assistant message or compaction summary sequence produced by this request. */
resultSeq?: number
/** Compaction replacement message sequence, when one was committed. */
replacementSeq?: number
/** Safe compaction summary projection. */
summary?: readonly ContentBlock[]
/** Complete compaction provider output before the safe projection. */
rawOutput?: readonly ContentBlock[]
/** Retry ordinal scheduled after a failed ordinary request. */
retry?: number
maxRetries?: number
retryDelayMs?: number
}
/** Immutable request-centric projection derived from one history window. */
export interface RequestInspectionSnapshot {
requests: readonly RequestView[]
callSchemas: ReadonlyMap<string, ToolSchema>
}
/**
* Derive the request-centric read model from one immutable history window.
* Compaction participates as a request purpose rather than a parallel
* top-level collection.
* @param entries - Contiguous raw session history.
* @returns Requests and call-time schemas derived from that history.
*/
export function inspectRequests(
entries: readonly HistoryEntry[],
): RequestInspectionSnapshot {
const events = entries.map(entry => entry.event)
return {
requests: deriveRequests(events),
callSchemas: deriveCallSchemas(events),
}
}
interface RetryEvent {
type: 'llm/retry'
seq: number
time: number
data: {
turn: number
step: number
retry: number
maxRetries: number
delayMs: number
failure: { message: string }
}
}
interface CompactionStartEvent {
type: 'compact/start'
seq: number
time: number
data: { turn: number }
}
interface CompactionSummaryEvent {
type: 'compact/summary'
seq: number
time: number
data: {
summary: readonly ContentBlock[]
rawOutput?: readonly ContentBlock[]
provider: string
model: string
maxTokens?: number
usage?: unknown
}
}
interface CompactionEndEvent {
type: 'compact/end'
seq: number
time: number
data: { turn: number; error?: string }
}
function requestKey(turn: number, step: number): string {
return `${turn}\u0000${step}`
}
function deriveCallSchemas(
events: readonly SessionEvent[],
): ReadonlyMap<string, ToolSchema> {
let active = new Map<string, ToolSchema>()
const calls = new Map<string, ToolSchema>()
const capture = (callId: string, name: string): void => {
if (calls.has(callId)) return
const schema = active.get(name)
if (schema !== undefined) calls.set(callId, schema)
}
for (const event of events) {
if (event.type === 'request/header') {
active = new Map(
(event.data.header.tools ?? []).map(schema => [schema.name, schema]),
)
continue
}
if (event.type === 'tool/call') {
capture(String(event.data.callId), event.data.name)
continue
}
const type = event.type as string
if (type === 'tool/code-dispatch-start' || type === 'tool/code-dispatch') {
const data = event.data as unknown as { subCallId: string; name: string }
capture(data.subCallId, data.name)
}
}
return calls
}
function promptChange(
previous: ConversationPromptSnapshot | undefined,
prompt: ConversationPromptSnapshot,
event: SessionEvent<'request/header'>,
): RequestPromptChange | undefined {
const systemChanged = previous !== undefined && previous.system !== prompt.system
const toolsChanged = previous !== undefined
&& JSON.stringify(previous.tools) !== JSON.stringify(prompt.tools)
if (previous !== undefined && !systemChanged && !toolsChanged) return
return {
seq: event.seq,
time: event.time,
kind: previous === undefined
? 'initial'
: systemChanged && toolsChanged
? 'system-and-tools'
: systemChanged
? 'system'
: 'tools',
...(previous === undefined ? {} : { previous }),
}
}
/** Project ordinary and compaction provider calls into one chronological request stream. */
function deriveRequests(events: readonly SessionEvent[]): readonly RequestView[] {
const requests: RequestView[] = []
const ordinaryByStep = new Map<string, number>()
let activeStep: string | undefined
let activePrompt: ConversationPromptSnapshot | undefined
let activeCompaction: number | undefined
const update = (index: number | undefined, change: Partial<RequestView>): void => {
if (index === undefined) return
const request = requests[index]
if (request !== undefined) requests[index] = { ...request, ...change }
}
for (const sourceEvent of events) {
if (sourceEvent.type === 'step/start') {
const { turn, step } = sourceEvent.data
const key = requestKey(turn, step)
ordinaryByStep.set(key, requests.length)
requests.push({
purpose: 'assistant',
startSeq: sourceEvent.seq,
turn,
step,
startedAt: sourceEvent.time,
completedAt: null,
status: 'running',
...(activePrompt === undefined
? {}
: { prompt: activePrompt, requestConfig: activePrompt.config }),
})
activeStep = key
continue
}
if (sourceEvent.type === 'request/header') {
const prompt: ConversationPromptSnapshot = {
config: sourceEvent.data.header.config,
system: sourceEvent.data.header.system ?? '',
tools: sourceEvent.data.header.tools ?? [],
}
const change = promptChange(activePrompt, prompt, sourceEvent)
activePrompt = prompt
update(activeStep === undefined ? undefined : ordinaryByStep.get(activeStep), {
prompt,
requestConfig: prompt.config,
...(change === undefined ? {} : { promptChange: change }),
})
continue
}
if (sourceEvent.type === 'assistant/message') {
update(ordinaryByStep.get(requestKey(sourceEvent.data.turn, sourceEvent.data.step)), {
completedAt: sourceEvent.time,
status: 'complete',
resultSeq: sourceEvent.seq,
provenance: {
provider: sourceEvent.data.provenance.provider,
model: sourceEvent.data.provenance.model,
},
...(sourceEvent.data.usage === undefined ? {} : { usage: sourceEvent.data.usage }),
})
continue
}
if (sourceEvent.type === 'step/end') {
const key = requestKey(sourceEvent.data.turn, sourceEvent.data.step)
const index = ordinaryByStep.get(key)
const request = index === undefined ? undefined : requests[index]
if (request?.status === 'running') {
update(index, {
completedAt: sourceEvent.time,
status: 'error',
})
}
if (activeStep === key) activeStep = undefined
continue
}
if ((sourceEvent.type as string) === 'llm/retry') {
const event = sourceEvent as unknown as RetryEvent
update(ordinaryByStep.get(requestKey(event.data.turn, event.data.step)), {
status: 'error',
error: event.data.failure.message,
retry: event.data.retry,
maxRetries: event.data.maxRetries,
retryDelayMs: event.data.delayMs,
})
continue
}
if (sourceEvent.type === 'turn/end' && sourceEvent.data.reason.kind === 'error') {
const reason = sourceEvent.data.reason
update(ordinaryByStep.get(requestKey(sourceEvent.data.turn, reason.step)), {
status: 'error',
error: 'failure' in reason ? reason.failure.message : reason.message,
})
continue
}
const type = sourceEvent.type as string
if (type === 'compact/start') {
const event = sourceEvent as unknown as CompactionStartEvent
activeCompaction = requests.length
requests.push({
purpose: 'compaction',
startSeq: event.seq,
turn: event.data.turn,
step: 0,
startedAt: event.time,
completedAt: null,
status: 'running',
})
continue
}
if (type === 'compact/summary' && activeCompaction !== undefined) {
const event = sourceEvent as unknown as CompactionSummaryEvent
update(activeCompaction, {
resultSeq: event.seq,
summary: event.data.summary,
...(event.data.rawOutput === undefined ? {} : { rawOutput: event.data.rawOutput }),
provenance: {
provider: event.data.provider,
model: event.data.model,
},
requestConfig: {
provider: event.data.provider,
model: event.data.model,
purpose: 'compaction',
...(event.data.maxTokens === undefined ? {} : { maxTokens: event.data.maxTokens }),
},
...(event.data.usage === undefined ? {} : { usage: event.data.usage }),
})
continue
}
if (
sourceEvent.type === 'user/message'
&& activeCompaction !== undefined
&& isCompactionSource(sourceEvent.data.source)
) {
update(activeCompaction, { replacementSeq: sourceEvent.seq })
continue
}
if (type !== 'compact/end' || activeCompaction === undefined) continue
const event = sourceEvent as unknown as CompactionEndEvent
update(activeCompaction, {
completedAt: event.time,
status: event.data.error === undefined ? 'complete' : 'error',
...(event.data.error === undefined ? {} : { error: event.data.error }),
})
activeCompaction = undefined
}
return requests.sort((left, right) => left.startSeq - right.startSeq)
}
function isCompactionSource(source: unknown): boolean {
return typeof source === 'object'
&& source !== null
&& 'kind' in source
&& source.kind === 'plugin'
&& 'plugin' in source
&& source.plugin === 'compact'
}

View File

@@ -15,7 +15,7 @@ import type {
CodeSubCall, ComposerPhase, ConversationNode, ConversationSnapshot,
OpenState, PromptError, QueuedMessage, RunningToolCall,
} from './conversation.ts'
import { SessionInspection } from './inspection.ts'
import type { SessionHistory, SessionHistorySnapshot } from './history.ts'
import type { PendingInteraction } from './pending.ts'
import { PendingWait } from './pending.ts'
import { FoldAdapter } from './fold-adapter.ts'
@@ -59,8 +59,9 @@ function queuePreviewOf(content: readonly ContentBlock[]): string {
}
/**
* Owns a session's event window, derived conversation state, and observable
* snapshot. React bindings remain outside this data layer.
* Owns a session's event window and exposes two observable read surfaces:
* the folded chat conversation and the raw history window. React bindings
* remain outside this data layer.
*/
export class Session implements ObservableSnapshot<ConversationSnapshot> {
// ---- Window and derived state (all private; the snapshot is the only read surface) ----
@@ -108,8 +109,9 @@ export class Session implements ObservableSnapshot<ConversationSnapshot> {
private codeDispatches = new Map<string, readonly CodeSubCall[]>()
private dispatchesRev = 0
private dispatchesCache: { rev: number; value: ReadonlyMap<string, readonly CodeSubCall[]> } | null = null
/** Diagnostic projections isolated from the ordinary conversation state machine. */
private readonly inspection = new SessionInspection()
/** Raw history revision; published entries are copied so later live appends never mutate a prior snapshot. */
private historyRev = 0
private historyEntriesCache: { rev: number; value: readonly HistoryEntry[] } | null = null
private running = false
/**
* Sticky send marker, private input of the composerPhase derivation: set
@@ -130,9 +132,13 @@ export class Session implements ObservableSnapshot<ConversationSnapshot> {
private subscribedLastSeq: number | null = null
private snapshotCache: ConversationSnapshot
private historySnapshotCache: SessionHistorySnapshot | undefined
private readonly notifier = new Notifier(() => {
this.snapshotCache = this.buildSnapshot()
this.historySnapshotCache = this.buildHistorySnapshot()
})
/** Raw log read surface; trajectory-like consumers project their own model from it. */
readonly history: SessionHistory
/**
* Agent-scoped cordis context, bound once by SessionsService when it
* mints the scope (the client mirror of the host Agent's loopCtx). The
@@ -153,6 +159,19 @@ export class Session implements ObservableSnapshot<ConversationSnapshot> {
private readonly options: SessionOptions = {},
) {
this.snapshotCache = this.buildSnapshot()
this.historySnapshotCache = this.buildHistorySnapshot()
this.history = {
getSnapshot: () => {
this.notifier.ensureFresh()
/* v8 ignore next -- constructor initializes the cache before history is published. */
if (this.historySnapshotCache === undefined) {
throw new Error(`session ${this.sessionId} history cache is uninitialized`)
}
return this.historySnapshotCache
},
subscribe: listener => this.notifier.subscribe(listener),
loadAll: () => this.loadAllHistory(),
}
}
/**
@@ -268,6 +287,7 @@ export class Session implements ObservableSnapshot<ConversationSnapshot> {
}
this.events = [...older.map(e => e.event), ...this.events]
this.views = [...older.map(e => e.view), ...this.views]
this.historyRev++
/* v8 ignore next -- the ?? arm needs older[0] undefined, but the empty-page branch above already returned. */
this.baseSeq = older[0]?.event.seq ?? this.baseSeq
this.hasMore = result.value.hasMore
@@ -281,6 +301,20 @@ export class Session implements ObservableSnapshot<ConversationSnapshot> {
}
}
/**
* Exhaust history paging for inspection surfaces that require a complete
* session ledger. Stops after a failed or non-advancing page so a transient
* backend failure cannot become an automatic retry loop.
* @returns When the available history has been exhausted or paging stops making progress.
*/
private async loadAllHistory(): Promise<void> {
while (this.openState === 'open' && this.hasMore && !this.loadingOlder) {
const previousBaseSeq = this.baseSeq
await this.loadOlder()
if (this.baseSeq === previousBaseSeq) return
}
}
/** Reconnect rebuild (manager calls this on onConnected for instances that were opened):
* reset the window and rerun open; pending waits for the baseline replay. Invalidates any
* in-flight open first — its history request rode the dead connection and must not settle
@@ -298,6 +332,7 @@ export class Session implements ObservableSnapshot<ConversationSnapshot> {
this.openError = null
this.events = []
this.views = []
this.historyRev++
this.baseSeq = 0
// Superseded, not settled: the baseline replay re-sends still-pending requested frames verbatim
// (same rpcId), re-minting fresh waits; a stale reference's respond() still reaches the host.
@@ -512,6 +547,7 @@ export class Session implements ObservableSnapshot<ConversationSnapshot> {
private installWindow(entries: HistoryEntry[], hasMore: boolean, todos: readonly TodoItem[] | undefined): void {
this.events = entries.map(e => e.event)
this.views = entries.map(e => e.view)
this.historyRev++
this.baseSeq = this.events[0]?.seq ?? 0
this.hasMore = hasMore
// Session-level projection from the tail page (full-log latest todo/write,
@@ -536,6 +572,7 @@ export class Session implements ObservableSnapshot<ConversationSnapshot> {
if (tailSeq !== null && event.seq <= tailSeq) return // replay overlap, drop
this.events.push(event)
this.views.push(view)
this.historyRev++
this.foldAdapter.append(event, view)
this.applyEventSideEffects(event, view)
}
@@ -605,7 +642,6 @@ export class Session implements ObservableSnapshot<ConversationSnapshot> {
/** Per-event side effects (right column of the §A.9 dispatch table):
* chunk accumulation / partial clear on finalize / openCalls add-remove. */
private applyEventSideEffects(event: SessionEvent, view?: ToolEventView): void {
this.inspection.applyEvent(event)
// 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
@@ -626,7 +662,6 @@ export class Session implements ObservableSnapshot<ConversationSnapshot> {
argsRaw: JSON.stringify(data.arguments),
turn: 0, step: 0, time: event.time, callView: null,
}
this.inspection.captureCallSchema(data.subCallId, data.name)
const siblings = this.codeDispatches.get(data.parentCallId) ?? []
this.codeDispatches.set(data.parentCallId, [...siblings, running])
this.dispatchesRev++
@@ -646,7 +681,6 @@ export class Session implements ObservableSnapshot<ConversationSnapshot> {
content: ContentBlock[]
}
const siblings = this.codeDispatches.get(data.parentCallId) ?? []
this.inspection.captureCallSchema(data.subCallId, data.name)
const at = siblings.findIndex(sub => sub.callId === data.subCallId)
const started = at === -1 ? undefined : siblings[at]
const settled: CodeSubCall = {
@@ -755,7 +789,6 @@ export class Session implements ObservableSnapshot<ConversationSnapshot> {
this.frozenRev++
this.codeDispatches = new Map()
this.dispatchesRev++
this.inspection.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. */
@@ -770,7 +803,6 @@ export class Session implements ObservableSnapshot<ConversationSnapshot> {
private buildSnapshot(): ConversationSnapshot {
const { nodes: folded, degraded } = this.foldAdapter.nodes()
const contexts = this.foldAdapter.contexts()
// Frozen interrupted nodes ride fractional seqs: a stable merge keeps them in flow order.
// The merged array is cached on (folded reference, frozenRev) so an unchanged flow keeps its
// reference across snapshot swaps (§A.9.4).
@@ -795,21 +827,15 @@ export class Session implements ObservableSnapshot<ConversationSnapshot> {
if (this.queueCache === null || this.queueCache.rev !== this.queueRev) {
this.queueCache = { rev: this.queueRev, value: this.queued.map(entry => entry.row) }
}
const inspection = this.inspection.snapshot(this.events)
const partial = this.partial?.toPartial() ?? null
return {
sessionId: this.sessionId,
nodes,
contexts,
compactionRequests: inspection.compactionRequests,
requestAttempts: inspection.requestAttempts,
promptChanges: inspection.promptChanges,
foldDegraded: degraded,
partial,
runningCalls: this.callsCache.value,
pending: this.pendingCache.value,
codeDispatches: this.dispatchesCache.value,
callSchemas: inspection.callSchemas,
queue: this.queueCache.value,
running: this.running,
composerPhase: derivePhase(
@@ -827,6 +853,40 @@ export class Session implements ObservableSnapshot<ConversationSnapshot> {
todos: this.todos,
}
}
/** Build the raw history read surface without leaking the mutable window arrays. */
private buildHistorySnapshot(): SessionHistorySnapshot {
if (this.historyEntriesCache === null || this.historyEntriesCache.rev !== this.historyRev) {
this.historyEntriesCache = {
rev: this.historyRev,
value: this.events.map((event, index) => {
const view = this.views[index]
return view === undefined ? { event } : { event, view }
}),
}
}
const previous = this.historySnapshotCache
if (
previous !== undefined
&& previous.entries === this.historyEntriesCache.value
&& previous.baseSeq === this.baseSeq
&& previous.openState === this.openState
&& previous.openError === this.openError
&& previous.hasMore === this.hasMore
&& previous.loadingOlder === this.loadingOlder
) {
return previous
}
return {
sessionId: this.sessionId,
entries: this.historyEntriesCache.value,
baseSeq: this.baseSeq,
openState: this.openState,
openError: this.openError,
hasMore: this.hasMore,
loadingOlder: this.loadingOlder,
}
}
}
/**

View File

@@ -6,7 +6,9 @@
import { describe, expect, it, vi } from 'vitest'
import type { SessionEvent } from '@deepseek-ai/dsh-session/types'
import { FoldAdapter } from '../src/client/sessions/fold-adapter.ts'
import {
FoldAdapter, projectConversationHistory,
} from '../src/client/sessions/fold-adapter.ts'
import { ev, plainTurn } from './event-script.ts'
const at = (seq: number, e: Record<string, unknown>): SessionEvent =>
@@ -35,8 +37,7 @@ describe('FoldAdapter', () => {
})
it('projects frozen surface generations without widening the core live surface', () => {
const adapter = new FoldAdapter()
adapter.reset([
const events = [
ev.user(0, 'a'),
ev.user(1, 'b'),
at(2, {
@@ -61,9 +62,9 @@ describe('FoldAdapter', () => {
provenance: { provider: 'fake', model: 'fake' },
},
}),
], 0)
]
expect(adapter.contexts().map(context => ({
expect(projectConversationHistory(events.map(event => ({ event }))).contexts.map(context => ({
id: context.id,
parentId: context.parentId,
originSeq: context.originSeq,

View File

@@ -0,0 +1,108 @@
import { describe, expect, it } from 'vitest'
import type { HistoryEntry } from '@deepseek-ai/dsh-client-connection/client'
import type { SessionEvent } from '@deepseek-ai/dsh-session/types'
import { inspectRequests } from '../src/client/sessions/request-inspection.ts'
const at = (seq: number, type: string, data: unknown): SessionEvent =>
({ seq, time: 1_700_000_000_000 + seq, type, data }) as SessionEvent
const entriesOf = (events: readonly SessionEvent[]): HistoryEntry[] =>
events.map(event => ({ event }))
describe('inspectRequests', () => {
it('projects ordinary and compaction calls into one chronological request stream', () => {
const events = [
at(0, 'step/start', { turn: 1, step: 1 }),
at(1, 'request/header', {
reason: 'initial',
header: {
config: { provider: 'fake', model: 'model' },
system: 'system',
tools: [{
name: 'read',
description: 'Read a file.',
parameters: { type: 'object' },
}],
},
}),
at(2, 'tool/call', {
turn: 1,
step: 1,
callId: 'call-1',
name: 'read',
arguments: '{}',
}),
at(3, 'assistant/message', {
turn: 1,
step: 1,
content: [{ type: 'text', text: 'done' }],
provenance: { provider: 'fake', model: 'model' },
usage: { inputTokens: 5, outputTokens: 2 },
}),
at(4, 'step/end', { turn: 1, step: 1 }),
at(5, 'compact/start', { turn: 1 }),
at(6, 'compact/summary', {
summary: [{ type: 'text', text: 'summary' }],
rawOutput: [
{ type: 'reasoning', text: 'thought' },
{ type: 'text', text: 'summary' },
],
provider: 'fake',
model: 'compact-model',
usage: { inputTokens: 8, outputTokens: 3 },
}),
at(7, 'user/message', {
content: [{ type: 'text', text: 'checkpoint' }],
source: { kind: 'plugin', plugin: 'compact' },
}),
at(8, 'compact/end', { turn: 1 }),
]
const snapshot = inspectRequests(entriesOf(events))
expect(snapshot.requests).toMatchObject([
{
purpose: 'assistant',
startSeq: 0,
resultSeq: 3,
status: 'complete',
prompt: {
config: { provider: 'fake', model: 'model' },
system: 'system',
},
promptChange: { seq: 1, kind: 'initial' },
},
{
purpose: 'compaction',
startSeq: 5,
resultSeq: 6,
replacementSeq: 7,
status: 'complete',
summary: [{ type: 'text', text: 'summary' }],
},
])
expect(snapshot.callSchemas.get('call-1')?.name).toBe('read')
})
it('captures schemas for nested tool dispatches from the active request header', () => {
const snapshot = inspectRequests(entriesOf([
at(0, 'request/header', {
reason: 'initial',
header: {
config: { provider: 'fake', model: 'model' },
tools: [{
name: 'read',
description: 'Read a file.',
parameters: { type: 'object' },
}],
},
}),
at(1, 'tool/code-dispatch-start', {
parentCallId: 'parent',
subCallId: 'nested',
name: 'read',
arguments: {},
}),
]))
expect(snapshot.callSchemas.get('nested')?.name).toBe('read')
})
})

View File

@@ -256,6 +256,42 @@ describe('paging', () => {
expect(snapshot.nodes.map(n => n.seq)).toEqual([1, 3, 7, 9])
})
it('loads every older page for complete-history inspection', async () => {
const pages = [
plainTurn(0, 0, '最早问', '最早答'),
plainTurn(6, 1, '中间问', '中间答'),
plainTurn(12, 2, '最新问', '最新答'),
]
const { api, session } = makeSession()
api.onHistory = (payload) => {
if (payload.beforeSeq === undefined) return histResponse(pages[2]!, true)
if (payload.beforeSeq === 12) return histResponse(pages[1]!, true)
return histResponse(pages[0]!, false)
}
await session.open()
await session.history.loadAll()
expect(api.callsOf('session.history')).toHaveLength(3)
expect(session.history.getSnapshot().hasMore).toBe(false)
expect(session.history.getSnapshot().entries.map(entry => entry.event.seq))
.toEqual([...Array(18).keys()])
expect(session.getSnapshot().nodes.map(node => node.seq)).toEqual([1, 3, 7, 9, 13, 15])
})
it('stops complete-history loading when a page makes no progress', async () => {
const { api, session } = makeSession()
api.onHistory = payload => payload.beforeSeq === undefined
? histResponse(plainTurn(6, 1, '新问', '新答'), true)
: Promise.resolve(err({ code: 'internal', message: 'page unavailable', details: {} }))
await session.open()
await session.history.loadAll()
expect(api.callsOf('session.history')).toHaveLength(2)
expect(session.history.getSnapshot().hasMore).toBe(true)
})
it('drops a discontinuous older page fail-soft (window unchanged, hasMore cleared)', async () => {
const { api, session } = makeSession()
api.onHistory = payload => payload.beforeSeq === undefined
@@ -855,16 +891,12 @@ describe('reference stability (the memo contract)', () => {
expect(after).not.toBe(before)
expect(after.runningCalls).toBe(before.runningCalls)
expect(after.pending).toBe(before.pending)
expect(after.requestAttempts).toBe(before.requestAttempts)
expect(after.compactionRequests).toBe(before.compactionRequests)
// And a mutation on the tracked domain swaps that array.
feed(ev.toolResult(11, 1, 'c1', 'ECHO'))
const resolved = session.getSnapshot()
expect(resolved.runningCalls).not.toBe(after.runningCalls)
expect(resolved.pending).toBe(after.pending)
feed(ev.assistant(12, 1, '完成'))
const completed = session.getSnapshot()
expect(completed.requestAttempts).not.toBe(resolved.requestAttempts)
expect(completed.compactionRequests).toBe(resolved.compactionRequests)
expect(session.getSnapshot()).not.toBe(resolved)
})
})

View File

@@ -1,14 +1,14 @@
/** Trajectory view: compact summary over a turn-aware event ledger. */
import { useMemo, useState } from 'react'
import { useEffect, useMemo, useState, useSyncExternalStore } from 'react'
import type { ConvViewProps } from '@deepseek-ai/dsh-client-ui-conversation/client'
import type {
AssistantMessageNode, CompactionRequestView, ConversationContext,
ConversationPromptChange, ModelRequestView,
AssistantMessageNode, ConversationContext, SessionHistory,
} from '@deepseek-ai/dsh-client-runtime/client'
import {
deriveTrajectoryContextBranches, trajectoryBranchContainsSeq,
} from './context-branches.ts'
inspectRequests, projectConversationHistory,
} from '@deepseek-ai/dsh-client-runtime/client'
import { deriveTrajectoryContextBranches } from './context-branches.ts'
import {
TrajectoryTable,
type TrajectoryRequestNumber,
@@ -19,9 +19,11 @@ import { deriveTrajectoryLayout } from './layout.ts'
import css from './views.module.css'
const EMPTY_IDS: ReadonlySet<number> = new Set()
const EMPTY_COMPACTION_REQUESTS: readonly CompactionRequestView[] = []
const EMPTY_MODEL_REQUESTS: readonly ModelRequestView[] = []
const EMPTY_PROMPT_CHANGES: readonly ConversationPromptChange[] = []
/** Raw session-history source needed by the event-complete trajectory view. */
export interface TrajectoryViewInjected {
history: SessionHistory
}
interface UsageLike {
inputTokens?: number
@@ -67,30 +69,47 @@ function addUsage(
}
}
export function TrajectoryView({ useSession }: ConvViewProps) {
export function TrajectoryView({ useSession, history }: ConvViewProps & TrajectoryViewInjected) {
const [collapsedTurns, setCollapsedTurns] = useState<ReadonlySet<number>>(EMPTY_IDS)
const [collapsedAssistants, setCollapsedAssistants] =
useState<ReadonlySet<number>>(EMPTY_IDS)
const nodes = useSession(s => s.nodes)
const projectedContexts = useSession(s => s.contexts)
const compactionRequests = useSession(
s => s.compactionRequests ?? EMPTY_COMPACTION_REQUESTS,
)
const requestAttempts = useSession(
s => s.requestAttempts ?? EMPTY_MODEL_REQUESTS,
)
const promptChanges = useSession(
s => s.promptChanges ?? EMPTY_PROMPT_CHANGES,
)
const partial = useSession(s => s.partial)
const runningCalls = useSession(s => s.runningCalls)
const callSchemas = useSession(s => s.callSchemas)
const codeDispatches = useSession(s => s.codeDispatches)
const subscribeHistory = useMemo(
() => (listener: () => void) => history.subscribe(listener),
[history],
)
const getHistorySnapshot = useMemo(
() => () => history.getSnapshot(),
[history],
)
const historySnapshot = useSyncExternalStore(
subscribeHistory,
getHistorySnapshot,
getHistorySnapshot,
)
useEffect(() => {
if (historySnapshot.openState === 'open' && historySnapshot.hasMore) {
void history.loadAll()
}
}, [history, historySnapshot.hasMore, historySnapshot.openState])
const projectedHistory = useMemo(
() => projectConversationHistory(historySnapshot.entries),
[historySnapshot.entries],
)
const requestInspection = useMemo(
() => inspectRequests(historySnapshot.entries),
[historySnapshot.entries],
)
const requests = requestInspection.requests
const callSchemas = requestInspection.callSchemas
const contexts = useMemo<readonly ConversationContext[]>(
() => projectedContexts === undefined || projectedContexts.length === 0
() => projectedHistory.contexts.length === 0
? [{ id: 0, nodes }]
: projectedContexts,
[nodes, projectedContexts],
: projectedHistory.contexts,
[nodes, projectedHistory.contexts],
)
const branches = useMemo(
() => deriveTrajectoryContextBranches(contexts),
@@ -98,11 +117,9 @@ export function TrajectoryView({ useSession }: ConvViewProps) {
)
const currentBranch = branches.at(-1)
if (currentBranch === undefined) throw new Error('trajectory branch projection must not be empty')
const selectedNodes = useMemo(() => {
const bySeq = new Map(currentBranch.nodes.map(node => [node.seq, node]))
for (const node of nodes) bySeq.set(node.seq, node)
return [...bySeq.values()].sort((left, right) => left.seq - right.seq)
}, [currentBranch, nodes])
const selectedNodes = projectedHistory.eventNodes.length === 0
? nodes
: projectedHistory.eventNodes
const globalRequestNumbers = useMemo<readonly TrajectoryRequestNumber[]>(() => {
const assistantsByStep = new Map<string, AssistantMessageNode>()
for (const context of contexts) {
@@ -115,46 +132,38 @@ export function TrajectoryView({ useSession }: ConvViewProps) {
if (node.kind !== 'assistant' || node.step <= 0) continue
assistantsByStep.set(`${node.turn}\u0000${node.step}`, node)
}
const attemptsByStep = new Map(
requestAttempts.map(request => [
`${request.turn}\u0000${request.step}`,
request,
]),
const requestsByStep = new Map(
requests
.filter(request => request.purpose === 'assistant')
.map(request => [
`${request.turn}\u0000${request.step}`,
request,
]),
)
const orderedRequests = [
...requestAttempts.map(request => ({
...requests.map(request => ({
seq: request.startSeq,
kind: 'ordinary' as const,
request,
node: assistantsByStep.get(`${request.turn}\u0000${request.step}`),
node: request.purpose === 'assistant'
? assistantsByStep.get(`${request.turn}\u0000${request.step}`)
: undefined,
})),
...[...assistantsByStep.entries()].flatMap(([key, node]) =>
attemptsByStep.has(key)
requestsByStep.has(key)
? []
: [{
seq: node.seq,
kind: 'ordinary' as const,
request: undefined,
node,
}],
),
...compactionRequests.map(request => ({
seq: request.startSeq,
kind: 'compaction' as const,
request,
node: undefined,
})),
].sort((left, right) => left.seq - right.seq)
const numbered: TrajectoryRequestNumber[] = []
let cumulativeUsage: TrajectoryUsage | undefined
for (const [index, entry] of orderedRequests.entries()) {
const usage = requestUsage(
entry.kind === 'compaction'
? entry.request.usage
: entry.request?.usage ?? entry.node?.usage,
)
const usage = requestUsage(entry.request?.usage ?? entry.node?.usage)
cumulativeUsage = addUsage(cumulativeUsage, usage)
if (entry.kind === 'ordinary') {
if (entry.request?.purpose !== 'compaction') {
const request = entry.request
const node = entry.node
const turn = request?.turn ?? node?.turn
@@ -223,10 +232,10 @@ export function TrajectoryView({ useSession }: ConvViewProps) {
step: partial.step,
group: `Step ${partial.step}`,
number: orderedRequests.length + 1,
...(currentBranch.latest.prompt?.config?.provider === undefined
...(currentBranch.latest.prompt?.config.provider === undefined
? {}
: { provider: currentBranch.latest.prompt.config.provider }),
...(currentBranch.latest.prompt?.config?.model === undefined
...(currentBranch.latest.prompt?.config.model === undefined
? {}
: { model: currentBranch.latest.prompt.config.model }),
...(currentBranch.latest.prompt?.config === undefined
@@ -238,48 +247,20 @@ export function TrajectoryView({ useSession }: ConvViewProps) {
}
return numbered
}, [
compactionRequests, contexts, currentBranch.latest.prompt, nodes, partial,
requestAttempts,
contexts, currentBranch.latest.prompt, nodes, partial, requests,
])
const requestNumbers = useMemo<readonly TrajectoryRequestNumber[]>(() => {
return globalRequestNumbers.filter(request =>
request.seq === undefined
|| trajectoryBranchContainsSeq(currentBranch, request.seq),
)
}, [currentBranch, globalRequestNumbers])
const visibleRequestAttempts = useMemo(
() => requestAttempts.filter(request =>
requestNumbers.some(number =>
number.purpose !== 'compaction' && number.seq === request.startSeq,
)),
[requestAttempts, requestNumbers],
)
const visibleCompactionRequests = useMemo(
() => compactionRequests.filter(request =>
requestNumbers.some(number =>
number.purpose === 'compaction' && number.seq === request.startSeq,
)),
[compactionRequests, requestNumbers],
)
const visiblePromptChanges = useMemo(
() => promptChanges.filter(change =>
trajectoryBranchContainsSeq(currentBranch, change.seq)),
[currentBranch, promptChanges],
)
const requestNumbers = globalRequestNumbers
const turns = useMemo(
() => deriveTrajectoryLayout({
nodes: selectedNodes,
partial,
runningCalls,
compactionRequests: visibleCompactionRequests,
requestAttempts: visibleRequestAttempts,
promptChanges: visiblePromptChanges,
requests,
callSchemas,
codeDispatches,
}),
[
selectedNodes, partial, runningCalls, visibleCompactionRequests,
visibleRequestAttempts, visiblePromptChanges, callSchemas, codeDispatches,
selectedNodes, partial, runningCalls, requests, callSchemas, codeDispatches,
],
)
const collapsibleTurnIds = useMemo(

View File

@@ -3,10 +3,11 @@
* view slot without defining a service.
*/
import type { Context } from 'cordis'
import type { SessionId } from '@deepseek-ai/dsh-client-runtime/client'
// Type-only: the 'conversation.view' SlotMap row (declared by the slot's
// owning package) must be in the program for the register calls to type.
import type {} from '@deepseek-ai/dsh-client-ui-conversation/client'
import { TrajectoryView } from './TrajectoryView.tsx'
import { TrajectoryView, type TrajectoryViewInjected } from './TrajectoryView.tsx'
import { WaterfallView } from './WaterfallView.tsx'
/**
@@ -16,7 +17,7 @@ import { WaterfallView } from './WaterfallView.tsx'
* into an undeclared slot throws — service waiting is what orders this
* apply after the declaring one.
*/
export const inject = ['slots', 'conversation']
export const inject = ['slots', 'conversation', 'sessions']
/**
* Client plugin body: register the trajectory and waterfall view tabs. The
@@ -25,8 +26,19 @@ export const inject = ['slots', 'conversation']
* @param ctx - client root context.
*/
export function apply(ctx: Context): void {
ctx.slots.register(
{ name: 'conversation.view', id: 'trajectory', order: 10, label: 'Trajectory' }, TrajectoryView)
ctx.slots.register({
name: 'conversation.view',
id: 'trajectory',
order: 10,
label: 'Trajectory',
inject: (sessionId: SessionId): TrajectoryViewInjected => {
const session = ctx.sessions.binding(sessionId)?.session
if (session === undefined) {
throw new Error(`ui-trajectory: session "${sessionId}" resolved no binding`)
}
return { history: session.history }
},
}, TrajectoryView)
ctx.slots.register(
{ name: 'conversation.view', id: 'waterfall', order: 20, label: 'Waterfall' }, WaterfallView)
}

View File

@@ -6,10 +6,10 @@ import type {
AssistantBlock,
AssistantMessageNode,
CodeSubCall,
CompactionRequestView,
ConversationPromptChange,
ConversationSnapshot,
ModelRequestView,
RequestInspectionSnapshot,
RequestPromptChange,
RequestView,
ToolResultNode,
} from '@deepseek-ai/dsh-client-runtime/client'
import type {
@@ -35,10 +35,8 @@ export interface TrajectoryLayoutInput {
nodes: ConversationSnapshot['nodes']
partial: ConversationSnapshot['partial']
runningCalls: ConversationSnapshot['runningCalls']
compactionRequests?: readonly CompactionRequestView[]
requestAttempts?: readonly ModelRequestView[]
promptChanges?: readonly ConversationPromptChange[]
callSchemas?: ConversationSnapshot['callSchemas']
requests?: readonly RequestView[]
callSchemas?: RequestInspectionSnapshot['callSchemas']
/** run_code sub-dispatches by parent callId (sub-cells nest under the parent Tool cell). */
codeDispatches: ConversationSnapshot['codeDispatches']
}
@@ -83,17 +81,18 @@ type OrderedLayoutEntry =
| {
kind: 'compaction'
seq: number
request: CompactionRequestView
request: RequestView
}
| {
kind: 'system'
seq: number
change: ConversationPromptChange
request: RequestView
change: RequestPromptChange
}
| {
kind: 'request'
seq: number
request: ModelRequestView
request: RequestView
}
function layoutEntryOrder(entry: OrderedLayoutEntry): number {
@@ -124,9 +123,7 @@ function inputCellDetail(node: InputNode): Pick<
*/
export function deriveTrajectoryLayout(input: TrajectoryLayoutInput): readonly TrajectoryTurnModel[] {
const {
nodes, partial, runningCalls, compactionRequests = [], requestAttempts = [],
promptChanges = [],
callSchemas, codeDispatches,
nodes, partial, runningCalls, requests = [], callSchemas, codeDispatches,
} = input
const resultByCall = indexResults(nodes)
const callStartById = new Map<string, number>()
@@ -193,17 +190,23 @@ export function deriveTrajectoryLayout(input: TrajectoryLayoutInput): readonly T
node,
nodeIndex,
})),
...compactionRequests.map(request => ({
kind: 'compaction' as const,
seq: request.startSeq,
request,
})),
...promptChanges.map(change => ({
kind: 'system' as const,
seq: change.seq,
change,
})),
...requestAttempts
...requests
.filter(request => request.purpose === 'compaction')
.map(request => ({
kind: 'compaction' as const,
seq: request.startSeq,
request,
})),
...requests.flatMap(request => request.promptChange === undefined || request.prompt === undefined
? []
: [{
kind: 'system' as const,
seq: request.promptChange.seq,
request,
change: request.promptChange,
}]),
...requests
.filter(request => request.purpose === 'assistant')
.filter(request =>
!representedRequests.has(`${request.turn}\u0000${request.step}`),
)
@@ -238,7 +241,7 @@ export function deriveTrajectoryLayout(input: TrajectoryLayoutInput): readonly T
continue
}
if (entry.kind === 'system') {
const { change } = entry
const { change, request } = entry
const turn = change.kind === 'initial'
? firstVisibleTurn(nodes, partial)
: enclosingPromptTurn(nodes, change.seq, partial)
@@ -249,7 +252,7 @@ export function deriveTrajectoryLayout(input: TrajectoryLayoutInput): readonly T
kind: 'system',
text: promptChangeLabel(change),
sourceSeq: change.seq,
promptDetail: change.prompt,
...(request.prompt === undefined ? {} : { promptDetail: request.prompt }),
...(change.previous === undefined
? {}
: { previousPromptDetail: change.previous }),
@@ -450,7 +453,7 @@ export function deriveTrajectoryLayout(input: TrajectoryLayoutInput): readonly T
function attachToolSchema(
laid: LaidCell,
callSchemas: ConversationSnapshot['callSchemas'],
callSchemas: RequestInspectionSnapshot['callSchemas'] | undefined,
): void {
if (laid.callId === undefined || callSchemas === undefined) return
const schema = callSchemas.get(laid.callId)
@@ -616,7 +619,7 @@ function summarizeAssistantActivity(blocks: readonly AssistantBlock[]): string {
return ''
}
function promptChangeLabel(change: ConversationPromptChange): string {
function promptChangeLabel(change: RequestPromptChange): string {
if (change.kind === 'initial') return 'Initial System Prompt'
if (change.kind === 'system') return 'System Prompt Updated'
if (change.kind === 'tools') return 'Tools Updated'

View File

@@ -15,7 +15,10 @@ import { bindSnapshotSelector } from '@deepseek-ai/dsh-client-web-react'
import { createSnapshotStore } from '@deepseek-ai/dsh-client-runtime/client'
import type { UseSession } from '@deepseek-ai/dsh-client-web-react'
import { SlotsService } from '@deepseek-ai/dsh-client-runtime/client'
import type { ConversationSnapshot, SessionId, SessionListState, WorkspaceListState } from '@deepseek-ai/dsh-client-runtime/client'
import type {
ConversationSnapshot, SessionHistory, SessionHistorySnapshot, SessionId,
SessionListState, WorkspaceListState,
} from '@deepseek-ai/dsh-client-runtime/client'
import type { ConvViewProps, ViewTab } from '@deepseek-ai/dsh-client-ui-conversation/client'
// Export discipline: packages/client/AGENTS.md.
import { ConversationSession, type ConversationSessionProps } from '@deepseek-ai/dsh-client-ui-conversation/src/client/skeleton/ConversationSession.tsx'
@@ -80,10 +83,42 @@ function standaloneProps(nodes: ConversationSnapshot['nodes']): ConvViewProps {
} as unknown as ConvViewProps
}
function emptyHistory(): SessionHistory {
const store = createSnapshotStore<SessionHistorySnapshot>({
sessionId: SID,
entries: [],
baseSeq: 0,
openState: 'open',
openError: null,
hasMore: false,
loadingOlder: false,
})
return {
getSnapshot: () => store.getSnapshot(),
subscribe: listener => store.subscribe(listener),
loadAll: () => Promise.resolve(),
}
}
/** Real-stack bench: root Context + real SlotsService ring + the plugin fiber. */
async function bench() {
const ctx = new Context()
const slots = new SlotsService(ctx)
const loadAllHistory = vi.fn(() => Promise.resolve())
const historyStore = createSnapshotStore<SessionHistorySnapshot>({
sessionId: SID,
entries: [],
baseSeq: 0,
openState: 'open',
openError: null,
hasMore: true,
loadingOlder: false,
})
const history: SessionHistory = {
getSnapshot: () => historyStore.getSnapshot(),
subscribe: listener => historyStore.subscribe(listener),
loadAll: loadAllHistory,
}
// The conversation entry's role: declare the ring, then seed the chat entry.
slots.register({
name: 'root',
@@ -95,9 +130,14 @@ async function bench() {
// 'conversation' inject is an ordering edge; the bench declares the ring
// itself, so a stub satisfies the wait.
ctx.provide('conversation', {})
ctx.provide('sessions', {
binding: (sessionId: SessionId) => sessionId === SID
? { session: { history } }
: undefined,
})
const fiber = ctx.plugin({ inject: [...inject], apply })
await fiber.await()
return { ctx, slots, fiber }
return { ctx, slots, fiber, loadAllHistory }
}
/** Tab projection twin of apply's viewTabs (the render-side consumption path). */
@@ -110,6 +150,7 @@ function tabsOf(slots: SlotsService): ViewTab[] {
function mount(slots: SlotsService, nodes: ConversationSnapshot['nodes'] = NODES) {
const sessionSnapshot = createSnapshotStore({
running: false, removed: false, promptError: null, nodes,
openState: 'open' as const, hasMore: true, loadingOlder: false,
partial: null, runningCalls: [] as ConversationSnapshot['runningCalls'], codeDispatches: new Map(),
})
const useSession = bindSnapshotSelector(sessionSnapshot) as unknown as UseSession<ConversationSnapshot>
@@ -121,8 +162,13 @@ function mount(slots: SlotsService, nodes: ConversationSnapshot['nodes'] = NODES
const entry = slots.entries('conversation.view').find(e => e.options.id === opts?.only)
if (entry === undefined) return null
const View = entry.component as FC<ConvViewProps>
const injectEntry = entry.inject as ((sessionId: SessionId) => object) | undefined
const injected = injectEntry === undefined
? {}
: injectEntry(SID)
return (
<View
{...injected}
{...({ sessionId: SID, useSession, useSessions: emptySessions(), useWorkspaces: emptyWorkspaces() } as unknown as ConvViewProps)}
key={key}
/>
@@ -186,6 +232,9 @@ describe('tab switching in ConversationRoot', () => {
fireEvent.click(screen.getByRole('button', { name: 'Expand turns' }))
expect(screen.getByRole('row', { name: /USER/ })).toBeTruthy()
expect(screen.queryByTestId('chat-body')).toBeNull()
await vi.waitFor(() => {
expect(b.loadAllHistory).toHaveBeenCalledOnce()
})
})
it('opens a local record inspector and switches payload tabs without opening chat details', async () => {
@@ -244,8 +293,10 @@ describe('span derivation', () => {
const { useSession } = fakeSession([])
const { container } = render(createElement(TrajectoryStatsHeader, { useSession: useSession }))
expect(container.firstChild).toBeNull()
render(createElement(TrajectoryView as FC<ConvViewProps>,
standaloneProps([])))
render(createElement(
TrajectoryView,
{ ...standaloneProps([]), history: emptyHistory() },
))
expect(screen.getByRole('toolbar', { name: 'Trajectory toolbar' })).toBeTruthy()
expect(screen.queryByRole('row')).toBeNull()
})