mirror of
https://github.com/deepseek-ai/deepseek-harness
synced 2026-08-15 21:04:50 +00:00
refactor(client): isolate trajectory history
This commit is contained in:
@@ -0,0 +1,35 @@
|
||||
import type {
|
||||
RpcError, SessionId,
|
||||
} from '@deepseek-ai/dsh-client-connection/client'
|
||||
import type { SessionHistoryInspection } from '../sessions/history.ts'
|
||||
import type { ObservableSnapshot } from './store.ts'
|
||||
|
||||
/** Observable state of one independently loaded session history ledger. */
|
||||
export interface SessionHistorySnapshot {
|
||||
state: 'cold' | 'loading' | 'ready' | 'error'
|
||||
error: RpcError | null
|
||||
hasMore: boolean
|
||||
inspection: SessionHistoryInspection
|
||||
}
|
||||
|
||||
/** Read-only history source addressed by session id. */
|
||||
export interface SessionHistoryFace
|
||||
extends ObservableSnapshot<SessionHistorySnapshot> {
|
||||
readonly sessionId: SessionId
|
||||
/**
|
||||
* Load the tail and exhaust every available older page.
|
||||
* @param signal - Consumer lifetime; abort is observed between page requests.
|
||||
* @returns When the available ledger is complete or stops advancing.
|
||||
*/
|
||||
loadAll(signal?: AbortSignal): Promise<void>
|
||||
}
|
||||
|
||||
/** Runtime service resolving independent history sources. */
|
||||
export interface ISessionHistory {
|
||||
/**
|
||||
* Resolve the identity-stable source for a session.
|
||||
* @param sessionId - Host session identity.
|
||||
* @returns The source owned outside Session and SessionManager.
|
||||
*/
|
||||
source(sessionId: SessionId): SessionHistoryFace
|
||||
}
|
||||
@@ -46,13 +46,6 @@ export interface ISession {
|
||||
* @returns completion; failures land in snapshot.openState/loadingOlder.
|
||||
*/
|
||||
loadOlder(): Promise<void>
|
||||
/**
|
||||
* Exhaust the available history for inspection features.
|
||||
* An abort stops before the next page without abandoning an active request.
|
||||
* @param signal - Consumer lifetime; abort is observed between pages.
|
||||
* @returns completion when history is exhausted or paging stops making progress.
|
||||
*/
|
||||
loadAllHistory(signal?: AbortSignal): Promise<void>
|
||||
/**
|
||||
* Execute one slash-command line against this session's agent — pure
|
||||
* admission semantics (the host executor durably logs the lifecycle).
|
||||
|
||||
@@ -5,6 +5,7 @@ import type { MaybeSnapshotSelectorHook, SnapshotSelectorHook } from '@deepseek-
|
||||
import { SlotsService } from './slots.ts'
|
||||
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 { UseProjection } from './sessions/projection-store.ts'
|
||||
@@ -12,6 +13,7 @@ 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'
|
||||
// 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'
|
||||
@@ -21,6 +23,9 @@ export type { AgentScopeHandle } from './agents/scope.ts'
|
||||
export { DirectoryBrowseError, WorkspaceCreateError, WorkspacesService } from './workspaces/service.ts'
|
||||
export type { Session } from './sessions/session.ts'
|
||||
export type { ISession, ProjectionsFace, SessionFace } from './contract/session.ts'
|
||||
export type {
|
||||
ISessionHistory, SessionHistoryFace, SessionHistorySnapshot,
|
||||
} from './contract/session-history.ts'
|
||||
export type { ISessions } from './contract/sessions.ts'
|
||||
export type { IWorkspaces } from './contract/workspaces.ts'
|
||||
export type {
|
||||
@@ -49,7 +54,7 @@ export type {
|
||||
export type {
|
||||
ConversationPromptSnapshot, RequestInspectionSnapshot, RequestPromptChange, RequestView,
|
||||
} from './sessions/request-inspection.ts'
|
||||
export type { ConversationHistoryProjection } from './sessions/fold-adapter.ts'
|
||||
export type { ConversationHistoryProjection } from './session-history/history-fold.ts'
|
||||
export type { SessionHistoryInspection } from './sessions/history.ts'
|
||||
export { PendingWait } from './sessions/pending.ts'
|
||||
export type { PendingInteraction, PendingKind, PendingPayloads } from './sessions/pending.ts'
|
||||
@@ -129,6 +134,8 @@ declare module 'cordis' {
|
||||
slots: import('./slots.ts').SlotsService
|
||||
/** The outward face only; the concrete service stays inside the runtime. */
|
||||
sessions: import('./contract/sessions.ts').ISessions
|
||||
/** Read-only history sources isolated from Chat sessions and workspace state. */
|
||||
sessionHistory: import('./contract/session-history.ts').ISessionHistory
|
||||
/** The outward face only; the concrete service stays inside the runtime. */
|
||||
workspaces: import('./contract/workspaces.ts').IWorkspaces
|
||||
}
|
||||
@@ -144,30 +151,55 @@ export function apply(ctx: Context): void {
|
||||
ctx.plugin(SlotsService)
|
||||
const connection = ctx.get('connection') as ConnectionHandle
|
||||
const sessions = new SessionsService(ctx, connection.api)
|
||||
const sessionHistory = new SessionHistoryService(ctx, connection.api)
|
||||
const workspaces = new WorkspacesService(ctx, connection.api, sessions)
|
||||
ctx.effect(
|
||||
() => workspaces.startInitialSelection(),
|
||||
'runtime: initial Workspace selection',
|
||||
)
|
||||
const loop = connection.start({
|
||||
onMuxEnvelope: (envelope) => { sessions.handleMuxEnvelope(envelope) },
|
||||
onMuxEnvelope: (envelope) => {
|
||||
sessions.handleMuxEnvelope(envelope)
|
||||
try {
|
||||
sessionHistory.handleMuxEnvelope(envelope)
|
||||
} catch (error) {
|
||||
console.error('[web-runtime] history frame routing failed:', error)
|
||||
}
|
||||
},
|
||||
onHostEnvelope: (envelope) => {
|
||||
sessions.handleHostEnvelope(envelope)
|
||||
workspaces.handleHostEnvelope(envelope)
|
||||
// Typed-event bridge: the session layer ignores registry frames (no
|
||||
// session routing); consumers (command directory caches) subscribe on ctx.
|
||||
if (envelope.payload.type === 'host/commands-changed') ctx.emit('commands/changed')
|
||||
try {
|
||||
sessionHistory.handleHostEnvelope(envelope)
|
||||
} catch (error) {
|
||||
console.error('[web-runtime] history host-frame routing failed:', error)
|
||||
}
|
||||
},
|
||||
onConnected: () => {
|
||||
sessions.handleConnected()
|
||||
workspaces.handleConnected()
|
||||
ctx.emit('connection/reset')
|
||||
try {
|
||||
sessionHistory.handleConnected()
|
||||
} catch (error) {
|
||||
console.error('[web-runtime] history reconnect failed:', error)
|
||||
}
|
||||
},
|
||||
onStateChange: (state) => {
|
||||
// Generation death fires before any next-generation frame can arrive
|
||||
// (reconnect replays flow from stream open, ahead of onConnected):
|
||||
// the only safe moment to drop generation-scoped interaction state.
|
||||
if (state === 'reconnecting') sessions.handleDisconnected()
|
||||
if (state === 'reconnecting') {
|
||||
sessions.handleDisconnected()
|
||||
try {
|
||||
sessionHistory.handleDisconnected()
|
||||
} catch (error) {
|
||||
console.error('[web-runtime] history disconnect failed:', error)
|
||||
}
|
||||
}
|
||||
},
|
||||
})
|
||||
ctx.effect(() => () => { loop.stop() }, 'runtime: connection stream loop')
|
||||
|
||||
@@ -0,0 +1,448 @@
|
||||
import type { ContentBlock } from '@deepseek-ai/dsh-llm/types'
|
||||
import type { SessionEvent } from '@deepseek-ai/dsh-session/types'
|
||||
import {
|
||||
SurfaceManager, isSurfaceEligibleType, isSurfaceEvent,
|
||||
} from '@deepseek-ai/dsh-session/surface'
|
||||
import type {
|
||||
HistoryEntry, ToolCallView, ToolResultView,
|
||||
} from '@deepseek-ai/dsh-client-connection/client'
|
||||
import type {
|
||||
AssistantRequestConfig, AssistantTiming, CodeSubCall, ConversationNode,
|
||||
PartialAssistant, RunningToolCall,
|
||||
} from '../sessions/conversation.ts'
|
||||
import { toAssistantBlocks } from '../sessions/conversation.ts'
|
||||
import type {
|
||||
ConversationContext, ConversationContextOriginKind,
|
||||
} from '../sessions/conversation-context.ts'
|
||||
import type { ConversationPromptSnapshot } from '../sessions/request-inspection.ts'
|
||||
import { PartialAccumulator } from '../sessions/partial.ts'
|
||||
|
||||
interface CallIndexEntry {
|
||||
name: string
|
||||
argsRaw: string
|
||||
time: number
|
||||
callView: ToolCallView | null
|
||||
}
|
||||
|
||||
interface FoldedContext {
|
||||
generation: number
|
||||
nodes: readonly number[]
|
||||
originSeq?: number
|
||||
}
|
||||
|
||||
interface AssistantStepMetadata {
|
||||
stepStartTime: number | null
|
||||
firstTokenTime: number | null
|
||||
}
|
||||
|
||||
/** Immutable conversation projections derived only from the history source. */
|
||||
export interface ConversationHistoryProjection {
|
||||
eventNodes: readonly ConversationNode[]
|
||||
contexts: readonly ConversationContext[]
|
||||
interruptedNodes: readonly ConversationNode[]
|
||||
partial: PartialAssistant | null
|
||||
runningCalls: readonly RunningToolCall[]
|
||||
codeDispatches: ReadonlyMap<string, readonly CodeSubCall[]>
|
||||
}
|
||||
|
||||
function assistantStepKey(turn: number, step: number): string {
|
||||
return `${turn}\u0000${step}`
|
||||
}
|
||||
|
||||
function paddingEvent(seq: number): SessionEvent {
|
||||
return { type: 'noop/padding', seq, time: 0, data: {} } as unknown as SessionEvent
|
||||
}
|
||||
|
||||
function replacementCrossesWindowHead(event: SessionEvent, baseSeq: number): boolean {
|
||||
if (!isSurfaceEvent(event) || event.surfaceOp === 'append') return false
|
||||
return event.surfaceOp.start < baseSeq || event.surfaceOp.end < baseSeq
|
||||
}
|
||||
|
||||
function contextOriginKind(event: SessionEvent | undefined): ConversationContextOriginKind {
|
||||
if (event?.type !== 'user/message') return 'rewrite'
|
||||
const source = event.data.source
|
||||
if (typeof source === 'object' && 'kind' in source && 'plugin' in source) {
|
||||
if (source.plugin === 'compact') return 'compaction'
|
||||
if (source.plugin === 'rewind') return 'rewind'
|
||||
}
|
||||
return 'rewrite'
|
||||
}
|
||||
|
||||
function isTokenDelta(chunk: SessionEvent<'assistant/chunk'>['data']['chunk']): boolean {
|
||||
switch (chunk.type) {
|
||||
case 'text-delta':
|
||||
case 'reasoning-delta':
|
||||
return chunk.text !== ''
|
||||
case 'tool-call-delta':
|
||||
return chunk.argumentsDelta !== '' || chunk.name !== undefined
|
||||
default:
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
function foldContexts(events: readonly SessionEvent[]): readonly FoldedContext[] {
|
||||
const replay: SessionEvent[] = []
|
||||
const surface = new SurfaceManager(replay)
|
||||
const contexts: FoldedContext[] = []
|
||||
let generation = 0
|
||||
let originSeq: number | undefined
|
||||
for (const event of events) {
|
||||
if (isSurfaceEvent(event) && event.surfaceOp !== 'append') {
|
||||
contexts.push({
|
||||
generation,
|
||||
nodes: [...surface.nodes],
|
||||
...(originSeq === undefined ? {} : { originSeq }),
|
||||
})
|
||||
generation++
|
||||
originSeq = event.seq
|
||||
}
|
||||
replay.push(event)
|
||||
}
|
||||
contexts.push({
|
||||
generation,
|
||||
nodes: [...surface.nodes],
|
||||
...(originSeq === undefined ? {} : { originSeq }),
|
||||
})
|
||||
return contexts
|
||||
}
|
||||
|
||||
function materializeNode(
|
||||
event: SessionEvent,
|
||||
callIndex: ReadonlyMap<string, CallIndexEntry>,
|
||||
resultView: ToolResultView | null,
|
||||
assistantTiming: AssistantTiming | undefined,
|
||||
requestConfig: AssistantRequestConfig | undefined,
|
||||
): ConversationNode {
|
||||
switch (event.type) {
|
||||
case 'user/message':
|
||||
if (event.data.source.kind !== 'user') {
|
||||
return {
|
||||
kind: 'context', seq: event.seq, time: event.time,
|
||||
content: event.data.content, source: event.data.source,
|
||||
}
|
||||
}
|
||||
return {
|
||||
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,
|
||||
turn: event.data.turn, step: event.data.step,
|
||||
blocks: toAssistantBlocks(event.data.message.content), usage: event.data.usage,
|
||||
provenance: {
|
||||
provider: event.data.message.source.provider,
|
||||
model: event.data.message.source.model,
|
||||
},
|
||||
...(requestConfig === undefined ? {} : { requestConfig }),
|
||||
...(assistantTiming === undefined ? {} : { timing: assistantTiming }),
|
||||
}
|
||||
case 'steering/message':
|
||||
return {
|
||||
kind: 'steering', seq: event.seq, time: event.time, turn: event.data.turn,
|
||||
content: event.data.message.content, source: event.data.message.source,
|
||||
}
|
||||
case 'tool/result': {
|
||||
const result = event.data.message.content[0]
|
||||
const callId = String(event.data.message.source.callId)
|
||||
const call = callIndex.get(callId)
|
||||
return {
|
||||
kind: 'tool-result', seq: event.seq, time: event.time,
|
||||
callId,
|
||||
call: call === undefined ? null : { name: call.name, argsRaw: call.argsRaw },
|
||||
callTime: call?.time ?? null,
|
||||
content: result.content, isError: result.isError === true,
|
||||
...(event.data.error === undefined ? {} : { error: event.data.error }),
|
||||
meta: event.data.meta,
|
||||
callView: call?.callView ?? null,
|
||||
resultView,
|
||||
}
|
||||
}
|
||||
default:
|
||||
return {
|
||||
kind: 'unknown', seq: event.seq, time: event.time,
|
||||
type: event.type, data: (event as { data?: unknown }).data,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function projectTransient(entries: readonly HistoryEntry[]): Pick<
|
||||
ConversationHistoryProjection,
|
||||
'interruptedNodes' | 'partial' | 'runningCalls' | 'codeDispatches'
|
||||
> {
|
||||
let partial: PartialAccumulator | null = null
|
||||
const openCalls = new Map<string, RunningToolCall>()
|
||||
const interruptedNodes: ConversationNode[] = []
|
||||
const codeDispatches = new Map<string, readonly CodeSubCall[]>()
|
||||
|
||||
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) ?? []
|
||||
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,
|
||||
}])
|
||||
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]
|
||||
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),
|
||||
)
|
||||
continue
|
||||
}
|
||||
switch (event.type) {
|
||||
case 'assistant/chunk': {
|
||||
const { turn, step, chunk } = event.data
|
||||
if (partial === null || partial.turn !== turn || partial.step !== step) {
|
||||
partial = new PartialAccumulator(turn, step)
|
||||
}
|
||||
partial.push(chunk)
|
||||
break
|
||||
}
|
||||
case 'assistant/message':
|
||||
if (partial?.turn === event.data.turn && partial.step === event.data.step) partial = null
|
||||
break
|
||||
case 'tool/call':
|
||||
openCalls.set(String(event.data.callId), {
|
||||
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: entry.view?.for === 'call' ? entry.view.view : null,
|
||||
})
|
||||
break
|
||||
case 'tool/result':
|
||||
openCalls.delete(String(event.data.message.source.callId))
|
||||
break
|
||||
case 'turn/end': {
|
||||
if (partial !== null && partial.turn === event.data.turn) {
|
||||
const { blocks } = partial.toPartial()
|
||||
const visible = blocks.some(block =>
|
||||
block.kind === 'text' || block.kind === 'reasoning' ? block.text !== '' : true)
|
||||
if (visible) {
|
||||
interruptedNodes.push({
|
||||
kind: 'assistant', seq: event.seq - 0.9, time: event.time,
|
||||
turn: partial.turn, step: partial.step, blocks, interrupted: true,
|
||||
})
|
||||
}
|
||||
partial = null
|
||||
}
|
||||
let callOffset = 0
|
||||
for (const [callId, call] of openCalls) {
|
||||
if (call.turn !== event.data.turn) continue
|
||||
openCalls.delete(callId)
|
||||
interruptedNodes.push({
|
||||
kind: 'tool-result', seq: event.seq - 0.8 + callOffset++ * 0.01,
|
||||
time: event.time,
|
||||
callId,
|
||||
call: { name: call.name, argsRaw: call.argsRaw },
|
||||
callTime: call.time,
|
||||
content: [],
|
||||
isError: true,
|
||||
error: { name: 'Interrupted', code: 'interrupted' },
|
||||
callView: call.callView,
|
||||
resultView: null,
|
||||
})
|
||||
}
|
||||
break
|
||||
}
|
||||
default:
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
interruptedNodes,
|
||||
partial: partial?.toPartial() ?? null,
|
||||
runningCalls: [...openCalls.values()],
|
||||
codeDispatches,
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Project one immutable history ledger without reading or mutating Chat state.
|
||||
* @param entries - Contiguous history entries in sequence order.
|
||||
* @returns Event order, context lineage, and transient tail state.
|
||||
*/
|
||||
export function projectConversationHistory(
|
||||
entries: readonly HistoryEntry[],
|
||||
): ConversationHistoryProjection {
|
||||
const events = entries.map(entry => entry.event)
|
||||
const baseSeq = events[0]?.seq ?? 0
|
||||
const padded = [
|
||||
...Array.from({ length: baseSeq }, (_, seq) => paddingEvent(seq)),
|
||||
...events,
|
||||
]
|
||||
const callIndex = new Map<string, CallIndexEntry>()
|
||||
const resultViews = new Map<number, ToolResultView>()
|
||||
const assistantSteps = new Map<string, AssistantStepMetadata>()
|
||||
const assistantTimings = new Map<number, AssistantTiming>()
|
||||
const assistantRequestConfigs = new Map<number, AssistantRequestConfig>()
|
||||
const promptsByContext = new Map<number, ConversationPromptSnapshot>()
|
||||
let activeRequestConfig: AssistantRequestConfig | undefined
|
||||
let activePrompt: ConversationPromptSnapshot | undefined
|
||||
let contextGeneration = 0
|
||||
|
||||
for (const [index, event] of events.entries()) {
|
||||
const view = entries[index]?.view
|
||||
if (event.type === 'tool/call') {
|
||||
callIndex.set(String(event.data.callId), {
|
||||
name: event.data.name,
|
||||
argsRaw: event.data.arguments,
|
||||
time: event.time,
|
||||
callView: view?.for === 'call' ? view.view : null,
|
||||
})
|
||||
} else if (event.type === 'tool/result' && view?.for === 'result') {
|
||||
resultViews.set(event.seq, view.view)
|
||||
}
|
||||
if (isSurfaceEvent(event) && event.surfaceOp !== 'append') {
|
||||
contextGeneration++
|
||||
if (activePrompt !== undefined) promptsByContext.set(contextGeneration, activePrompt)
|
||||
}
|
||||
if (event.type === 'request/header') {
|
||||
activeRequestConfig = event.data.header.config
|
||||
activePrompt = {
|
||||
config: event.data.header.config,
|
||||
system: event.data.header.system ?? '',
|
||||
tools: event.data.header.tools ?? [],
|
||||
}
|
||||
promptsByContext.set(contextGeneration, activePrompt)
|
||||
} else if (event.type === 'step/start') {
|
||||
assistantSteps.set(
|
||||
assistantStepKey(event.data.turn, event.data.step),
|
||||
{ stepStartTime: event.time, firstTokenTime: null },
|
||||
)
|
||||
} else if (event.type === 'assistant/chunk' && isTokenDelta(event.data.chunk)) {
|
||||
const key = assistantStepKey(event.data.turn, event.data.step)
|
||||
const current = assistantSteps.get(key) ?? {
|
||||
stepStartTime: null,
|
||||
firstTokenTime: null,
|
||||
}
|
||||
if (current.firstTokenTime === null) {
|
||||
assistantSteps.set(key, { ...current, firstTokenTime: event.time })
|
||||
}
|
||||
} else if (event.type === 'assistant/message') {
|
||||
assistantTimings.set(
|
||||
event.seq,
|
||||
{
|
||||
...(assistantSteps.get(assistantStepKey(event.data.turn, event.data.step)) ?? {
|
||||
stepStartTime: null,
|
||||
firstTokenTime: null,
|
||||
}),
|
||||
completedTime: event.time,
|
||||
},
|
||||
)
|
||||
if (activeRequestConfig !== undefined) {
|
||||
assistantRequestConfigs.set(event.seq, activeRequestConfig)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const nodeCache = new Map<number, ConversationNode>()
|
||||
const materialize = (seq: number): ConversationNode | undefined => {
|
||||
const cached = nodeCache.get(seq)
|
||||
if (cached !== undefined) return cached
|
||||
const event = padded[seq]
|
||||
if (event === undefined || !isSurfaceEligibleType(event.type)) return
|
||||
const node = materializeNode(
|
||||
event,
|
||||
callIndex,
|
||||
resultViews.get(seq) ?? null,
|
||||
assistantTimings.get(seq),
|
||||
assistantRequestConfigs.get(seq),
|
||||
)
|
||||
nodeCache.set(seq, node)
|
||||
return node
|
||||
}
|
||||
const eventNodes = events.flatMap((event) => {
|
||||
const node = materialize(event.seq)
|
||||
return node === undefined ? [] : [node]
|
||||
})
|
||||
|
||||
let contexts: readonly ConversationContext[]
|
||||
if (events.some(event => replacementCrossesWindowHead(event, baseSeq))) {
|
||||
contexts = [{
|
||||
id: 0,
|
||||
...(activePrompt === undefined ? {} : { prompt: activePrompt }),
|
||||
nodes: eventNodes,
|
||||
}]
|
||||
} else {
|
||||
try {
|
||||
contexts = foldContexts(padded).map((context): ConversationContext => {
|
||||
const nodes = context.nodes.flatMap((seq) => {
|
||||
const node = materialize(seq)
|
||||
return node === undefined ? [] : [node]
|
||||
})
|
||||
const prompt = promptsByContext.get(context.generation)
|
||||
if (context.originSeq === undefined) {
|
||||
return {
|
||||
id: context.generation,
|
||||
...(prompt === undefined ? {} : { prompt }),
|
||||
nodes,
|
||||
}
|
||||
}
|
||||
const originEvent = padded[context.originSeq]
|
||||
return {
|
||||
id: context.generation,
|
||||
parentId: context.generation - 1,
|
||||
origin: contextOriginKind(originEvent),
|
||||
originSeq: context.originSeq,
|
||||
...(originEvent === undefined ? {} : { createdAt: originEvent.time }),
|
||||
...(prompt === undefined ? {} : { prompt }),
|
||||
nodes,
|
||||
}
|
||||
})
|
||||
} catch (error) {
|
||||
console.error('[web-runtime] history surface fold failed, using event order:', error)
|
||||
contexts = [{
|
||||
id: 0,
|
||||
...(activePrompt === undefined ? {} : { prompt: activePrompt }),
|
||||
nodes: eventNodes,
|
||||
}]
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
eventNodes,
|
||||
contexts,
|
||||
...projectTransient(entries),
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,66 @@
|
||||
import type { Context } from 'cordis'
|
||||
import type {
|
||||
HostFrame, IApiClient, MuxFrame, RpcRequest, SessionId,
|
||||
} from '@deepseek-ai/dsh-client-connection/client'
|
||||
import type {
|
||||
ISessionHistory, SessionHistoryFace,
|
||||
} from '../contract/session-history.ts'
|
||||
import { SessionHistorySource } from './source.ts'
|
||||
|
||||
/** Root registry and frame router for independent inspection histories. */
|
||||
export class SessionHistoryService implements ISessionHistory {
|
||||
private readonly sources = new Map<SessionId, SessionHistorySource>()
|
||||
|
||||
/**
|
||||
* @param ctx - Client root context.
|
||||
* @param api - Shared wire client.
|
||||
*/
|
||||
constructor(ctx: Context, private readonly api: IApiClient) {
|
||||
ctx.reflect.provide('sessionHistory', this, undefined)
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolve one identity-stable history source.
|
||||
* @param sessionId - Host session identity.
|
||||
* @returns Source independent from SessionManager.
|
||||
*/
|
||||
source(sessionId: SessionId): SessionHistoryFace {
|
||||
let source = this.sources.get(sessionId)
|
||||
if (source === undefined) {
|
||||
source = new SessionHistorySource(sessionId, this.api)
|
||||
this.sources.set(sessionId, source)
|
||||
}
|
||||
return source
|
||||
}
|
||||
|
||||
/**
|
||||
* Route history-relevant mux frames only to an existing source.
|
||||
* @param envelope - Validated mux envelope.
|
||||
*/
|
||||
handleMuxEnvelope(envelope: RpcRequest<MuxFrame>): void {
|
||||
const frame = envelope.payload
|
||||
if (frame.type === 'stream/error') return
|
||||
this.sources.get(frame.sessionId)?.handleMuxFrame(frame)
|
||||
}
|
||||
|
||||
/**
|
||||
* Drop a removed session's independent history source.
|
||||
* @param envelope - Validated host envelope.
|
||||
*/
|
||||
handleHostEnvelope(envelope: RpcRequest<HostFrame>): void {
|
||||
const frame = envelope.payload
|
||||
if (frame.type !== 'host/session-removed') return
|
||||
this.sources.get(frame.sessionId)?.dispose()
|
||||
this.sources.delete(frame.sessionId)
|
||||
}
|
||||
|
||||
/** Invalidate requests from the dead connection generation. */
|
||||
handleDisconnected(): void {
|
||||
for (const source of this.sources.values()) source.handleDisconnected()
|
||||
}
|
||||
|
||||
/** Rebuild every previously activated source from the new generation. */
|
||||
handleConnected(): void {
|
||||
for (const source of this.sources.values()) source.resync()
|
||||
}
|
||||
}
|
||||
352
packages/client/runtime/src/client/session-history/source.ts
Normal file
352
packages/client/runtime/src/client/session-history/source.ts
Normal file
@@ -0,0 +1,352 @@
|
||||
import type {
|
||||
HistoryEntry, IApiClient, MuxFrame, RpcError, SessionId,
|
||||
} from '@deepseek-ai/dsh-client-connection/client'
|
||||
import { transportError } from '@deepseek-ai/dsh-host-apiproxy/api'
|
||||
import type {
|
||||
SessionHistoryFace, SessionHistorySnapshot,
|
||||
} from '../contract/session-history.ts'
|
||||
import { createHistoryInspection } from '../sessions/history.ts'
|
||||
import { Notifier } from '../sessions/notifier.ts'
|
||||
|
||||
const HISTORY_PAGE_MESSAGES = 50
|
||||
|
||||
function isAborted(signal: AbortSignal | undefined): boolean {
|
||||
return signal?.aborted === true
|
||||
}
|
||||
|
||||
/** Independent raw-history owner used only by inspection consumers. */
|
||||
export class SessionHistorySource implements SessionHistoryFace {
|
||||
private entries: readonly HistoryEntry[] = []
|
||||
private baseSeq = 0
|
||||
private hasMore = false
|
||||
private state: SessionHistorySnapshot['state'] = 'cold'
|
||||
private error: RpcError | null = null
|
||||
private generation = 0
|
||||
private persistentConsumer = false
|
||||
private readonly consumerSignals = new Set<AbortSignal>()
|
||||
private openPromise: Promise<void> | null = null
|
||||
private olderPromise: Promise<void> | null = null
|
||||
private stitching = false
|
||||
private liveBuffer: HistoryEntry[] = []
|
||||
private subscribedLastSeq: number | null = null
|
||||
private inspectionCache: {
|
||||
entries: readonly HistoryEntry[]
|
||||
value: SessionHistorySnapshot['inspection']
|
||||
} | null = null
|
||||
private snapshotCache: SessionHistorySnapshot
|
||||
private readonly notifier = new Notifier(() => {
|
||||
this.snapshotCache = this.buildSnapshot()
|
||||
})
|
||||
|
||||
/**
|
||||
* @param sessionId - Host session identity.
|
||||
* @param api - Shared wire client.
|
||||
*/
|
||||
constructor(
|
||||
readonly sessionId: SessionId,
|
||||
private readonly api: IApiClient,
|
||||
) {
|
||||
this.snapshotCache = this.buildSnapshot()
|
||||
}
|
||||
|
||||
/**
|
||||
* Subscribe to ledger changes.
|
||||
* @param listener - Change callback.
|
||||
* @returns Unsubscribe function.
|
||||
*/
|
||||
subscribe(listener: () => void): () => void {
|
||||
return this.notifier.subscribe(listener)
|
||||
}
|
||||
|
||||
/**
|
||||
* Read the cached ledger snapshot.
|
||||
* @returns Stable snapshot until the source changes.
|
||||
*/
|
||||
getSnapshot(): SessionHistorySnapshot {
|
||||
this.notifier.ensureFresh()
|
||||
return this.snapshotCache
|
||||
}
|
||||
|
||||
/**
|
||||
* Load the tail and exhaust all available older pages.
|
||||
* @param signal - Consumer lifetime.
|
||||
* @returns When paging completes, fails to advance, or is aborted.
|
||||
*/
|
||||
async loadAll(signal?: AbortSignal): Promise<void> {
|
||||
if (signal?.aborted === true) return
|
||||
this.trackConsumer(signal)
|
||||
await this.open()
|
||||
while (
|
||||
!isAborted(signal)
|
||||
&& this.state === 'ready'
|
||||
&& this.hasMore
|
||||
) {
|
||||
const previousBaseSeq = this.baseSeq
|
||||
await this.loadOlder()
|
||||
if (isAborted(signal) || this.baseSeq === previousBaseSeq) return
|
||||
}
|
||||
}
|
||||
|
||||
/** Rebuild and page for whichever mounted consumers survive a reconnect. */
|
||||
private async loadForConsumers(): Promise<void> {
|
||||
await this.open()
|
||||
while (
|
||||
this.hasConsumer()
|
||||
&& this.state === 'ready'
|
||||
&& this.hasMore
|
||||
) {
|
||||
const previousBaseSeq = this.baseSeq
|
||||
await this.loadOlder()
|
||||
if (!this.hasConsumer() || this.baseSeq === previousBaseSeq) return
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Route a relevant mux frame without involving the Chat session.
|
||||
* @param frame - Session-addressed frame.
|
||||
*/
|
||||
handleMuxFrame(frame: MuxFrame): void {
|
||||
if (frame.type === 'session/subscribed') {
|
||||
this.subscribedLastSeq = frame.lastSeq
|
||||
return
|
||||
}
|
||||
if (frame.type !== 'session/event') return
|
||||
this.acceptLive({ event: frame.event, ...(frame.view === undefined ? {} : { view: frame.view }) })
|
||||
}
|
||||
|
||||
/** Invalidate dead-generation requests while retaining the last readable snapshot. */
|
||||
handleDisconnected(): void {
|
||||
this.generation++
|
||||
this.openPromise = null
|
||||
this.olderPromise = null
|
||||
this.stitching = false
|
||||
this.liveBuffer = []
|
||||
this.subscribedLastSeq = null
|
||||
if (this.state !== 'cold') {
|
||||
this.state = 'cold'
|
||||
this.error = null
|
||||
this.notifier.markDirty()
|
||||
}
|
||||
}
|
||||
|
||||
/** Rebuild an activated ledger from the new connection generation. */
|
||||
resync(): void {
|
||||
if (!this.hasConsumer()) return
|
||||
this.generation++
|
||||
this.openPromise = null
|
||||
this.olderPromise = null
|
||||
this.stitching = false
|
||||
this.liveBuffer = []
|
||||
this.subscribedLastSeq = null
|
||||
this.entries = []
|
||||
this.baseSeq = 0
|
||||
this.hasMore = false
|
||||
this.state = 'cold'
|
||||
this.error = null
|
||||
this.notifier.markDirty()
|
||||
void this.loadForConsumers()
|
||||
}
|
||||
|
||||
/** Stop future refresh work after the host removes the session. */
|
||||
dispose(): void {
|
||||
this.persistentConsumer = false
|
||||
this.consumerSignals.clear()
|
||||
this.generation++
|
||||
this.openPromise = null
|
||||
this.olderPromise = null
|
||||
this.liveBuffer = []
|
||||
}
|
||||
|
||||
private open(): Promise<void> {
|
||||
if (this.state === 'ready') return Promise.resolve()
|
||||
if (this.openPromise !== null) return this.openPromise
|
||||
const generation = this.generation
|
||||
const operation = this.doOpen(generation)
|
||||
const settled = operation.finally(() => {
|
||||
if (this.openPromise === settled) this.openPromise = null
|
||||
})
|
||||
this.openPromise = settled
|
||||
return settled
|
||||
}
|
||||
|
||||
private trackConsumer(signal: AbortSignal | undefined): void {
|
||||
if (signal === undefined) {
|
||||
this.persistentConsumer = true
|
||||
return
|
||||
}
|
||||
if (this.consumerSignals.has(signal)) return
|
||||
this.consumerSignals.add(signal)
|
||||
signal.addEventListener('abort', () => {
|
||||
this.consumerSignals.delete(signal)
|
||||
}, { once: true })
|
||||
}
|
||||
|
||||
private hasConsumer(): boolean {
|
||||
return this.persistentConsumer || this.consumerSignals.size > 0
|
||||
}
|
||||
|
||||
private async doOpen(generation: number): Promise<void> {
|
||||
this.state = 'loading'
|
||||
this.error = null
|
||||
this.notifier.markDirty()
|
||||
try {
|
||||
let { result } = await this.api.sessions.history({
|
||||
sessionId: this.sessionId,
|
||||
maxMessages: HISTORY_PAGE_MESSAGES,
|
||||
})
|
||||
if (generation !== this.generation) return
|
||||
if (!result.ok) {
|
||||
this.state = 'error'
|
||||
this.error = result.error
|
||||
return
|
||||
}
|
||||
this.installTail(result.value.events, result.value.hasMore, true)
|
||||
const tailSeq = this.tailSeq()
|
||||
if (
|
||||
this.subscribedLastSeq !== null
|
||||
&& tailSeq !== null
|
||||
&& this.subscribedLastSeq > tailSeq
|
||||
) {
|
||||
result = (await this.api.sessions.history({
|
||||
sessionId: this.sessionId,
|
||||
maxMessages: HISTORY_PAGE_MESSAGES,
|
||||
})).result
|
||||
if (generation !== this.generation) return
|
||||
if (result.ok) this.installTail(result.value.events, result.value.hasMore, true)
|
||||
}
|
||||
this.state = 'ready'
|
||||
} catch (error) {
|
||||
if (generation !== this.generation) return
|
||||
this.state = 'error'
|
||||
const folded = transportError<never>(error)
|
||||
/* v8 ignore next -- transportError always returns the error branch. */
|
||||
this.error = folded.ok ? null : folded.error
|
||||
} finally {
|
||||
if (generation === this.generation) this.notifier.markDirty()
|
||||
}
|
||||
}
|
||||
|
||||
private loadOlder(): Promise<void> {
|
||||
if (this.olderPromise !== null) return this.olderPromise
|
||||
if (this.state !== 'ready' || !this.hasMore) return Promise.resolve()
|
||||
const generation = this.generation
|
||||
const operation = (async () => {
|
||||
try {
|
||||
const { result } = await this.api.sessions.history({
|
||||
sessionId: this.sessionId,
|
||||
beforeSeq: this.baseSeq,
|
||||
maxMessages: HISTORY_PAGE_MESSAGES,
|
||||
})
|
||||
if (generation !== this.generation || this.state !== 'ready' || !result.ok) return
|
||||
const older = result.value.events
|
||||
if (older.length === 0) {
|
||||
this.hasMore = result.value.hasMore
|
||||
return
|
||||
}
|
||||
const tail = older.at(-1)
|
||||
if (tail === undefined || tail.event.seq + 1 !== this.baseSeq) {
|
||||
console.error(
|
||||
`[web-runtime] inspection history page discontinuous: tail seq ${tail?.event.seq} vs baseSeq ${this.baseSeq}`,
|
||||
)
|
||||
this.hasMore = false
|
||||
return
|
||||
}
|
||||
this.entries = [...older, ...this.entries]
|
||||
this.baseSeq = older[0]?.event.seq ?? this.baseSeq
|
||||
this.hasMore = result.value.hasMore
|
||||
} catch (error) {
|
||||
console.error('[web-runtime] inspection history paging failed:', error)
|
||||
}
|
||||
})()
|
||||
const settled = operation.finally(() => {
|
||||
if (this.olderPromise !== settled) return
|
||||
this.olderPromise = null
|
||||
this.notifier.markDirty()
|
||||
})
|
||||
this.olderPromise = settled
|
||||
return settled
|
||||
}
|
||||
|
||||
private installTail(
|
||||
tail: readonly HistoryEntry[],
|
||||
hasMore: boolean,
|
||||
replace: boolean,
|
||||
): void {
|
||||
if (replace) {
|
||||
this.entries = [...tail]
|
||||
this.hasMore = hasMore
|
||||
} else {
|
||||
const firstSeq = tail[0]?.event.seq
|
||||
const prefix = firstSeq === undefined
|
||||
? this.entries
|
||||
: this.entries.filter(entry => entry.event.seq < firstSeq)
|
||||
this.entries = [...prefix, ...tail]
|
||||
}
|
||||
this.baseSeq = this.entries[0]?.event.seq ?? 0
|
||||
const buffered = this.liveBuffer
|
||||
this.liveBuffer = []
|
||||
for (const entry of buffered) this.appendLive(entry)
|
||||
this.notifier.markDirty()
|
||||
}
|
||||
|
||||
private acceptLive(entry: HistoryEntry): void {
|
||||
if (this.state === 'loading' || this.stitching) {
|
||||
this.liveBuffer.push(entry)
|
||||
return
|
||||
}
|
||||
if (this.state !== 'ready') return
|
||||
const tailSeq = this.tailSeq()
|
||||
if (tailSeq !== null && entry.event.seq > tailSeq + 1) {
|
||||
this.liveBuffer.push(entry)
|
||||
void this.repairGap()
|
||||
return
|
||||
}
|
||||
this.appendLive(entry)
|
||||
this.notifier.markDirty()
|
||||
}
|
||||
|
||||
private appendLive(entry: HistoryEntry): void {
|
||||
const tailSeq = this.tailSeq()
|
||||
if (tailSeq !== null && entry.event.seq <= tailSeq) return
|
||||
this.entries = [...this.entries, entry]
|
||||
}
|
||||
|
||||
private async repairGap(): Promise<void> {
|
||||
if (this.stitching) return
|
||||
this.stitching = true
|
||||
const generation = this.generation
|
||||
try {
|
||||
const { result } = await this.api.sessions.history({
|
||||
sessionId: this.sessionId,
|
||||
maxMessages: HISTORY_PAGE_MESSAGES,
|
||||
})
|
||||
if (result.ok && generation === this.generation && this.state === 'ready') {
|
||||
this.installTail(result.value.events, result.value.hasMore, false)
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('[web-runtime] inspection history gap repair failed:', error)
|
||||
} finally {
|
||||
if (generation === this.generation) this.stitching = false
|
||||
}
|
||||
}
|
||||
|
||||
private tailSeq(): number | null {
|
||||
return this.entries.at(-1)?.event.seq ?? null
|
||||
}
|
||||
|
||||
private buildSnapshot(): SessionHistorySnapshot {
|
||||
if (this.inspectionCache?.entries !== this.entries) {
|
||||
const entries = this.entries
|
||||
this.inspectionCache = {
|
||||
entries,
|
||||
value: createHistoryInspection(() => entries),
|
||||
}
|
||||
}
|
||||
return {
|
||||
state: this.state,
|
||||
error: this.error,
|
||||
hasMore: this.hasMore,
|
||||
inspection: this.inspectionCache.value,
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -9,7 +9,6 @@ import type { TodoItem } from '@deepseek-ai/dsh-session/types'
|
||||
import type {
|
||||
RpcError, SessionId, ToolCallView, ToolResultView,
|
||||
} from '@deepseek-ai/dsh-client-connection/client'
|
||||
import type { SessionHistoryInspection } from './history.ts'
|
||||
import type { PendingInteraction } from './pending.ts'
|
||||
export type { TodoItem }
|
||||
|
||||
@@ -266,8 +265,6 @@ export interface ConversationSnapshot {
|
||||
sessionId: SessionId
|
||||
/** Surface fold product (finalized conversation nodes in surface order). */
|
||||
nodes: readonly ConversationNode[]
|
||||
/** Lazy history-only projections consumed by inspection views. */
|
||||
inspection?: SessionHistoryInspection
|
||||
/** Fold degradation flag (cross-window replace defense): when true, nodes come from the lenient linear scan. */
|
||||
foldDegraded: boolean
|
||||
partial: PartialAssistant | null
|
||||
|
||||
@@ -1,55 +1,17 @@
|
||||
// FoldAdapter: core SurfaceManager wiring + node materialization cache.
|
||||
// Padding sentinels solve the paged-window seq offset (core fold asserts seq === index).
|
||||
// A replace that crosses the loaded window head uses a lenient linear scan until
|
||||
// paging reaches its range; unexpected fold failures report and use the same fallback.
|
||||
// Padding sentinels solve the paged-window seq offset (core fold asserts seq === index);
|
||||
// a cross-window replace throw degrades to a lenient linear scan (foldDegraded —
|
||||
// the degradation lives in one branch function in this file, zero scattered removal points).
|
||||
|
||||
import type { SessionEvent } from '@deepseek-ai/dsh-session/types'
|
||||
// Subpath export (package.json exports "./surface", alias added for this): all value imports
|
||||
// go through it — the package root points at lib/index.js (needs a build) which the vite
|
||||
// browser bundle cannot resolve; surface.ts has no Node dependencies.
|
||||
import {
|
||||
SurfaceManager, isSurfaceEligibleType, isSurfaceEvent,
|
||||
} from '@deepseek-ai/dsh-session/surface'
|
||||
import { SurfaceManager, isSurfaceEligibleType } from '@deepseek-ai/dsh-session/surface'
|
||||
import type { CommandId } from '@deepseek-ai/dsh-commands/brand'
|
||||
import type {
|
||||
HistoryEntry, ToolCallView, ToolEventView, ToolResultView,
|
||||
} from '@deepseek-ai/dsh-client-connection/client'
|
||||
import type {
|
||||
AssistantRequestConfig, AssistantTiming, CommandNode, ConversationNode,
|
||||
} from './conversation.ts'
|
||||
import type { ToolCallView, ToolEventView, ToolResultView } from '@deepseek-ai/dsh-client-connection/client'
|
||||
import type { CommandNode, ConversationNode } from './conversation.ts'
|
||||
import { toAssistantBlocks } from './conversation.ts'
|
||||
import type {
|
||||
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 {
|
||||
@@ -71,70 +33,11 @@ function paddingEvent(seq: number): SessionEvent {
|
||||
return { type: 'noop/padding', seq, time: 0, data: {} } as unknown as SessionEvent
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether a valid replacement range begins before the loaded history window.
|
||||
* @param event - Candidate surface event in the current replay window.
|
||||
* @param baseSeq - Sequence at the loaded window head.
|
||||
* @returns True when strict folding requires an earlier page.
|
||||
*/
|
||||
function replacementCrossesWindowHead(event: SessionEvent, baseSeq: number): boolean {
|
||||
if (!isSurfaceEvent(event) || event.surfaceOp === 'append') return false
|
||||
return event.surfaceOp.start < baseSeq || event.surfaceOp.end < baseSeq
|
||||
}
|
||||
|
||||
/** Minimal generation projection owned by the inspection adapter, not the core live surface. */
|
||||
interface FoldedContext {
|
||||
generation: number
|
||||
nodes: readonly number[]
|
||||
originSeq?: number
|
||||
}
|
||||
|
||||
interface AssistantStepMetadata {
|
||||
stepStartTime: number | null
|
||||
firstTokenTime: number | null
|
||||
}
|
||||
|
||||
function assistantStepKey(turn: number, step: number): string {
|
||||
return `${turn}\u0000${step}`
|
||||
}
|
||||
|
||||
/**
|
||||
* Replay surface replacements into frozen generations while keeping replacement
|
||||
* validation and mutation in the canonical core manager.
|
||||
*/
|
||||
function foldContexts(events: readonly SessionEvent[]): readonly FoldedContext[] {
|
||||
const replay: SessionEvent[] = []
|
||||
const surface = new SurfaceManager(replay)
|
||||
const contexts: FoldedContext[] = []
|
||||
let generation = 0
|
||||
let originSeq: number | undefined
|
||||
for (const event of events) {
|
||||
if (isSurfaceEvent(event) && event.surfaceOp !== 'append') {
|
||||
contexts.push({
|
||||
generation,
|
||||
nodes: [...surface.nodes],
|
||||
...(originSeq === undefined ? {} : { originSeq }),
|
||||
})
|
||||
generation++
|
||||
originSeq = event.seq
|
||||
}
|
||||
replay.push(event)
|
||||
}
|
||||
contexts.push({
|
||||
generation,
|
||||
nodes: [...surface.nodes],
|
||||
...(originSeq === undefined ? {} : { originSeq }),
|
||||
})
|
||||
return contexts
|
||||
}
|
||||
|
||||
/** One event -> UI node (pure function; the six-variant ConversationNode union). */
|
||||
function materializeNode(
|
||||
event: SessionEvent,
|
||||
callIndex: ReadonlyMap<string, CallIndexEntry>,
|
||||
resultView: ToolResultView | null,
|
||||
assistantTiming?: AssistantTiming,
|
||||
requestConfig?: AssistantRequestConfig,
|
||||
): ConversationNode {
|
||||
switch (event.type) {
|
||||
case 'user/message':
|
||||
@@ -155,12 +58,6 @@ function materializeNode(
|
||||
kind: 'assistant', seq: event.seq, time: event.time,
|
||||
turn: event.data.turn, step: event.data.step,
|
||||
blocks: toAssistantBlocks(event.data.message.content), usage: event.data.usage,
|
||||
provenance: {
|
||||
provider: event.data.message.source.provider,
|
||||
model: event.data.message.source.model,
|
||||
},
|
||||
...(requestConfig === undefined ? {} : { requestConfig }),
|
||||
...(assistantTiming !== undefined ? { timing: assistantTiming } : {}),
|
||||
}
|
||||
case 'steering/message':
|
||||
return {
|
||||
@@ -194,7 +91,7 @@ function materializeNode(
|
||||
}
|
||||
}
|
||||
|
||||
/** Window fold over the core SurfaceManager with a lenient partial-history fallback. */
|
||||
/** Window fold over the core SurfaceManager (sentinel padding for the seq offset; degrades to a linear scan on cross-window replace). */
|
||||
export class FoldAdapter {
|
||||
/** padded = [sentinel x baseSeq, ...window events]; SurfaceManager borrows this reference for lazy incremental folding. */
|
||||
private padded: SessionEvent[] = []
|
||||
@@ -219,23 +116,6 @@ 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
|
||||
private contextGeneration = 0
|
||||
private activePrompt: ConversationPromptSnapshot | undefined
|
||||
private promptsByContext = new Map<number, ConversationPromptSnapshot>()
|
||||
private assistantSteps = new Map<string, AssistantStepMetadata>()
|
||||
private assistantTimings = new Map<number, AssistantTiming>()
|
||||
private activeRequestConfig: AssistantRequestConfig | undefined
|
||||
private assistantRequestConfigs = new Map<number, AssistantRequestConfig>()
|
||||
|
||||
/**
|
||||
* @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> {
|
||||
@@ -251,31 +131,21 @@ export class FoldAdapter {
|
||||
*/
|
||||
reset(events: readonly SessionEvent[], baseSeq: number, views?: readonly (ToolEventView | undefined)[]): void {
|
||||
this.rev++
|
||||
if (this.projectContexts) this.contextRev++
|
||||
this.baseSeq = baseSeq
|
||||
this.padded = []
|
||||
for (let i = 0; i < baseSeq; i++) this.padded.push(paddingEvent(i))
|
||||
for (const event of events) this.padded.push(event)
|
||||
this.surface = new SurfaceManager(this.padded)
|
||||
this.nodeCache.clear()
|
||||
this.degraded = events.some(event => replacementCrossesWindowHead(event, baseSeq))
|
||||
this.degraded = false
|
||||
this.callIdx = new Map()
|
||||
this.resultViews.clear()
|
||||
this.contextGeneration = 0
|
||||
this.activePrompt = undefined
|
||||
this.promptsByContext = new Map()
|
||||
this.assistantSteps = new Map()
|
||||
this.assistantTimings = new Map()
|
||||
this.activeRequestConfig = undefined
|
||||
this.assistantRequestConfigs = new Map()
|
||||
this.commandIdx = new Map()
|
||||
for (let i = 0; i < events.length; i++) {
|
||||
const event = events[i]
|
||||
/* 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])
|
||||
if (this.projectContexts) this.indexContextPrompt(event)
|
||||
this.indexAssistantMetadata(event)
|
||||
this.indexCommand(event)
|
||||
}
|
||||
}
|
||||
@@ -289,14 +159,8 @@ export class FoldAdapter {
|
||||
*/
|
||||
append(event: SessionEvent, view?: ToolEventView): void {
|
||||
this.rev++
|
||||
if (this.projectContexts && (isSurfaceEvent(event) || event.type === 'request/header')) {
|
||||
this.contextRev++
|
||||
}
|
||||
if (replacementCrossesWindowHead(event, this.baseSeq)) this.degraded = true
|
||||
this.padded.push(event)
|
||||
this.indexCall(event, view)
|
||||
if (this.projectContexts) this.indexContextPrompt(event)
|
||||
this.indexAssistantMetadata(event)
|
||||
this.indexCommand(event)
|
||||
}
|
||||
|
||||
@@ -321,9 +185,17 @@ export class FoldAdapter {
|
||||
}
|
||||
const out: ConversationNode[] = []
|
||||
for (const seq of seqs) {
|
||||
const node = this.materialize(seq)
|
||||
/* v8 ignore next -- both seq sources only emit indexes present in padded. */
|
||||
if (node !== undefined) out.push(node)
|
||||
const cached = this.nodeCache.get(seq)
|
||||
if (cached !== undefined) {
|
||||
out.push(cached)
|
||||
continue
|
||||
}
|
||||
const event = this.padded[seq]
|
||||
/* v8 ignore next -- sparse guard: both seq sources (surface fold and degradedSeqs) only emit indexes present in padded. */
|
||||
if (event === undefined) continue
|
||||
const node = materializeNode(event, this.callIdx, this.resultViews.get(seq) ?? null)
|
||||
this.nodeCache.set(seq, node)
|
||||
out.push(node)
|
||||
}
|
||||
// Command nodes fold outside the surface (log-only events); merge by seq.
|
||||
// Both inputs are seq-ascending (surface order and run-index insertion
|
||||
@@ -346,75 +218,6 @@ 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
|
||||
}
|
||||
const current = this.nodes()
|
||||
if (current.degraded) {
|
||||
const value: readonly ConversationContext[] = [{
|
||||
id: 0,
|
||||
...(this.activePrompt === undefined ? {} : { prompt: this.activePrompt }),
|
||||
nodes: current.nodes,
|
||||
}]
|
||||
this.contextsResult = { rev: this.contextRev, value }
|
||||
return value
|
||||
}
|
||||
const value = foldContexts(this.padded).map((context): ConversationContext => {
|
||||
const nodes: ConversationNode[] = []
|
||||
for (const seq of context.nodes) {
|
||||
const node = this.materialize(seq)
|
||||
if (node !== undefined) nodes.push(node)
|
||||
}
|
||||
const prompt = this.promptsByContext.get(context.generation)
|
||||
if (context.originSeq === undefined) {
|
||||
return {
|
||||
id: context.generation,
|
||||
...(prompt === undefined ? {} : { prompt }),
|
||||
nodes,
|
||||
}
|
||||
}
|
||||
const originEvent = this.padded[context.originSeq]
|
||||
return {
|
||||
id: context.generation,
|
||||
parentId: context.generation - 1,
|
||||
origin: contextOriginKind(originEvent),
|
||||
originSeq: context.originSeq,
|
||||
...(originEvent === undefined ? {} : { createdAt: originEvent.time }),
|
||||
...(prompt === undefined ? {} : { prompt }),
|
||||
nodes,
|
||||
}
|
||||
})
|
||||
this.contextsResult = { rev: this.contextRev, value }
|
||||
return value
|
||||
}
|
||||
|
||||
/** Degradation branch: lenient linear scan ignoring surfaceOp/replace (all surface-eligible events in append order). */
|
||||
private degradedSeqs(): number[] {
|
||||
const seqs: number[] = []
|
||||
@@ -425,22 +228,6 @@ export class FoldAdapter {
|
||||
return seqs
|
||||
}
|
||||
|
||||
private materialize(seq: number): ConversationNode | undefined {
|
||||
const cached = this.nodeCache.get(seq)
|
||||
if (cached !== undefined) return cached
|
||||
const event = this.padded[seq]
|
||||
if (event === undefined) return
|
||||
const node = materializeNode(
|
||||
event,
|
||||
this.callIdx,
|
||||
this.resultViews.get(seq) ?? null,
|
||||
this.assistantTimings.get(seq),
|
||||
this.assistantRequestConfigs.get(seq),
|
||||
)
|
||||
this.nodeCache.set(seq, node)
|
||||
return node
|
||||
}
|
||||
|
||||
/** Fold one command lifecycle event into its node (run mints, done settles in place; done-only soft-falls). */
|
||||
private indexCommand(event: SessionEvent): void {
|
||||
// Log-only plugin events: the host-side dsh-commands declaration cannot
|
||||
@@ -485,86 +272,4 @@ export class FoldAdapter {
|
||||
// No backfill into already-materialized tool-result nodes for this callId
|
||||
// (window order puts the call before its result; cannot happen on the normal path).
|
||||
}
|
||||
|
||||
private indexAssistantMetadata(event: SessionEvent): void {
|
||||
if (event.type === 'request/header') {
|
||||
this.activeRequestConfig = event.data.header.config
|
||||
return
|
||||
}
|
||||
if (event.type === 'step/start') {
|
||||
this.assistantSteps.set(
|
||||
assistantStepKey(event.data.turn, event.data.step),
|
||||
{ stepStartTime: event.time, firstTokenTime: null },
|
||||
)
|
||||
return
|
||||
}
|
||||
if (event.type === 'assistant/chunk') {
|
||||
if (!isTokenDelta(event.data.chunk)) return
|
||||
const key = assistantStepKey(event.data.turn, event.data.step)
|
||||
const current = this.assistantSteps.get(key) ?? {
|
||||
stepStartTime: null,
|
||||
firstTokenTime: null,
|
||||
}
|
||||
if (current.firstTokenTime === null) {
|
||||
this.assistantSteps.set(key, {
|
||||
...current,
|
||||
firstTokenTime: event.time,
|
||||
})
|
||||
}
|
||||
return
|
||||
}
|
||||
if (event.type !== 'assistant/message') return
|
||||
const timing = this.assistantSteps.get(
|
||||
assistantStepKey(event.data.turn, event.data.step),
|
||||
) ?? { stepStartTime: null, firstTokenTime: null }
|
||||
this.assistantTimings.set(event.seq, {
|
||||
...timing,
|
||||
completedTime: event.time,
|
||||
})
|
||||
if (this.activeRequestConfig !== undefined) {
|
||||
this.assistantRequestConfigs.set(event.seq, this.activeRequestConfig)
|
||||
}
|
||||
}
|
||||
|
||||
private indexContextPrompt(event: SessionEvent): void {
|
||||
if (isSurfaceEvent(event) && event.surfaceOp !== 'append') {
|
||||
this.contextGeneration++
|
||||
if (this.activePrompt !== undefined) {
|
||||
this.promptsByContext.set(this.contextGeneration, this.activePrompt)
|
||||
}
|
||||
}
|
||||
if (event.type !== 'request/header') return
|
||||
this.activePrompt = {
|
||||
config: event.data.header.config,
|
||||
system: event.data.header.system ?? '',
|
||||
tools: event.data.header.tools ?? [],
|
||||
}
|
||||
this.promptsByContext.set(this.contextGeneration, this.activePrompt)
|
||||
}
|
||||
}
|
||||
|
||||
function contextOriginKind(event: SessionEvent | undefined): ConversationContextOriginKind {
|
||||
if (event?.type !== 'user/message') return 'rewrite'
|
||||
const source = event.data.source
|
||||
if (
|
||||
typeof source === 'object'
|
||||
&& 'kind' in source
|
||||
&& 'plugin' in source
|
||||
) {
|
||||
if (source.plugin === 'compact') return 'compaction'
|
||||
if (source.plugin === 'rewind') return 'rewind'
|
||||
}
|
||||
return 'rewrite'
|
||||
}
|
||||
|
||||
function isTokenDelta(chunk: SessionEvent<'assistant/chunk'>['data']['chunk']): boolean {
|
||||
switch (chunk.type) {
|
||||
case 'text-delta':
|
||||
case 'reasoning-delta':
|
||||
return chunk.text !== ''
|
||||
case 'tool-call-delta':
|
||||
return chunk.argumentsDelta !== '' || chunk.name !== undefined
|
||||
default:
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,8 +1,10 @@
|
||||
import type { ToolSchema } from '@deepseek-ai/dsh-llm/types'
|
||||
import type { HistoryEntry } from '@deepseek-ai/dsh-client-connection/client'
|
||||
import type { ConversationNode } from './conversation.ts'
|
||||
import type {
|
||||
CodeSubCall, ConversationNode, PartialAssistant, RunningToolCall,
|
||||
} from './conversation.ts'
|
||||
import type { ConversationContext } from './conversation-context.ts'
|
||||
import { projectConversationHistory } from './fold-adapter.ts'
|
||||
import { projectConversationHistory } from '../session-history/history-fold.ts'
|
||||
import { inspectRequests, type RequestView } from './request-inspection.ts'
|
||||
|
||||
/** Lazily derived inspection data for one immutable session-history window. */
|
||||
@@ -11,6 +13,10 @@ export interface SessionHistoryInspection {
|
||||
contexts: readonly ConversationContext[]
|
||||
requests: readonly RequestView[]
|
||||
callSchemas: ReadonlyMap<string, ToolSchema>
|
||||
interruptedNodes: readonly ConversationNode[]
|
||||
partial: PartialAssistant | null
|
||||
runningCalls: readonly RunningToolCall[]
|
||||
codeDispatches: ReadonlyMap<string, readonly CodeSubCall[]>
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -38,6 +44,18 @@ export function createHistoryInspection(
|
||||
get contexts() {
|
||||
return conversationProjection().contexts
|
||||
},
|
||||
get interruptedNodes() {
|
||||
return conversationProjection().interruptedNodes
|
||||
},
|
||||
get partial() {
|
||||
return conversationProjection().partial
|
||||
},
|
||||
get runningCalls() {
|
||||
return conversationProjection().runningCalls
|
||||
},
|
||||
get codeDispatches() {
|
||||
return conversationProjection().codeDispatches
|
||||
},
|
||||
get requests() {
|
||||
return requestProjection().requests
|
||||
},
|
||||
|
||||
@@ -12,12 +12,9 @@ import type {
|
||||
import { transportError } from '@deepseek-ai/dsh-host-apiproxy/api'
|
||||
import type { SessionFace } from '../contract/session.ts'
|
||||
import type {
|
||||
CodeSubCall, ComposerPhase, ConversationNode, ConversationSnapshot,
|
||||
OpenState, PromptError, QueuedMessage, RunningToolCall,
|
||||
CodeSubCall, ComposerPhase, ConversationNode, ConversationSnapshot, OpenState,
|
||||
PromptError, QueuedMessage, RunningToolCall,
|
||||
} from './conversation.ts'
|
||||
import {
|
||||
createHistoryInspection, type SessionHistoryInspection,
|
||||
} from './history.ts'
|
||||
import type { PendingInteraction } from './pending.ts'
|
||||
import { PendingWait } from './pending.ts'
|
||||
import { FoldAdapter } from './fold-adapter.ts'
|
||||
@@ -51,10 +48,6 @@ export interface SessionOptions {
|
||||
/** Queue-row preview cap: the dock renders one line, the full content never leaves the host mirror. */
|
||||
const QUEUE_PREVIEW_CHARS = 200
|
||||
|
||||
function isAborted(signal: AbortSignal | undefined): boolean {
|
||||
return signal?.aborted === true
|
||||
}
|
||||
|
||||
/** Internal inbox-mirror entry: the snapshot row plus the retirement-matching fields the frames carry. */
|
||||
interface QueuedEntry {
|
||||
row: QueuedMessage
|
||||
@@ -73,11 +66,10 @@ function queuePreviewOf(content: readonly ContentBlock[]): string {
|
||||
}
|
||||
|
||||
/**
|
||||
* Owns a session's event window, folded conversation, raw history inspection,
|
||||
* and observable snapshot. React bindings remain outside this data layer.
|
||||
* Features see only the {@link SessionFace} slice (ISession verbs + the
|
||||
* snapshot source); the remaining public members are manager/runtime entry
|
||||
* points.
|
||||
* Owns a session's event window, derived conversation state, and observable
|
||||
* snapshot. React bindings remain outside this data layer. Features see only
|
||||
* the {@link SessionFace} slice (ISession verbs + the snapshot source); the
|
||||
* remaining public members are manager/runtime entry points.
|
||||
*/
|
||||
export class Session implements SessionFace {
|
||||
// ---- Window and derived state (all private; the snapshot is the only read surface) ----
|
||||
@@ -122,13 +114,6 @@ export class Session implements SessionFace {
|
||||
private codeDispatches = new Map<string, readonly CodeSubCall[]>()
|
||||
private dispatchesRev = 0
|
||||
private dispatchesCache: { rev: number; value: ReadonlyMap<string, readonly CodeSubCall[]> } | null = null
|
||||
/** Raw history revision; inspection wrappers capture the exact array window and length. */
|
||||
private historyRev = 0
|
||||
private historyInspectionCache: {
|
||||
rev: number
|
||||
value: SessionHistoryInspection
|
||||
} | null = null
|
||||
private loadOlderPromise: Promise<void> | null = null
|
||||
private running = false
|
||||
/**
|
||||
* Sticky send marker, private input of the composerPhase derivation: set
|
||||
@@ -294,75 +279,40 @@ export class Session implements SessionFace {
|
||||
return promise
|
||||
}
|
||||
|
||||
/**
|
||||
* Page up: pull one earlier page with the window's first seq as beforeSeq and prepend (§D.2).
|
||||
* Concurrent callers share the active page so complete-history readers can continue afterward.
|
||||
* @returns When the active or newly started page request settles.
|
||||
*/
|
||||
loadOlder(): Promise<void> {
|
||||
if (this.loadOlderPromise !== null) return this.loadOlderPromise
|
||||
if (this.openState !== 'open' || !this.hasMore) return Promise.resolve()
|
||||
/** Page up: pull one earlier page with the window's first seq as beforeSeq and prepend (§D.2). */
|
||||
async loadOlder(): Promise<void> {
|
||||
if (this.openState !== 'open' || !this.hasMore || this.loadingOlder) return
|
||||
this.loadingOlder = true
|
||||
this.notifier.markDirty()
|
||||
const generation = this.openGeneration
|
||||
const operation = (async () => {
|
||||
try {
|
||||
const { result } = await this.api.sessions.history({
|
||||
sessionId: this.sessionId, beforeSeq: this.baseSeq, maxMessages: PAGE_MESSAGES,
|
||||
})
|
||||
if (generation !== this.openGeneration || this.openState !== 'open') return
|
||||
if (!result.ok) return // keep the window as-is; do not overwrite openError (open already succeeded)
|
||||
const older = result.value.events
|
||||
if (older.length === 0) {
|
||||
this.hasMore = result.value.hasMore
|
||||
return
|
||||
}
|
||||
const tail = older[older.length - 1]
|
||||
if (tail === undefined || tail.event.seq + 1 !== this.baseSeq) {
|
||||
// §D.2 continuity assertion: on violation drop the page fail-soft rather than render an out-of-order stream.
|
||||
console.error(`[web-runtime] history page discontinuous: tail seq ${tail?.event.seq} vs baseSeq ${this.baseSeq}`)
|
||||
this.hasMore = false
|
||||
return
|
||||
}
|
||||
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
|
||||
try {
|
||||
const { result } = await this.api.sessions.history({
|
||||
sessionId: this.sessionId, beforeSeq: this.baseSeq, maxMessages: PAGE_MESSAGES,
|
||||
})
|
||||
if (!result.ok) return // keep the window as-is; do not overwrite openError (open already succeeded)
|
||||
const older = result.value.events
|
||||
if (older.length === 0) {
|
||||
this.hasMore = result.value.hasMore
|
||||
this.foldAdapter.reset(this.events, this.baseSeq, this.views) // prepend forces a rebuild (sentinel count changed)
|
||||
this.rebuildDerivedFromWindow()
|
||||
} catch (error) {
|
||||
console.error('[web-runtime] loadOlder failed:', error)
|
||||
return
|
||||
}
|
||||
})()
|
||||
const settled = operation.finally(() => {
|
||||
if (this.loadOlderPromise !== settled) return
|
||||
this.loadOlderPromise = null
|
||||
const tail = older[older.length - 1]
|
||||
if (tail === undefined || tail.event.seq + 1 !== this.baseSeq) {
|
||||
// §D.2 continuity assertion: on violation drop the page fail-soft rather than render an out-of-order stream.
|
||||
console.error(`[web-runtime] history page discontinuous: tail seq ${tail?.event.seq} vs baseSeq ${this.baseSeq}`)
|
||||
this.hasMore = false
|
||||
return
|
||||
}
|
||||
this.events = [...older.map(e => e.event), ...this.events]
|
||||
this.views = [...older.map(e => e.view), ...this.views]
|
||||
/* 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
|
||||
this.foldAdapter.reset(this.events, this.baseSeq, this.views) // prepend forces a rebuild (sentinel count changed)
|
||||
this.rebuildDerivedFromWindow()
|
||||
} catch (error) {
|
||||
console.error('[web-runtime] loadOlder failed:', error)
|
||||
} finally {
|
||||
this.loadingOlder = false
|
||||
this.notifier.markDirty()
|
||||
})
|
||||
this.loadOlderPromise = settled
|
||||
return settled
|
||||
}
|
||||
|
||||
/**
|
||||
* 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, and observes
|
||||
* cancellation between pages without abandoning an active unary request.
|
||||
* @param signal - Mounted consumer lifetime; abort stops before the next page.
|
||||
* @returns When the available history has been exhausted or paging stops making progress.
|
||||
*/
|
||||
async loadAllHistory(signal?: AbortSignal): Promise<void> {
|
||||
while (
|
||||
!isAborted(signal)
|
||||
&& this.openState === 'open'
|
||||
&& this.hasMore
|
||||
) {
|
||||
const previousBaseSeq = this.baseSeq
|
||||
await this.loadOlder()
|
||||
if (isAborted(signal) || this.baseSeq === previousBaseSeq) return
|
||||
}
|
||||
}
|
||||
|
||||
@@ -379,13 +329,10 @@ export class Session implements SessionFace {
|
||||
if (this.openState === 'cold') return // never opened: no window to rebuild (doOpen flips to 'loading' synchronously, so cold implies no in-flight open)
|
||||
this.openGeneration++
|
||||
this.openPromise = null
|
||||
this.loadOlderPromise = null
|
||||
this.loadingOlder = false
|
||||
this.openState = 'cold'
|
||||
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.
|
||||
@@ -604,7 +551,6 @@ export class Session implements SessionFace {
|
||||
private installWindow(entries: HistoryEntry[], hasMore: boolean, projections?: ProjectionsBaseline): 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
|
||||
this.foldAdapter.reset(this.events, this.baseSeq, this.views)
|
||||
@@ -622,7 +568,6 @@ export class Session implements SessionFace {
|
||||
if (tailSeq !== null && event.seq <= tailSeq) return // replay overlap, drop
|
||||
this.events.push(event)
|
||||
this.views.push(view)
|
||||
if (event.type !== 'assistant/chunk') this.historyRev++
|
||||
this.foldAdapter.append(event, view)
|
||||
this.applyEventSideEffects(event, view)
|
||||
}
|
||||
@@ -874,7 +819,6 @@ export class Session implements SessionFace {
|
||||
return {
|
||||
sessionId: this.sessionId,
|
||||
nodes,
|
||||
inspection: this.buildHistoryInspection(),
|
||||
foldDegraded: degraded,
|
||||
partial,
|
||||
runningCalls: this.callsCache.value,
|
||||
@@ -899,32 +843,6 @@ export class Session implements SessionFace {
|
||||
lastAgentError: this.lastAgentError,
|
||||
}
|
||||
}
|
||||
|
||||
/** Build a lazy inspection wrapper for the exact current history window. */
|
||||
private buildHistoryInspection(): SessionHistoryInspection {
|
||||
if (
|
||||
this.historyInspectionCache === null
|
||||
|| this.historyInspectionCache.rev !== this.historyRev
|
||||
) {
|
||||
const events = this.events
|
||||
const views = this.views
|
||||
const length = events.length
|
||||
this.historyInspectionCache = {
|
||||
rev: this.historyRev,
|
||||
value: createHistoryInspection(() =>
|
||||
Array.from({ length }, (_, index) => {
|
||||
const event = events[index]
|
||||
if (event === undefined) {
|
||||
throw new Error('captured history window changed before inspection')
|
||||
}
|
||||
const view = views[index]
|
||||
return view === undefined ? { event } : { event, view }
|
||||
}),
|
||||
),
|
||||
}
|
||||
}
|
||||
return this.historyInspectionCache.value
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -7,9 +7,8 @@ import { createUserMessage, CallId, createMessage, createToolResultMessage } fro
|
||||
|
||||
import { describe, expect, it, vi } from 'vitest'
|
||||
import type { SessionEvent } from '@deepseek-ai/dsh-session/types'
|
||||
import {
|
||||
FoldAdapter, projectConversationHistory,
|
||||
} from '../src/client/sessions/fold-adapter.ts'
|
||||
import { FoldAdapter } from '../src/client/sessions/fold-adapter.ts'
|
||||
import { projectConversationHistory } from '../src/client/session-history/history-fold.ts'
|
||||
import { ev, plainTurn } from './event-script.ts'
|
||||
|
||||
const at = (seq: number, e: Record<string, unknown>): SessionEvent =>
|
||||
|
||||
98
packages/client/runtime/tests/session-history-source.spec.ts
Normal file
98
packages/client/runtime/tests/session-history-source.spec.ts
Normal file
@@ -0,0 +1,98 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import type { SessionEvent } from '@deepseek-ai/dsh-session/types'
|
||||
import type { SessionId } from '@deepseek-ai/dsh-client-connection/client'
|
||||
import { SessionHistorySource } from '../src/client/session-history/source.ts'
|
||||
import { FakeApiClient, deferred, err, ok } from './fake-api.ts'
|
||||
import { entries, ev, plainTurn } from './event-script.ts'
|
||||
|
||||
const SID = 'history-s1' as SessionId
|
||||
|
||||
function histResponse(events: SessionEvent[], hasMore = false) {
|
||||
return Promise.resolve(ok({ events: entries(events) as never[], hasMore }))
|
||||
}
|
||||
|
||||
describe('SessionHistorySource', () => {
|
||||
it('loads every older page without changing a Chat session', async () => {
|
||||
const pages = [
|
||||
plainTurn(0, 0, '最早问', '最早答'),
|
||||
plainTurn(6, 1, '中间问', '中间答'),
|
||||
plainTurn(12, 2, '最新问', '最新答'),
|
||||
]
|
||||
const api = new FakeApiClient()
|
||||
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)
|
||||
}
|
||||
const source = new SessionHistorySource(SID, api)
|
||||
|
||||
await source.loadAll()
|
||||
|
||||
expect(api.callsOf('session.history')).toHaveLength(3)
|
||||
expect(source.getSnapshot().hasMore).toBe(false)
|
||||
expect(source.getSnapshot().inspection.eventNodes.map(node => node.seq))
|
||||
.toEqual([1, 3, 7, 9, 13, 15])
|
||||
})
|
||||
|
||||
it('pins a lazy inspection to the entries in its source snapshot', async () => {
|
||||
const api = new FakeApiClient()
|
||||
api.onHistory = () => histResponse(plainTurn(0, 0, '问', '答'))
|
||||
const source = new SessionHistorySource(SID, api)
|
||||
await source.loadAll()
|
||||
const before = source.getSnapshot()
|
||||
|
||||
source.handleMuxFrame({
|
||||
type: 'session/event',
|
||||
sessionId: SID,
|
||||
event: ev.user(6, 'later'),
|
||||
})
|
||||
|
||||
expect(before.inspection.eventNodes.map(node => node.seq)).toEqual([1, 3])
|
||||
expect(source.getSnapshot().inspection.eventNodes.map(node => node.seq))
|
||||
.toEqual([1, 3, 6])
|
||||
})
|
||||
|
||||
it('stops loading when an older page fails to advance', async () => {
|
||||
const api = new FakeApiClient()
|
||||
api.onHistory = payload => payload.beforeSeq === undefined
|
||||
? histResponse(plainTurn(6, 1, '新问', '新答'), true)
|
||||
: Promise.resolve(err({
|
||||
code: 'internal',
|
||||
message: 'page unavailable',
|
||||
details: {},
|
||||
}))
|
||||
const source = new SessionHistorySource(SID, api)
|
||||
|
||||
await source.loadAll()
|
||||
|
||||
expect(api.callsOf('session.history')).toHaveLength(2)
|
||||
expect(source.getSnapshot().hasMore).toBe(true)
|
||||
})
|
||||
|
||||
it('observes consumer cancellation between older pages', async () => {
|
||||
const middle = deferred<Awaited<ReturnType<FakeApiClient['onHistory']>>>()
|
||||
const olderStarted = deferred<void>()
|
||||
const api = new FakeApiClient()
|
||||
api.onHistory = (payload) => {
|
||||
if (payload.beforeSeq === undefined) {
|
||||
return histResponse(plainTurn(12, 2, '最新问', '最新答'), true)
|
||||
}
|
||||
olderStarted.resolve()
|
||||
return middle.promise
|
||||
}
|
||||
const source = new SessionHistorySource(SID, api)
|
||||
const controller = new AbortController()
|
||||
const complete = source.loadAll(controller.signal)
|
||||
await olderStarted.promise
|
||||
controller.abort()
|
||||
middle.resolve(ok({
|
||||
events: entries(plainTurn(6, 1, '中间问', '中间答')) as never[],
|
||||
hasMore: true,
|
||||
}))
|
||||
|
||||
await complete
|
||||
|
||||
expect(api.callsOf('session.history')).toHaveLength(2)
|
||||
expect(source.getSnapshot().hasMore).toBe(true)
|
||||
})
|
||||
})
|
||||
@@ -161,21 +161,6 @@ describe('live event path', () => {
|
||||
expect((last as { interrupted?: true }).interrupted).toBeUndefined()
|
||||
})
|
||||
|
||||
it('keeps a lazily inspected snapshot pinned to its original history window', async () => {
|
||||
const { session } = await opened()
|
||||
const before = session.getSnapshot()
|
||||
|
||||
session.handleMuxEnvelope('r' as never, {
|
||||
type: 'session/event',
|
||||
sessionId: SID,
|
||||
event: ev.user(6, 'later'),
|
||||
})
|
||||
|
||||
expect(before.inspection?.eventNodes.map(node => node.seq)).toEqual([1, 3])
|
||||
expect(session.getSnapshot().inspection?.eventNodes.map(node => node.seq))
|
||||
.toEqual([1, 3, 6])
|
||||
})
|
||||
|
||||
it('freezes an unfinalized partial into an interrupted node on turn/end (cancel path)', async () => {
|
||||
const { session } = await opened()
|
||||
const feed = (event: SessionEvent) => { session.handleMuxEnvelope('r' as never, { type: 'session/event', sessionId: SID, event }) }
|
||||
@@ -241,92 +226,6 @@ 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.loadAllHistory()
|
||||
|
||||
expect(api.callsOf('session.history')).toHaveLength(3)
|
||||
expect(session.getSnapshot().hasMore).toBe(false)
|
||||
expect(session.getSnapshot().inspection?.eventNodes.map(node => node.seq))
|
||||
.toEqual([1, 3, 7, 9, 13, 15])
|
||||
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.loadAllHistory()
|
||||
|
||||
expect(api.callsOf('session.history')).toHaveLength(2)
|
||||
expect(session.getSnapshot().hasMore).toBe(true)
|
||||
})
|
||||
|
||||
it('continues complete-history loading after an already active page', async () => {
|
||||
const pages = [
|
||||
plainTurn(0, 0, '最早问', '最早答'),
|
||||
plainTurn(6, 1, '中间问', '中间答'),
|
||||
plainTurn(12, 2, '最新问', '最新答'),
|
||||
]
|
||||
const middle = deferred<Awaited<ReturnType<FakeApiClient['onHistory']>>>()
|
||||
const { api, session } = makeSession()
|
||||
api.onHistory = (payload) => {
|
||||
if (payload.beforeSeq === undefined) return histResponse(pages[2]!, true)
|
||||
if (payload.beforeSeq === 12) return middle.promise
|
||||
return histResponse(pages[0]!, false)
|
||||
}
|
||||
|
||||
await session.open()
|
||||
const activePage = session.loadOlder()
|
||||
const completeHistory = session.loadAllHistory()
|
||||
middle.resolve(ok({
|
||||
events: entries(pages[1]!) as never[],
|
||||
hasMore: true,
|
||||
modelTarget: { provider: 'deepseek', model: 'deepseek-v4-flash' },
|
||||
}))
|
||||
await Promise.all([activePage, completeHistory])
|
||||
|
||||
expect(api.callsOf('session.history')).toHaveLength(3)
|
||||
expect(session.getSnapshot().hasMore).toBe(false)
|
||||
expect(session.getSnapshot().nodes.map(node => node.seq))
|
||||
.toEqual([1, 3, 7, 9, 13, 15])
|
||||
})
|
||||
|
||||
it('stops complete-history loading between pages after its consumer aborts', async () => {
|
||||
const middle = deferred<Awaited<ReturnType<FakeApiClient['onHistory']>>>()
|
||||
const { api, session } = makeSession()
|
||||
api.onHistory = payload => payload.beforeSeq === undefined
|
||||
? histResponse(plainTurn(12, 2, '最新问', '最新答'), true)
|
||||
: middle.promise
|
||||
await session.open()
|
||||
const controller = new AbortController()
|
||||
const completeHistory = session.loadAllHistory(controller.signal)
|
||||
controller.abort()
|
||||
middle.resolve(ok({
|
||||
events: entries(plainTurn(6, 1, '中间问', '中间答')) as never[],
|
||||
hasMore: true,
|
||||
}))
|
||||
await completeHistory
|
||||
|
||||
expect(api.callsOf('session.history')).toHaveLength(2)
|
||||
expect(session.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
|
||||
@@ -803,31 +702,6 @@ describe('resync', () => {
|
||||
expect(snapshot.nodes.map(n => n.seq)).toEqual([7, 9])
|
||||
})
|
||||
|
||||
it('starts fresh paging while an older generation page is still pending', async () => {
|
||||
const stalePage = deferred<Awaited<ReturnType<FakeApiClient['onHistory']>>>()
|
||||
const { api, session } = makeSession()
|
||||
let call = 0
|
||||
api.onHistory = () => {
|
||||
call++
|
||||
if (call === 1) return histResponse(plainTurn(6, 1, '新', '页'), true)
|
||||
if (call === 2) return stalePage.promise
|
||||
if (call === 3) return histResponse(plainTurn(6, 1, '新', '代'), true)
|
||||
return histResponse(plainTurn(0, 0, '旧', '页'), false)
|
||||
}
|
||||
await session.open()
|
||||
const stale = session.loadOlder()
|
||||
await session.resync()
|
||||
const fresh = session.loadAllHistory()
|
||||
await vi.waitFor(() => { expect(call).toBe(4) })
|
||||
stalePage.resolve(ok({
|
||||
events: entries(plainTurn(0, 0, '废', '弃')) as never[],
|
||||
hasMore: false,
|
||||
}))
|
||||
await Promise.all([stale, fresh])
|
||||
|
||||
expect(session.getSnapshot().hasMore).toBe(false)
|
||||
expect(session.getSnapshot().nodes.map(node => node.seq)).toEqual([1, 3, 7, 9])
|
||||
})
|
||||
})
|
||||
|
||||
describe('run_code sub-dispatch indexing', () => {
|
||||
|
||||
@@ -108,13 +108,6 @@ export class FixtureSession implements SessionFace {
|
||||
throw new Error(`test session "${this.sessionId}": loadOlder is not stubbed — supply it on the fixture's session face`)
|
||||
}
|
||||
|
||||
/**
|
||||
* Fail-loud stub; supply `loadAllHistory` on the fixture's session face to exercise it.
|
||||
* @returns never — always throws.
|
||||
*/
|
||||
loadAllHistory(): never {
|
||||
throw new Error(`test session "${this.sessionId}": loadAllHistory is not stubbed — supply it on the fixture's session face`)
|
||||
}
|
||||
}
|
||||
|
||||
/** One live test session: fixture-derived stores plus its minted scope state. */
|
||||
|
||||
@@ -470,7 +470,6 @@ describe('fixture session face', () => {
|
||||
expect(() => bare.cancel()).toThrow(/cancel is not stubbed/)
|
||||
expect(() => bare.command()).toThrow(/command is not stubbed/)
|
||||
expect(() => bare.loadOlder()).toThrow(/loadOlder is not stubbed/)
|
||||
expect(() => bare.loadAllHistory()).toThrow(/loadAllHistory is not stubbed/)
|
||||
await runtime.dispose()
|
||||
})
|
||||
|
||||
|
||||
@@ -83,8 +83,8 @@ export function apply(ctx: Context): void {
|
||||
},
|
||||
}), 'ui-conversation: input standard-kit provider')
|
||||
|
||||
// Resident current-session-optional shell. It constructs the stable
|
||||
// composer frame; the strict session child decides whether Chat mounts it.
|
||||
// Resident current-session-optional shell. It owns the stable Hero/composer
|
||||
// frame while strict session slots fill only their session-bound regions.
|
||||
slots.register({
|
||||
name: 'conversation',
|
||||
children: {
|
||||
@@ -114,8 +114,8 @@ export function apply(ctx: Context): void {
|
||||
}),
|
||||
}, ConversationRoot)
|
||||
|
||||
// The strict session subtree owns the per-session store and view content;
|
||||
// the resident parent supplies the composer node through owner props.
|
||||
// The strict session subtree owns only per-session store and view content;
|
||||
// the resident parent keeps Hero and composer layout identity stable.
|
||||
slots.register({
|
||||
name: 'conversation.session',
|
||||
children: { 'conversation.view': { kind: 'list', scope: 'session' } },
|
||||
|
||||
@@ -19,7 +19,7 @@ declare module '@deepseek-ai/dsh-client-ui-slots' {
|
||||
'conversation.session': { kind: 'single'; scope: 'session'; owner: ConversationSessionOwnerProps }
|
||||
/**
|
||||
* The conversation view ring: one list entry per view tab (chat here;
|
||||
* Trajectory from ui-trajectory), rendered one-at-a-time by
|
||||
* trajectory/waterfall from ui-trajectory), rendered one-at-a-time by
|
||||
* ConversationRoot via `only: <active id>`. Declared by this package's
|
||||
* 'conversation' entry (declaring is claiming). Session scope: views read
|
||||
* the conversation snapshot through the standard kit.
|
||||
@@ -117,8 +117,6 @@ declare module '@deepseek-ai/dsh-client-ui-slots' {
|
||||
|
||||
/** Owner share of the strict session content seat. */
|
||||
export interface ConversationSessionOwnerProps {
|
||||
/** Composer chain assembled by the resident parent; only Chat mounts it. */
|
||||
composer: ReactNode
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -143,7 +141,7 @@ export interface ConvViewOwnerProps {}
|
||||
|
||||
/**
|
||||
* Owner share of a per-view toolview slot: the call material the rendering
|
||||
* view supplies per row. Uniform across views — the Trajectory
|
||||
* view supplies per row. Uniform across views — the trajectory/waterfall
|
||||
* toolview slots (same kind/scope/owner, names fixed by the slot-naming
|
||||
* discipline) land with their own row render sites; today only the chat slot
|
||||
* is declared (RendersCheck rejects a declaration nobody renders).
|
||||
|
||||
@@ -118,11 +118,6 @@ export function ConversationRoot({
|
||||
{inputBar}
|
||||
</div>
|
||||
)
|
||||
const composer = renderSlotChain(
|
||||
'conversation.composer',
|
||||
{ interactions: pending },
|
||||
{ fallback: composerBar, overlay: true },
|
||||
)
|
||||
|
||||
return (
|
||||
<div className={css.root} data-phase={settling ? 'settling' : hero ? 'hero' : 'active'}>
|
||||
@@ -130,9 +125,12 @@ export function ConversationRoot({
|
||||
renders no chrome while blank but owns the draft-persistence mirror
|
||||
bind — unmounting it in the hero would lose pre-first-send text on
|
||||
a refresh or scope rebuild. */}
|
||||
{sessionId !== undefined
|
||||
? renderSlot('conversation.session', { composer })
|
||||
: composer}
|
||||
{sessionId !== undefined && renderSlot('conversation.session', {})}
|
||||
{renderSlotChain(
|
||||
'conversation.composer',
|
||||
{ interactions: pending },
|
||||
{ fallback: composerBar, overlay: true },
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
/** Strict per-session conversation content: header, view ring, and chat store bindings. */
|
||||
|
||||
import { Fragment, useEffect, useSyncExternalStore } from 'react'
|
||||
import { useEffect, useSyncExternalStore } from 'react'
|
||||
import clsx from 'clsx'
|
||||
import { shallowEqual } from '@deepseek-ai/dsh-client-runtime/client'
|
||||
import type { SessionId, SessionListState, SessionSummary } from '@deepseek-ai/dsh-client-runtime/client'
|
||||
@@ -24,7 +24,7 @@ function deriveAncestry(list: SessionListState, id: SessionId): readonly Session
|
||||
|
||||
export function ConversationSession({
|
||||
sessionId, useSession, useSessions, useInput, inputActions, useStore, actions,
|
||||
renderSlot, views, bindDraftMirror, open, composer,
|
||||
renderSlot, views, bindDraftMirror, open,
|
||||
}: ConversationSessionProps) {
|
||||
useSyncExternalStore(views.subscribe, views.version)
|
||||
const tabs = views.list()
|
||||
@@ -32,7 +32,6 @@ export function ConversationSession({
|
||||
const active = tabs.find(view => view.id === activeId) ?? tabs[0]
|
||||
const ancestry = useSessions(s => deriveAncestry(s, sessionId), shallowEqual)
|
||||
const composerPhase = useSession(s => s.composerPhase)
|
||||
const hasPending = useSession(s => s.pending.length > 0)
|
||||
const blank = useSession(s => s.blank)
|
||||
const inputState = useInput(s => s)
|
||||
const storedDraft = useStore(s => s.draft)
|
||||
@@ -45,11 +44,11 @@ export function ConversationSession({
|
||||
// the machine mirror, not this seed effect.
|
||||
}, [inputActions])
|
||||
|
||||
const blankHero = blank && composerPhase === 'blank'
|
||||
if (blank && composerPhase === 'blank') return null
|
||||
|
||||
return (
|
||||
<>
|
||||
{!blankHero && <header className={css.header}>
|
||||
<header className={css.header}>
|
||||
<div className={css.crumbRow}>
|
||||
<nav className={css.crumbs} aria-label="Session hierarchy">
|
||||
{ancestry.map((summary, index) => {
|
||||
@@ -87,13 +86,10 @@ export function ConversationSession({
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</header>}
|
||||
{!blankHero && <div className={css.viewArea}>
|
||||
</header>
|
||||
<div className={css.viewArea}>
|
||||
{active !== undefined && renderSlot('conversation.view', {}, { only: active.id })}
|
||||
</div>}
|
||||
{(blankHero || active?.id === 'chat' || hasPending) && (
|
||||
<Fragment key="composer">{composer}</Fragment>
|
||||
)}
|
||||
</div>
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -34,7 +34,6 @@ function sessionFakeFor() {
|
||||
return {
|
||||
open: vi.fn(() => Promise.resolve()),
|
||||
loadOlder: vi.fn<ISession['loadOlder']>(() => Promise.resolve()),
|
||||
loadAllHistory: vi.fn<ISession['loadAllHistory']>(() => Promise.resolve()),
|
||||
prompt: vi.fn<ISession['prompt']>(() => Promise.resolve({ ok: true, value: { accepted: true } })),
|
||||
cancel: vi.fn<ISession['cancel']>(() => Promise.resolve({ ok: true, value: { accepted: true } })),
|
||||
} satisfies SessionBehaviorOverrides
|
||||
|
||||
@@ -59,7 +59,6 @@ async function bench(nodes: ToolResultNode[]) {
|
||||
snapshot: { nodes },
|
||||
session: {
|
||||
loadOlder: vi.fn<ISession['loadOlder']>(),
|
||||
loadAllHistory: vi.fn<ISession['loadAllHistory']>(),
|
||||
prompt: vi.fn<ISession['prompt']>(async () => ({ ok: true, value: { accepted: true } })),
|
||||
},
|
||||
})
|
||||
|
||||
@@ -18,7 +18,7 @@ import { ConversationSession } from '../src/client/skeleton/ConversationSession.
|
||||
import { InputBar } from '../src/client/skeleton/InputBar.tsx'
|
||||
import type { InputBarProps } from '../src/client/skeleton/InputBar.tsx'
|
||||
import type {
|
||||
ComposerBarOwnerProps, ConversationSessionOwnerProps,
|
||||
ComposerBarOwnerProps,
|
||||
} from '../src/client/contract/slots.ts'
|
||||
|
||||
/** Machine-backed wiring over a sink spy. */
|
||||
@@ -88,10 +88,8 @@ function mount(
|
||||
slotCalls.push(key)
|
||||
if (key === 'conversation.hero.workspace') { pickerOwner = owner; return null }
|
||||
if (key === 'conversation.session') {
|
||||
const sessionOwner = owner as ConversationSessionOwnerProps
|
||||
return (
|
||||
<ConversationSession
|
||||
composer={sessionOwner.composer}
|
||||
sessionId={SID}
|
||||
SessionProvider={({ children }) => children(SID)}
|
||||
useSession={useSession}
|
||||
|
||||
@@ -49,15 +49,17 @@
|
||||
}
|
||||
|
||||
/* Drag handles are frame children (columns clip overflow): an 8px hit strip
|
||||
centered on the column border via inline left, above column content. */
|
||||
centered on the column border via inline left, above column content. The
|
||||
visible pill (12x32 r10, riding the border at vertical center) is the figma
|
||||
Handle component; the hit strip stays wider than the pill. */
|
||||
.handle {
|
||||
position: absolute;
|
||||
z-index: 6;
|
||||
top: 0;
|
||||
bottom: 0;
|
||||
width: 8px;
|
||||
margin-left: -4px;
|
||||
cursor: col-resize;
|
||||
z-index: 2;
|
||||
touch-action: none;
|
||||
/* Rides the same curve as the tracks so the pill stays on the moving
|
||||
border during collapse/expand; paused while dragging (frame rule). */
|
||||
@@ -73,3 +75,36 @@
|
||||
transition: none;
|
||||
}
|
||||
}
|
||||
|
||||
.handle::after {
|
||||
content: '';
|
||||
position: absolute;
|
||||
top: 50%;
|
||||
left: 50%;
|
||||
transform: translate(-50%, -50%);
|
||||
width: 12px;
|
||||
height: 32px;
|
||||
border-radius: 10px;
|
||||
box-sizing: border-box;
|
||||
background: var(--dsw-alias-button-floating-fill);
|
||||
border: 1px solid var(--dsw-alias-border-l2-darkmode-thin);
|
||||
/* Hover affordance: the pill hides until the pointer is over the owning
|
||||
column (data-side pairs handle and column), the strip itself, or a drag. */
|
||||
opacity: 0;
|
||||
transition:
|
||||
opacity var(--ds-transition-duration-slow) var(--ds-ease-in-out),
|
||||
background var(--ds-transition-duration-slow) var(--ds-ease-in-out);
|
||||
}
|
||||
|
||||
.sidebarCol:hover ~ .handle[data-side='sidebar']::after,
|
||||
.detailsCol:hover ~ .handle[data-side='details']::after,
|
||||
.handle:hover::after,
|
||||
.handle[data-dragging='true']::after {
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
.handle:hover::after,
|
||||
.handle[data-dragging='true']::after {
|
||||
background: var(--dsw-alias-button-floating-hover);
|
||||
border-color: var(--dsw-alias-border-l3);
|
||||
}
|
||||
|
||||
@@ -66,17 +66,6 @@
|
||||
font: inherit;
|
||||
}
|
||||
|
||||
.noLanguage .bannerWrap {
|
||||
position: absolute;
|
||||
right: 0;
|
||||
background: transparent;
|
||||
}
|
||||
|
||||
.noLanguage .banner {
|
||||
padding-left: 8px;
|
||||
background: transparent;
|
||||
}
|
||||
|
||||
.block :where(pre) {
|
||||
font: var(--dsl-code-block-content-font);
|
||||
padding: 16px;
|
||||
@@ -87,10 +76,6 @@
|
||||
background: var(--dsw-alias-markdown-code-block);
|
||||
}
|
||||
|
||||
.noLanguage :where(pre) {
|
||||
padding-right: 72px;
|
||||
}
|
||||
|
||||
/* Shiki inlines its theme background var; route it to the repo token. */
|
||||
.block :where(pre.shiki) {
|
||||
background: var(--dsw-alias-markdown-code-block) !important;
|
||||
|
||||
@@ -59,7 +59,6 @@ async function writeClipboard(text: string): Promise<boolean> {
|
||||
|
||||
export function CodeBlock({ code, lang, className }: CodeBlockProps) {
|
||||
const trimmed = code.endsWith('\n') ? code.slice(0, -1) : code
|
||||
const hasLanguage = lang !== undefined && lang !== ''
|
||||
const html = useMemo(() => highlightToHtml(trimmed, lang), [trimmed, lang])
|
||||
const rootRef = useRef<HTMLDivElement>(null)
|
||||
const [copied, setCopied] = useState(false)
|
||||
@@ -88,10 +87,7 @@ export function CodeBlock({ code, lang, className }: CodeBlockProps) {
|
||||
)
|
||||
|
||||
return (
|
||||
<div
|
||||
ref={rootRef}
|
||||
className={clsx(css.block, !hasLanguage && css.noLanguage, 'md-code-block', className)}
|
||||
>
|
||||
<div ref={rootRef} className={clsx(css.block, 'md-code-block', className)}>
|
||||
<div className={css.bannerWrap}>
|
||||
<div className={css.banner}>
|
||||
<div className={css.infostring}>{lang ?? ''}</div>
|
||||
|
||||
@@ -2,8 +2,10 @@
|
||||
|
||||
import { useCallback, useEffect, useMemo, useRef, useState } from 'react'
|
||||
import type { ConvViewProps } from '@deepseek-ai/dsh-client-ui-conversation/client'
|
||||
import type { InjectFace } from '@deepseek-ai/dsh-client-ui-slots'
|
||||
import type {
|
||||
AssistantMessageNode, ConversationContext, ConversationNode, RequestView,
|
||||
AssistantMessageNode, ConversationContext, RequestView,
|
||||
SessionHistoryFace,
|
||||
} from '@deepseek-ai/dsh-client-runtime/client'
|
||||
import {
|
||||
deriveTrajectoryContextBranches, trajectoryBranchContainsRequest,
|
||||
@@ -28,6 +30,7 @@ const EMPTY_REQUESTS: readonly RequestView[] = []
|
||||
|
||||
/** Session-history paging needed by the event-complete trajectory view. */
|
||||
export interface TrajectoryViewInjected {
|
||||
hooks: { history: SessionHistoryFace }
|
||||
loadAllHistory: (signal: AbortSignal) => Promise<void>
|
||||
}
|
||||
|
||||
@@ -84,12 +87,6 @@ function searchableJson(value: unknown): string {
|
||||
}
|
||||
}
|
||||
|
||||
function isInterruptedNode(node: ConversationNode): boolean {
|
||||
return node.kind === 'assistant'
|
||||
? node.interrupted === true
|
||||
: node.kind === 'tool-result' && node.error?.code === 'interrupted'
|
||||
}
|
||||
|
||||
function searchMatches(
|
||||
turns: ReturnType<typeof deriveTrajectoryLayout>,
|
||||
query: string,
|
||||
@@ -137,7 +134,9 @@ function searchMatches(
|
||||
return matches
|
||||
}
|
||||
|
||||
export function TrajectoryView({ useSession, loadAllHistory }: ConvViewProps & TrajectoryViewInjected) {
|
||||
export function TrajectoryView({
|
||||
useHistory, loadAllHistory,
|
||||
}: ConvViewProps & InjectFace<TrajectoryViewInjected>) {
|
||||
const [collapsedTurns, setCollapsedTurns] = useState<ReadonlySet<number>>(EMPTY_IDS)
|
||||
const [collapsedAssistants, setCollapsedAssistants] =
|
||||
useState<ReadonlySet<number>>(EMPTY_IDS)
|
||||
@@ -150,26 +149,22 @@ export function TrajectoryView({ useSession, loadAllHistory }: ConvViewProps & T
|
||||
const [searchQuery, setSearchQuery] = useState('')
|
||||
const [selectedTimelineIndex, setSelectedTimelineIndex] = useState<number | null>(null)
|
||||
const ledgerRef = useRef<HTMLDivElement>(null)
|
||||
const nodes = useSession(s => s.nodes)
|
||||
const inspection = useSession(s => s.inspection)
|
||||
const hasMore = useSession(s => s.hasMore)
|
||||
const openState = useSession(s => s.openState)
|
||||
const partial = useSession(s => s.partial)
|
||||
const runningCalls = useSession(s => s.runningCalls)
|
||||
const codeDispatches = useSession(s => s.codeDispatches)
|
||||
const inspection = useHistory(snapshot => snapshot.inspection)
|
||||
const nodes = inspection.eventNodes
|
||||
const partial = inspection.partial
|
||||
const runningCalls = inspection.runningCalls
|
||||
const codeDispatches = inspection.codeDispatches
|
||||
const loadAllHistoryRef = useRef(loadAllHistory)
|
||||
loadAllHistoryRef.current = loadAllHistory
|
||||
useEffect(() => {
|
||||
const controller = new AbortController()
|
||||
if (openState === 'open' && hasMore) {
|
||||
void loadAllHistoryRef.current(controller.signal)
|
||||
}
|
||||
void loadAllHistoryRef.current(controller.signal)
|
||||
return () => { controller.abort() }
|
||||
}, [hasMore, openState])
|
||||
const requests = inspection?.requests ?? EMPTY_REQUESTS
|
||||
const callSchemas = inspection?.callSchemas
|
||||
}, [])
|
||||
const requests = inspection.requests ?? EMPTY_REQUESTS
|
||||
const callSchemas = inspection.callSchemas
|
||||
const contexts = useMemo<readonly ConversationContext[]>(
|
||||
() => inspection === undefined || inspection.contexts.length === 0
|
||||
() => inspection.contexts.length === 0
|
||||
? [{ id: 0, nodes }]
|
||||
: inspection.contexts,
|
||||
[inspection, nodes],
|
||||
@@ -182,11 +177,11 @@ export function TrajectoryView({ useSession, loadAllHistory }: ConvViewProps & T
|
||||
if (currentBranch === undefined) throw new Error('trajectory branch projection must not be empty')
|
||||
const selectedNodes = useMemo(() => {
|
||||
const selected = new Map(currentBranch.nodes.map(node => [node.seq, node]))
|
||||
for (const node of nodes) {
|
||||
if (isInterruptedNode(node)) selected.set(node.seq, node)
|
||||
for (const node of inspection.interruptedNodes) {
|
||||
selected.set(node.seq, node)
|
||||
}
|
||||
return [...selected.values()].sort((left, right) => left.seq - right.seq)
|
||||
}, [currentBranch, nodes])
|
||||
}, [currentBranch, inspection])
|
||||
const selectedRequests = useMemo(
|
||||
() => requests.filter(request =>
|
||||
trajectoryBranchContainsRequest(currentBranch, request),
|
||||
|
||||
@@ -16,7 +16,7 @@ import { TrajectoryView, type TrajectoryViewInjected } from './TrajectoryView.ts
|
||||
* into an undeclared slot throws — service waiting is what orders this
|
||||
* apply after the declaring one.
|
||||
*/
|
||||
export const inject = ['slots', 'conversation', 'sessions']
|
||||
export const inject = ['slots', 'conversation', 'sessionHistory']
|
||||
|
||||
/**
|
||||
* Client plugin body: register the trajectory view tab. The registration
|
||||
@@ -30,12 +30,10 @@ export function apply(ctx: Context): void {
|
||||
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`)
|
||||
}
|
||||
const history = ctx.sessionHistory.source(sessionId)
|
||||
return {
|
||||
loadAllHistory: signal => session.loadAllHistory(signal),
|
||||
hooks: { history },
|
||||
loadAllHistory: signal => history.loadAll(signal),
|
||||
}
|
||||
},
|
||||
}, TrajectoryView)
|
||||
|
||||
@@ -10,13 +10,14 @@
|
||||
import { Context } from 'cordis'
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { cleanup, fireEvent, render, screen } from '@testing-library/react'
|
||||
import { createElement, type FC, type ReactNode } from 'react'
|
||||
import { createElement, type ComponentProps, type FC, type ReactNode } from 'react'
|
||||
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, RequestView, SessionId, SessionListState, WorkspaceListState,
|
||||
ConversationSnapshot, RequestView, SessionHistoryFace, SessionHistoryInspection,
|
||||
SessionHistorySnapshot, SessionId, SessionListState, WorkspaceListState,
|
||||
} from '@deepseek-ai/dsh-client-runtime/client'
|
||||
import type { ConvViewProps, ViewTab } from '@deepseek-ai/dsh-client-ui-conversation/client'
|
||||
import { ConversationSession, type ConversationSessionProps } from '@deepseek-ai/dsh-client-ui-conversation/src/client/skeleton/ConversationSession.tsx'
|
||||
@@ -24,7 +25,9 @@ import { createChatStore } from '@deepseek-ai/dsh-client-ui-conversation/src/cli
|
||||
import { apply, inject } from '@deepseek-ai/dsh-client-ui-trajectory/client'
|
||||
import { apply as nodeApply } from '@deepseek-ai/dsh-client-ui-trajectory'
|
||||
import type { TrajectoryTurnModel } from '../src/client/layout.ts'
|
||||
import { TrajectoryView } from '../src/client/TrajectoryView.tsx'
|
||||
import {
|
||||
TrajectoryView, type TrajectoryViewInjected,
|
||||
} from '../src/client/TrajectoryView.tsx'
|
||||
import { deriveTrajectoryTimeline } from '../src/client/timeline.ts'
|
||||
|
||||
const SID = 's1' as SessionId
|
||||
@@ -54,6 +57,38 @@ const NODES = [
|
||||
},
|
||||
] as unknown as ConversationSnapshot['nodes']
|
||||
|
||||
function historySnapshot(
|
||||
nodes: ConversationSnapshot['nodes'],
|
||||
inspection: Partial<SessionHistoryInspection> = {},
|
||||
): SessionHistorySnapshot {
|
||||
return {
|
||||
state: 'ready',
|
||||
error: null,
|
||||
hasMore: false,
|
||||
inspection: {
|
||||
eventNodes: nodes,
|
||||
contexts: [{ id: 0, nodes }],
|
||||
requests: [],
|
||||
callSchemas: new Map(),
|
||||
interruptedNodes: [],
|
||||
partial: null,
|
||||
runningCalls: [],
|
||||
codeDispatches: new Map(),
|
||||
...inspection,
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
function standaloneHistory(
|
||||
snapshot: SessionHistorySnapshot,
|
||||
): Pick<ComponentProps<typeof TrajectoryView>, 'useHistory' | 'loadAllHistory'> {
|
||||
const store = createSnapshotStore(snapshot)
|
||||
return {
|
||||
useHistory: bindSnapshotSelector(store),
|
||||
loadAllHistory: () => Promise.resolve(),
|
||||
}
|
||||
}
|
||||
|
||||
function fakeSession(nodes: ConversationSnapshot['nodes']) {
|
||||
const store = createSnapshotStore({
|
||||
nodes, pending: [], partial: null,
|
||||
@@ -93,6 +128,13 @@ async function bench() {
|
||||
const ctx = new Context()
|
||||
const slots = new SlotsService(ctx)
|
||||
const loadAllHistory = vi.fn((_signal: AbortSignal) => Promise.resolve())
|
||||
const historyStore = createSnapshotStore(historySnapshot(NODES))
|
||||
const history: SessionHistoryFace = {
|
||||
sessionId: SID,
|
||||
getSnapshot: historyStore.getSnapshot,
|
||||
subscribe: historyStore.subscribe,
|
||||
loadAll: loadAllHistory,
|
||||
}
|
||||
// The conversation entry's role: declare the ring, then seed the chat entry.
|
||||
slots.register({
|
||||
name: 'root',
|
||||
@@ -104,11 +146,7 @@ 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: { loadAllHistory } }
|
||||
: undefined,
|
||||
})
|
||||
ctx.provide('sessionHistory', { source: () => history })
|
||||
const fiber = ctx.plugin({ inject: [...inject], apply })
|
||||
await fiber.await()
|
||||
return { ctx, slots, fiber, loadAllHistory }
|
||||
@@ -141,9 +179,17 @@ function mount(slots: SlotsService, nodes: ConversationSnapshot['nodes'] = NODES
|
||||
const injected = injectEntry === undefined
|
||||
? {}
|
||||
: injectEntry(SID)
|
||||
const injectedProps = 'hooks' in injected
|
||||
? {
|
||||
loadAllHistory: (injected as TrajectoryViewInjected).loadAllHistory,
|
||||
useHistory: bindSnapshotSelector(
|
||||
(injected as TrajectoryViewInjected).hooks.history,
|
||||
),
|
||||
}
|
||||
: injected
|
||||
return (
|
||||
<View
|
||||
{...injected}
|
||||
{...injectedProps}
|
||||
{...({ sessionId: SID, useSession, useSessions: emptySessions(), useWorkspaces: emptyWorkspaces() } as unknown as ConvViewProps)}
|
||||
key={key}
|
||||
/>
|
||||
@@ -151,7 +197,6 @@ function mount(slots: SlotsService, nodes: ConversationSnapshot['nodes'] = NODES
|
||||
}) as unknown as ConversationSessionProps['renderSlot']
|
||||
return render(
|
||||
<ConversationSession
|
||||
composer={null}
|
||||
sessionId={SID}
|
||||
SessionProvider={({ children }) => children(SID)}
|
||||
useSession={useSession}
|
||||
@@ -345,7 +390,10 @@ describe('timeline projection', () => {
|
||||
expect(deriveTrajectoryTimeline([])).toBeNull()
|
||||
render(createElement(
|
||||
TrajectoryView,
|
||||
{ ...standaloneProps([]), loadAllHistory: () => Promise.resolve() },
|
||||
{
|
||||
...standaloneProps([]),
|
||||
...standaloneHistory(historySnapshot([])),
|
||||
},
|
||||
))
|
||||
expect(screen.getByRole('toolbar', { name: 'Trajectory toolbar' })).toBeTruthy()
|
||||
expect(screen.queryByRole('row')).toBeNull()
|
||||
@@ -386,9 +434,9 @@ describe('TrajectoryView branches', () => {
|
||||
completedAt: startSeq * 1_000 + 100,
|
||||
status: 'complete',
|
||||
})
|
||||
const store = createSnapshotStore({
|
||||
nodes: [retained, current],
|
||||
inspection: {
|
||||
const store = createSnapshotStore(historySnapshot(
|
||||
[retained, abandoned, current],
|
||||
{
|
||||
eventNodes: [retained, abandoned, current],
|
||||
contexts: [
|
||||
{ id: 0, nodes: [retained, abandoned] },
|
||||
@@ -403,17 +451,12 @@ describe('TrajectoryView branches', () => {
|
||||
requests: [request(2, 1), request(4, 2)],
|
||||
callSchemas: new Map(),
|
||||
},
|
||||
openState: 'open' as const,
|
||||
hasMore: false,
|
||||
partial: null,
|
||||
runningCalls: [] as ConversationSnapshot['runningCalls'],
|
||||
codeDispatches: new Map(),
|
||||
})
|
||||
))
|
||||
|
||||
const view = render(
|
||||
<TrajectoryView
|
||||
{...standaloneProps([])}
|
||||
useSession={bindSnapshotSelector(store) as unknown as UseSession<ConversationSnapshot>}
|
||||
useHistory={bindSnapshotSelector(store)}
|
||||
loadAllHistory={vi.fn(() => Promise.resolve())}
|
||||
/>,
|
||||
)
|
||||
@@ -441,25 +484,21 @@ describe('TrajectoryView branches', () => {
|
||||
error: { name: 'Interrupted', code: 'interrupted' },
|
||||
callView: null, resultView: null,
|
||||
} as unknown as ConversationSnapshot['nodes'][number]
|
||||
const store = createSnapshotStore({
|
||||
nodes: [retained, interruptedAssistant, interruptedTool],
|
||||
inspection: {
|
||||
const store = createSnapshotStore(historySnapshot(
|
||||
[retained],
|
||||
{
|
||||
eventNodes: [retained],
|
||||
contexts: [{ id: 0, nodes: [retained] }],
|
||||
requests: [],
|
||||
callSchemas: new Map(),
|
||||
interruptedNodes: [interruptedAssistant, interruptedTool],
|
||||
},
|
||||
openState: 'open' as const,
|
||||
hasMore: false,
|
||||
partial: null,
|
||||
runningCalls: [] as ConversationSnapshot['runningCalls'],
|
||||
codeDispatches: new Map(),
|
||||
})
|
||||
))
|
||||
|
||||
render(
|
||||
<TrajectoryView
|
||||
{...standaloneProps([])}
|
||||
useSession={bindSnapshotSelector(store) as unknown as UseSession<ConversationSnapshot>}
|
||||
useHistory={bindSnapshotSelector(store)}
|
||||
loadAllHistory={vi.fn(() => Promise.resolve())}
|
||||
/>,
|
||||
)
|
||||
|
||||
Reference in New Issue
Block a user