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.
|
* @returns completion; failures land in snapshot.openState/loadingOlder.
|
||||||
*/
|
*/
|
||||||
loadOlder(): Promise<void>
|
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
|
* Execute one slash-command line against this session's agent — pure
|
||||||
* admission semantics (the host executor durably logs the lifecycle).
|
* 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 { SlotsService } from './slots.ts'
|
||||||
import { SessionsService } from './sessions/service.ts'
|
import { SessionsService } from './sessions/service.ts'
|
||||||
import type { SessionListState } 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 { WorkspacesService } from './workspaces/service.ts'
|
||||||
import type { ConversationSnapshot, RunningToolCall, ToolResultNode } from './sessions/conversation.ts'
|
import type { ConversationSnapshot, RunningToolCall, ToolResultNode } from './sessions/conversation.ts'
|
||||||
import type { UseProjection } from './sessions/projection-store.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 { SlotsService } from './slots.ts'
|
||||||
export type { RootOwnerProps } from './slots.ts'
|
export type { RootOwnerProps } from './slots.ts'
|
||||||
export { SessionCreateError, SessionsService, scopeOf, workspaceTitleOf } from './sessions/service.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
|
// The provide channel is shared with the client test runtime (one
|
||||||
// materialization/projection implementation; no test-side mirror to drift).
|
// materialization/projection implementation; no test-side mirror to drift).
|
||||||
export { SessionProvideChannel } from './sessions/provide.ts'
|
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 { DirectoryBrowseError, WorkspaceCreateError, WorkspacesService } from './workspaces/service.ts'
|
||||||
export type { Session } from './sessions/session.ts'
|
export type { Session } from './sessions/session.ts'
|
||||||
export type { ISession, ProjectionsFace, SessionFace } from './contract/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 { ISessions } from './contract/sessions.ts'
|
||||||
export type { IWorkspaces } from './contract/workspaces.ts'
|
export type { IWorkspaces } from './contract/workspaces.ts'
|
||||||
export type {
|
export type {
|
||||||
@@ -49,7 +54,7 @@ export type {
|
|||||||
export type {
|
export type {
|
||||||
ConversationPromptSnapshot, RequestInspectionSnapshot, RequestPromptChange, RequestView,
|
ConversationPromptSnapshot, RequestInspectionSnapshot, RequestPromptChange, RequestView,
|
||||||
} from './sessions/request-inspection.ts'
|
} 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 type { SessionHistoryInspection } from './sessions/history.ts'
|
||||||
export { PendingWait } from './sessions/pending.ts'
|
export { PendingWait } from './sessions/pending.ts'
|
||||||
export type { PendingInteraction, PendingKind, PendingPayloads } 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
|
slots: import('./slots.ts').SlotsService
|
||||||
/** The outward face only; the concrete service stays inside the runtime. */
|
/** The outward face only; the concrete service stays inside the runtime. */
|
||||||
sessions: import('./contract/sessions.ts').ISessions
|
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. */
|
/** The outward face only; the concrete service stays inside the runtime. */
|
||||||
workspaces: import('./contract/workspaces.ts').IWorkspaces
|
workspaces: import('./contract/workspaces.ts').IWorkspaces
|
||||||
}
|
}
|
||||||
@@ -144,30 +151,55 @@ export function apply(ctx: Context): void {
|
|||||||
ctx.plugin(SlotsService)
|
ctx.plugin(SlotsService)
|
||||||
const connection = ctx.get('connection') as ConnectionHandle
|
const connection = ctx.get('connection') as ConnectionHandle
|
||||||
const sessions = new SessionsService(ctx, connection.api)
|
const sessions = new SessionsService(ctx, connection.api)
|
||||||
|
const sessionHistory = new SessionHistoryService(ctx, connection.api)
|
||||||
const workspaces = new WorkspacesService(ctx, connection.api, sessions)
|
const workspaces = new WorkspacesService(ctx, connection.api, sessions)
|
||||||
ctx.effect(
|
ctx.effect(
|
||||||
() => workspaces.startInitialSelection(),
|
() => workspaces.startInitialSelection(),
|
||||||
'runtime: initial Workspace selection',
|
'runtime: initial Workspace selection',
|
||||||
)
|
)
|
||||||
const loop = connection.start({
|
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) => {
|
onHostEnvelope: (envelope) => {
|
||||||
sessions.handleHostEnvelope(envelope)
|
sessions.handleHostEnvelope(envelope)
|
||||||
workspaces.handleHostEnvelope(envelope)
|
workspaces.handleHostEnvelope(envelope)
|
||||||
// Typed-event bridge: the session layer ignores registry frames (no
|
// Typed-event bridge: the session layer ignores registry frames (no
|
||||||
// session routing); consumers (command directory caches) subscribe on ctx.
|
// session routing); consumers (command directory caches) subscribe on ctx.
|
||||||
if (envelope.payload.type === 'host/commands-changed') ctx.emit('commands/changed')
|
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: () => {
|
onConnected: () => {
|
||||||
sessions.handleConnected()
|
sessions.handleConnected()
|
||||||
workspaces.handleConnected()
|
workspaces.handleConnected()
|
||||||
ctx.emit('connection/reset')
|
ctx.emit('connection/reset')
|
||||||
|
try {
|
||||||
|
sessionHistory.handleConnected()
|
||||||
|
} catch (error) {
|
||||||
|
console.error('[web-runtime] history reconnect failed:', error)
|
||||||
|
}
|
||||||
},
|
},
|
||||||
onStateChange: (state) => {
|
onStateChange: (state) => {
|
||||||
// Generation death fires before any next-generation frame can arrive
|
// Generation death fires before any next-generation frame can arrive
|
||||||
// (reconnect replays flow from stream open, ahead of onConnected):
|
// (reconnect replays flow from stream open, ahead of onConnected):
|
||||||
// the only safe moment to drop generation-scoped interaction state.
|
// 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')
|
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 {
|
import type {
|
||||||
RpcError, SessionId, ToolCallView, ToolResultView,
|
RpcError, SessionId, ToolCallView, ToolResultView,
|
||||||
} from '@deepseek-ai/dsh-client-connection/client'
|
} from '@deepseek-ai/dsh-client-connection/client'
|
||||||
import type { SessionHistoryInspection } from './history.ts'
|
|
||||||
import type { PendingInteraction } from './pending.ts'
|
import type { PendingInteraction } from './pending.ts'
|
||||||
export type { TodoItem }
|
export type { TodoItem }
|
||||||
|
|
||||||
@@ -266,8 +265,6 @@ export interface ConversationSnapshot {
|
|||||||
sessionId: SessionId
|
sessionId: SessionId
|
||||||
/** Surface fold product (finalized conversation nodes in surface order). */
|
/** Surface fold product (finalized conversation nodes in surface order). */
|
||||||
nodes: readonly ConversationNode[]
|
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. */
|
/** Fold degradation flag (cross-window replace defense): when true, nodes come from the lenient linear scan. */
|
||||||
foldDegraded: boolean
|
foldDegraded: boolean
|
||||||
partial: PartialAssistant | null
|
partial: PartialAssistant | null
|
||||||
|
|||||||
@@ -1,55 +1,17 @@
|
|||||||
// FoldAdapter: core SurfaceManager wiring + node materialization cache.
|
// FoldAdapter: core SurfaceManager wiring + node materialization cache.
|
||||||
// Padding sentinels solve the paged-window seq offset (core fold asserts seq === index).
|
// 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
|
// a cross-window replace throw degrades to a lenient linear scan (foldDegraded —
|
||||||
// paging reaches its range; unexpected fold failures report and use the same fallback.
|
// the degradation lives in one branch function in this file, zero scattered removal points).
|
||||||
|
|
||||||
import type { SessionEvent } from '@deepseek-ai/dsh-session/types'
|
import type { SessionEvent } from '@deepseek-ai/dsh-session/types'
|
||||||
// Subpath export (package.json exports "./surface", alias added for this): all value imports
|
// 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
|
// 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.
|
// browser bundle cannot resolve; surface.ts has no Node dependencies.
|
||||||
import {
|
import { SurfaceManager, isSurfaceEligibleType } from '@deepseek-ai/dsh-session/surface'
|
||||||
SurfaceManager, isSurfaceEligibleType, isSurfaceEvent,
|
|
||||||
} from '@deepseek-ai/dsh-session/surface'
|
|
||||||
import type { CommandId } from '@deepseek-ai/dsh-commands/brand'
|
import type { CommandId } from '@deepseek-ai/dsh-commands/brand'
|
||||||
import type {
|
import type { ToolCallView, ToolEventView, ToolResultView } from '@deepseek-ai/dsh-client-connection/client'
|
||||||
HistoryEntry, ToolCallView, ToolEventView, ToolResultView,
|
import type { CommandNode, ConversationNode } from './conversation.ts'
|
||||||
} from '@deepseek-ai/dsh-client-connection/client'
|
|
||||||
import type {
|
|
||||||
AssistantRequestConfig, AssistantTiming, CommandNode, ConversationNode,
|
|
||||||
} from './conversation.ts'
|
|
||||||
import { toAssistantBlocks } 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). */
|
/** In-window tool/call index entry (result-card backfill + runningCalls material). */
|
||||||
export interface CallIndexEntry {
|
export interface CallIndexEntry {
|
||||||
@@ -71,70 +33,11 @@ function paddingEvent(seq: number): SessionEvent {
|
|||||||
return { type: 'noop/padding', seq, time: 0, data: {} } as unknown as 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). */
|
/** One event -> UI node (pure function; the six-variant ConversationNode union). */
|
||||||
function materializeNode(
|
function materializeNode(
|
||||||
event: SessionEvent,
|
event: SessionEvent,
|
||||||
callIndex: ReadonlyMap<string, CallIndexEntry>,
|
callIndex: ReadonlyMap<string, CallIndexEntry>,
|
||||||
resultView: ToolResultView | null,
|
resultView: ToolResultView | null,
|
||||||
assistantTiming?: AssistantTiming,
|
|
||||||
requestConfig?: AssistantRequestConfig,
|
|
||||||
): ConversationNode {
|
): ConversationNode {
|
||||||
switch (event.type) {
|
switch (event.type) {
|
||||||
case 'user/message':
|
case 'user/message':
|
||||||
@@ -155,12 +58,6 @@ function materializeNode(
|
|||||||
kind: 'assistant', seq: event.seq, time: event.time,
|
kind: 'assistant', seq: event.seq, time: event.time,
|
||||||
turn: event.data.turn, step: event.data.step,
|
turn: event.data.turn, step: event.data.step,
|
||||||
blocks: toAssistantBlocks(event.data.message.content), usage: event.data.usage,
|
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':
|
case 'steering/message':
|
||||||
return {
|
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 {
|
export class FoldAdapter {
|
||||||
/** padded = [sentinel x baseSeq, ...window events]; SurfaceManager borrows this reference for lazy incremental folding. */
|
/** padded = [sentinel x baseSeq, ...window events]; SurfaceManager borrows this reference for lazy incremental folding. */
|
||||||
private padded: SessionEvent[] = []
|
private padded: SessionEvent[] = []
|
||||||
@@ -219,23 +116,6 @@ export class FoldAdapter {
|
|||||||
* reference-stability contract (§A.9.4) starts here. */
|
* reference-stability contract (§A.9.4) starts here. */
|
||||||
private rev = 0
|
private rev = 0
|
||||||
private nodesResult: { rev: number; value: { nodes: ConversationNode[]; degraded: boolean } } | null = null
|
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). */
|
/** In-window tool/call index (Session uses it for runningCalls and result-card backfill). */
|
||||||
get callIndex(): ReadonlyMap<string, CallIndexEntry> {
|
get callIndex(): ReadonlyMap<string, CallIndexEntry> {
|
||||||
@@ -251,31 +131,21 @@ export class FoldAdapter {
|
|||||||
*/
|
*/
|
||||||
reset(events: readonly SessionEvent[], baseSeq: number, views?: readonly (ToolEventView | undefined)[]): void {
|
reset(events: readonly SessionEvent[], baseSeq: number, views?: readonly (ToolEventView | undefined)[]): void {
|
||||||
this.rev++
|
this.rev++
|
||||||
if (this.projectContexts) this.contextRev++
|
|
||||||
this.baseSeq = baseSeq
|
this.baseSeq = baseSeq
|
||||||
this.padded = []
|
this.padded = []
|
||||||
for (let i = 0; i < baseSeq; i++) this.padded.push(paddingEvent(i))
|
for (let i = 0; i < baseSeq; i++) this.padded.push(paddingEvent(i))
|
||||||
for (const event of events) this.padded.push(event)
|
for (const event of events) this.padded.push(event)
|
||||||
this.surface = new SurfaceManager(this.padded)
|
this.surface = new SurfaceManager(this.padded)
|
||||||
this.nodeCache.clear()
|
this.nodeCache.clear()
|
||||||
this.degraded = events.some(event => replacementCrossesWindowHead(event, baseSeq))
|
this.degraded = false
|
||||||
this.callIdx = new Map()
|
this.callIdx = new Map()
|
||||||
this.resultViews.clear()
|
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()
|
this.commandIdx = new Map()
|
||||||
for (let i = 0; i < events.length; i++) {
|
for (let i = 0; i < events.length; i++) {
|
||||||
const event = events[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. */
|
/* 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) {
|
if (event !== undefined) {
|
||||||
this.indexCall(event, views?.[i])
|
this.indexCall(event, views?.[i])
|
||||||
if (this.projectContexts) this.indexContextPrompt(event)
|
|
||||||
this.indexAssistantMetadata(event)
|
|
||||||
this.indexCommand(event)
|
this.indexCommand(event)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -289,14 +159,8 @@ export class FoldAdapter {
|
|||||||
*/
|
*/
|
||||||
append(event: SessionEvent, view?: ToolEventView): void {
|
append(event: SessionEvent, view?: ToolEventView): void {
|
||||||
this.rev++
|
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.padded.push(event)
|
||||||
this.indexCall(event, view)
|
this.indexCall(event, view)
|
||||||
if (this.projectContexts) this.indexContextPrompt(event)
|
|
||||||
this.indexAssistantMetadata(event)
|
|
||||||
this.indexCommand(event)
|
this.indexCommand(event)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -321,9 +185,17 @@ export class FoldAdapter {
|
|||||||
}
|
}
|
||||||
const out: ConversationNode[] = []
|
const out: ConversationNode[] = []
|
||||||
for (const seq of seqs) {
|
for (const seq of seqs) {
|
||||||
const node = this.materialize(seq)
|
const cached = this.nodeCache.get(seq)
|
||||||
/* v8 ignore next -- both seq sources only emit indexes present in padded. */
|
if (cached !== undefined) {
|
||||||
if (node !== undefined) out.push(node)
|
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.
|
// Command nodes fold outside the surface (log-only events); merge by seq.
|
||||||
// Both inputs are seq-ascending (surface order and run-index insertion
|
// Both inputs are seq-ascending (surface order and run-index insertion
|
||||||
@@ -346,75 +218,6 @@ export class FoldAdapter {
|
|||||||
return value
|
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). */
|
/** Degradation branch: lenient linear scan ignoring surfaceOp/replace (all surface-eligible events in append order). */
|
||||||
private degradedSeqs(): number[] {
|
private degradedSeqs(): number[] {
|
||||||
const seqs: number[] = []
|
const seqs: number[] = []
|
||||||
@@ -425,22 +228,6 @@ export class FoldAdapter {
|
|||||||
return seqs
|
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). */
|
/** Fold one command lifecycle event into its node (run mints, done settles in place; done-only soft-falls). */
|
||||||
private indexCommand(event: SessionEvent): void {
|
private indexCommand(event: SessionEvent): void {
|
||||||
// Log-only plugin events: the host-side dsh-commands declaration cannot
|
// 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
|
// 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).
|
// (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 { ToolSchema } from '@deepseek-ai/dsh-llm/types'
|
||||||
import type { HistoryEntry } from '@deepseek-ai/dsh-client-connection/client'
|
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 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'
|
import { inspectRequests, type RequestView } from './request-inspection.ts'
|
||||||
|
|
||||||
/** Lazily derived inspection data for one immutable session-history window. */
|
/** Lazily derived inspection data for one immutable session-history window. */
|
||||||
@@ -11,6 +13,10 @@ export interface SessionHistoryInspection {
|
|||||||
contexts: readonly ConversationContext[]
|
contexts: readonly ConversationContext[]
|
||||||
requests: readonly RequestView[]
|
requests: readonly RequestView[]
|
||||||
callSchemas: ReadonlyMap<string, ToolSchema>
|
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() {
|
get contexts() {
|
||||||
return conversationProjection().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() {
|
get requests() {
|
||||||
return requestProjection().requests
|
return requestProjection().requests
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -12,12 +12,9 @@ import type {
|
|||||||
import { transportError } from '@deepseek-ai/dsh-host-apiproxy/api'
|
import { transportError } from '@deepseek-ai/dsh-host-apiproxy/api'
|
||||||
import type { SessionFace } from '../contract/session.ts'
|
import type { SessionFace } from '../contract/session.ts'
|
||||||
import type {
|
import type {
|
||||||
CodeSubCall, ComposerPhase, ConversationNode, ConversationSnapshot,
|
CodeSubCall, ComposerPhase, ConversationNode, ConversationSnapshot, OpenState,
|
||||||
OpenState, PromptError, QueuedMessage, RunningToolCall,
|
PromptError, QueuedMessage, RunningToolCall,
|
||||||
} from './conversation.ts'
|
} from './conversation.ts'
|
||||||
import {
|
|
||||||
createHistoryInspection, type SessionHistoryInspection,
|
|
||||||
} from './history.ts'
|
|
||||||
import type { PendingInteraction } from './pending.ts'
|
import type { PendingInteraction } from './pending.ts'
|
||||||
import { PendingWait } from './pending.ts'
|
import { PendingWait } from './pending.ts'
|
||||||
import { FoldAdapter } from './fold-adapter.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. */
|
/** Queue-row preview cap: the dock renders one line, the full content never leaves the host mirror. */
|
||||||
const QUEUE_PREVIEW_CHARS = 200
|
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. */
|
/** Internal inbox-mirror entry: the snapshot row plus the retirement-matching fields the frames carry. */
|
||||||
interface QueuedEntry {
|
interface QueuedEntry {
|
||||||
row: QueuedMessage
|
row: QueuedMessage
|
||||||
@@ -73,11 +66,10 @@ function queuePreviewOf(content: readonly ContentBlock[]): string {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Owns a session's event window, folded conversation, raw history inspection,
|
* Owns a session's event window, derived conversation state, and observable
|
||||||
* and observable snapshot. React bindings remain outside this data layer.
|
* snapshot. React bindings remain outside this data layer. Features see only
|
||||||
* Features see only the {@link SessionFace} slice (ISession verbs + the
|
* the {@link SessionFace} slice (ISession verbs + the snapshot source); the
|
||||||
* snapshot source); the remaining public members are manager/runtime entry
|
* remaining public members are manager/runtime entry points.
|
||||||
* points.
|
|
||||||
*/
|
*/
|
||||||
export class Session implements SessionFace {
|
export class Session implements SessionFace {
|
||||||
// ---- Window and derived state (all private; the snapshot is the only read surface) ----
|
// ---- 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 codeDispatches = new Map<string, readonly CodeSubCall[]>()
|
||||||
private dispatchesRev = 0
|
private dispatchesRev = 0
|
||||||
private dispatchesCache: { rev: number; value: ReadonlyMap<string, readonly CodeSubCall[]> } | null = null
|
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
|
private running = false
|
||||||
/**
|
/**
|
||||||
* Sticky send marker, private input of the composerPhase derivation: set
|
* Sticky send marker, private input of the composerPhase derivation: set
|
||||||
@@ -294,75 +279,40 @@ export class Session implements SessionFace {
|
|||||||
return promise
|
return promise
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/** Page up: pull one earlier page with the window's first seq as beforeSeq and prepend (§D.2). */
|
||||||
* Page up: pull one earlier page with the window's first seq as beforeSeq and prepend (§D.2).
|
async loadOlder(): Promise<void> {
|
||||||
* Concurrent callers share the active page so complete-history readers can continue afterward.
|
if (this.openState !== 'open' || !this.hasMore || this.loadingOlder) return
|
||||||
* @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()
|
|
||||||
this.loadingOlder = true
|
this.loadingOlder = true
|
||||||
this.notifier.markDirty()
|
this.notifier.markDirty()
|
||||||
const generation = this.openGeneration
|
try {
|
||||||
const operation = (async () => {
|
const { result } = await this.api.sessions.history({
|
||||||
try {
|
sessionId: this.sessionId, beforeSeq: this.baseSeq, maxMessages: PAGE_MESSAGES,
|
||||||
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 (generation !== this.openGeneration || this.openState !== 'open') return
|
if (older.length === 0) {
|
||||||
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
|
|
||||||
this.hasMore = result.value.hasMore
|
this.hasMore = result.value.hasMore
|
||||||
this.foldAdapter.reset(this.events, this.baseSeq, this.views) // prepend forces a rebuild (sentinel count changed)
|
return
|
||||||
this.rebuildDerivedFromWindow()
|
|
||||||
} catch (error) {
|
|
||||||
console.error('[web-runtime] loadOlder failed:', error)
|
|
||||||
}
|
}
|
||||||
})()
|
const tail = older[older.length - 1]
|
||||||
const settled = operation.finally(() => {
|
if (tail === undefined || tail.event.seq + 1 !== this.baseSeq) {
|
||||||
if (this.loadOlderPromise !== settled) return
|
// §D.2 continuity assertion: on violation drop the page fail-soft rather than render an out-of-order stream.
|
||||||
this.loadOlderPromise = null
|
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.loadingOlder = false
|
||||||
this.notifier.markDirty()
|
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)
|
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.openGeneration++
|
||||||
this.openPromise = null
|
this.openPromise = null
|
||||||
this.loadOlderPromise = null
|
|
||||||
this.loadingOlder = false
|
|
||||||
this.openState = 'cold'
|
this.openState = 'cold'
|
||||||
this.openError = null
|
this.openError = null
|
||||||
this.events = []
|
this.events = []
|
||||||
this.views = []
|
this.views = []
|
||||||
this.historyRev++
|
|
||||||
this.baseSeq = 0
|
this.baseSeq = 0
|
||||||
// Superseded, not settled: the baseline replay re-sends still-pending requested frames verbatim
|
// 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.
|
// (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 {
|
private installWindow(entries: HistoryEntry[], hasMore: boolean, projections?: ProjectionsBaseline): void {
|
||||||
this.events = entries.map(e => e.event)
|
this.events = entries.map(e => e.event)
|
||||||
this.views = entries.map(e => e.view)
|
this.views = entries.map(e => e.view)
|
||||||
this.historyRev++
|
|
||||||
this.baseSeq = this.events[0]?.seq ?? 0
|
this.baseSeq = this.events[0]?.seq ?? 0
|
||||||
this.hasMore = hasMore
|
this.hasMore = hasMore
|
||||||
this.foldAdapter.reset(this.events, this.baseSeq, this.views)
|
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
|
if (tailSeq !== null && event.seq <= tailSeq) return // replay overlap, drop
|
||||||
this.events.push(event)
|
this.events.push(event)
|
||||||
this.views.push(view)
|
this.views.push(view)
|
||||||
if (event.type !== 'assistant/chunk') this.historyRev++
|
|
||||||
this.foldAdapter.append(event, view)
|
this.foldAdapter.append(event, view)
|
||||||
this.applyEventSideEffects(event, view)
|
this.applyEventSideEffects(event, view)
|
||||||
}
|
}
|
||||||
@@ -874,7 +819,6 @@ export class Session implements SessionFace {
|
|||||||
return {
|
return {
|
||||||
sessionId: this.sessionId,
|
sessionId: this.sessionId,
|
||||||
nodes,
|
nodes,
|
||||||
inspection: this.buildHistoryInspection(),
|
|
||||||
foldDegraded: degraded,
|
foldDegraded: degraded,
|
||||||
partial,
|
partial,
|
||||||
runningCalls: this.callsCache.value,
|
runningCalls: this.callsCache.value,
|
||||||
@@ -899,32 +843,6 @@ export class Session implements SessionFace {
|
|||||||
lastAgentError: this.lastAgentError,
|
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 { describe, expect, it, vi } from 'vitest'
|
||||||
import type { SessionEvent } from '@deepseek-ai/dsh-session/types'
|
import type { SessionEvent } from '@deepseek-ai/dsh-session/types'
|
||||||
import {
|
import { FoldAdapter } from '../src/client/sessions/fold-adapter.ts'
|
||||||
FoldAdapter, projectConversationHistory,
|
import { projectConversationHistory } from '../src/client/session-history/history-fold.ts'
|
||||||
} from '../src/client/sessions/fold-adapter.ts'
|
|
||||||
import { ev, plainTurn } from './event-script.ts'
|
import { ev, plainTurn } from './event-script.ts'
|
||||||
|
|
||||||
const at = (seq: number, e: Record<string, unknown>): SessionEvent =>
|
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()
|
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 () => {
|
it('freezes an unfinalized partial into an interrupted node on turn/end (cancel path)', async () => {
|
||||||
const { session } = await opened()
|
const { session } = await opened()
|
||||||
const feed = (event: SessionEvent) => { session.handleMuxEnvelope('r' as never, { type: 'session/event', sessionId: SID, event }) }
|
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])
|
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 () => {
|
it('drops a discontinuous older page fail-soft (window unchanged, hasMore cleared)', async () => {
|
||||||
const { api, session } = makeSession()
|
const { api, session } = makeSession()
|
||||||
api.onHistory = payload => payload.beforeSeq === undefined
|
api.onHistory = payload => payload.beforeSeq === undefined
|
||||||
@@ -803,31 +702,6 @@ describe('resync', () => {
|
|||||||
expect(snapshot.nodes.map(n => n.seq)).toEqual([7, 9])
|
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', () => {
|
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`)
|
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. */
|
/** 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.cancel()).toThrow(/cancel is not stubbed/)
|
||||||
expect(() => bare.command()).toThrow(/command is not stubbed/)
|
expect(() => bare.command()).toThrow(/command is not stubbed/)
|
||||||
expect(() => bare.loadOlder()).toThrow(/loadOlder is not stubbed/)
|
expect(() => bare.loadOlder()).toThrow(/loadOlder is not stubbed/)
|
||||||
expect(() => bare.loadAllHistory()).toThrow(/loadAllHistory is not stubbed/)
|
|
||||||
await runtime.dispose()
|
await runtime.dispose()
|
||||||
})
|
})
|
||||||
|
|
||||||
|
|||||||
@@ -83,8 +83,8 @@ export function apply(ctx: Context): void {
|
|||||||
},
|
},
|
||||||
}), 'ui-conversation: input standard-kit provider')
|
}), 'ui-conversation: input standard-kit provider')
|
||||||
|
|
||||||
// Resident current-session-optional shell. It constructs the stable
|
// Resident current-session-optional shell. It owns the stable Hero/composer
|
||||||
// composer frame; the strict session child decides whether Chat mounts it.
|
// frame while strict session slots fill only their session-bound regions.
|
||||||
slots.register({
|
slots.register({
|
||||||
name: 'conversation',
|
name: 'conversation',
|
||||||
children: {
|
children: {
|
||||||
@@ -114,8 +114,8 @@ export function apply(ctx: Context): void {
|
|||||||
}),
|
}),
|
||||||
}, ConversationRoot)
|
}, ConversationRoot)
|
||||||
|
|
||||||
// The strict session subtree owns the per-session store and view content;
|
// The strict session subtree owns only per-session store and view content;
|
||||||
// the resident parent supplies the composer node through owner props.
|
// the resident parent keeps Hero and composer layout identity stable.
|
||||||
slots.register({
|
slots.register({
|
||||||
name: 'conversation.session',
|
name: 'conversation.session',
|
||||||
children: { 'conversation.view': { kind: 'list', scope: '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 }
|
'conversation.session': { kind: 'single'; scope: 'session'; owner: ConversationSessionOwnerProps }
|
||||||
/**
|
/**
|
||||||
* The conversation view ring: one list entry per view tab (chat here;
|
* 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
|
* ConversationRoot via `only: <active id>`. Declared by this package's
|
||||||
* 'conversation' entry (declaring is claiming). Session scope: views read
|
* 'conversation' entry (declaring is claiming). Session scope: views read
|
||||||
* the conversation snapshot through the standard kit.
|
* 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. */
|
/** Owner share of the strict session content seat. */
|
||||||
export interface ConversationSessionOwnerProps {
|
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
|
* 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
|
* 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
|
* discipline) land with their own row render sites; today only the chat slot
|
||||||
* is declared (RendersCheck rejects a declaration nobody renders).
|
* is declared (RendersCheck rejects a declaration nobody renders).
|
||||||
|
|||||||
@@ -118,11 +118,6 @@ export function ConversationRoot({
|
|||||||
{inputBar}
|
{inputBar}
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
const composer = renderSlotChain(
|
|
||||||
'conversation.composer',
|
|
||||||
{ interactions: pending },
|
|
||||||
{ fallback: composerBar, overlay: true },
|
|
||||||
)
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className={css.root} data-phase={settling ? 'settling' : hero ? 'hero' : 'active'}>
|
<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
|
renders no chrome while blank but owns the draft-persistence mirror
|
||||||
bind — unmounting it in the hero would lose pre-first-send text on
|
bind — unmounting it in the hero would lose pre-first-send text on
|
||||||
a refresh or scope rebuild. */}
|
a refresh or scope rebuild. */}
|
||||||
{sessionId !== undefined
|
{sessionId !== undefined && renderSlot('conversation.session', {})}
|
||||||
? renderSlot('conversation.session', { composer })
|
{renderSlotChain(
|
||||||
: composer}
|
'conversation.composer',
|
||||||
|
{ interactions: pending },
|
||||||
|
{ fallback: composerBar, overlay: true },
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
/** Strict per-session conversation content: header, view ring, and chat store bindings. */
|
/** 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 clsx from 'clsx'
|
||||||
import { shallowEqual } from '@deepseek-ai/dsh-client-runtime/client'
|
import { shallowEqual } from '@deepseek-ai/dsh-client-runtime/client'
|
||||||
import type { SessionId, SessionListState, SessionSummary } 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({
|
export function ConversationSession({
|
||||||
sessionId, useSession, useSessions, useInput, inputActions, useStore, actions,
|
sessionId, useSession, useSessions, useInput, inputActions, useStore, actions,
|
||||||
renderSlot, views, bindDraftMirror, open, composer,
|
renderSlot, views, bindDraftMirror, open,
|
||||||
}: ConversationSessionProps) {
|
}: ConversationSessionProps) {
|
||||||
useSyncExternalStore(views.subscribe, views.version)
|
useSyncExternalStore(views.subscribe, views.version)
|
||||||
const tabs = views.list()
|
const tabs = views.list()
|
||||||
@@ -32,7 +32,6 @@ export function ConversationSession({
|
|||||||
const active = tabs.find(view => view.id === activeId) ?? tabs[0]
|
const active = tabs.find(view => view.id === activeId) ?? tabs[0]
|
||||||
const ancestry = useSessions(s => deriveAncestry(s, sessionId), shallowEqual)
|
const ancestry = useSessions(s => deriveAncestry(s, sessionId), shallowEqual)
|
||||||
const composerPhase = useSession(s => s.composerPhase)
|
const composerPhase = useSession(s => s.composerPhase)
|
||||||
const hasPending = useSession(s => s.pending.length > 0)
|
|
||||||
const blank = useSession(s => s.blank)
|
const blank = useSession(s => s.blank)
|
||||||
const inputState = useInput(s => s)
|
const inputState = useInput(s => s)
|
||||||
const storedDraft = useStore(s => s.draft)
|
const storedDraft = useStore(s => s.draft)
|
||||||
@@ -45,11 +44,11 @@ export function ConversationSession({
|
|||||||
// the machine mirror, not this seed effect.
|
// the machine mirror, not this seed effect.
|
||||||
}, [inputActions])
|
}, [inputActions])
|
||||||
|
|
||||||
const blankHero = blank && composerPhase === 'blank'
|
if (blank && composerPhase === 'blank') return null
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
{!blankHero && <header className={css.header}>
|
<header className={css.header}>
|
||||||
<div className={css.crumbRow}>
|
<div className={css.crumbRow}>
|
||||||
<nav className={css.crumbs} aria-label="Session hierarchy">
|
<nav className={css.crumbs} aria-label="Session hierarchy">
|
||||||
{ancestry.map((summary, index) => {
|
{ancestry.map((summary, index) => {
|
||||||
@@ -87,13 +86,10 @@ export function ConversationSession({
|
|||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</header>}
|
</header>
|
||||||
{!blankHero && <div className={css.viewArea}>
|
<div className={css.viewArea}>
|
||||||
{active !== undefined && renderSlot('conversation.view', {}, { only: active.id })}
|
{active !== undefined && renderSlot('conversation.view', {}, { only: active.id })}
|
||||||
</div>}
|
</div>
|
||||||
{(blankHero || active?.id === 'chat' || hasPending) && (
|
|
||||||
<Fragment key="composer">{composer}</Fragment>
|
|
||||||
)}
|
|
||||||
</>
|
</>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -34,7 +34,6 @@ function sessionFakeFor() {
|
|||||||
return {
|
return {
|
||||||
open: vi.fn(() => Promise.resolve()),
|
open: vi.fn(() => Promise.resolve()),
|
||||||
loadOlder: vi.fn<ISession['loadOlder']>(() => 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 } })),
|
prompt: vi.fn<ISession['prompt']>(() => Promise.resolve({ ok: true, value: { accepted: true } })),
|
||||||
cancel: vi.fn<ISession['cancel']>(() => Promise.resolve({ ok: true, value: { accepted: true } })),
|
cancel: vi.fn<ISession['cancel']>(() => Promise.resolve({ ok: true, value: { accepted: true } })),
|
||||||
} satisfies SessionBehaviorOverrides
|
} satisfies SessionBehaviorOverrides
|
||||||
|
|||||||
@@ -59,7 +59,6 @@ async function bench(nodes: ToolResultNode[]) {
|
|||||||
snapshot: { nodes },
|
snapshot: { nodes },
|
||||||
session: {
|
session: {
|
||||||
loadOlder: vi.fn<ISession['loadOlder']>(),
|
loadOlder: vi.fn<ISession['loadOlder']>(),
|
||||||
loadAllHistory: vi.fn<ISession['loadAllHistory']>(),
|
|
||||||
prompt: vi.fn<ISession['prompt']>(async () => ({ ok: true, value: { accepted: true } })),
|
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 { InputBar } from '../src/client/skeleton/InputBar.tsx'
|
||||||
import type { InputBarProps } from '../src/client/skeleton/InputBar.tsx'
|
import type { InputBarProps } from '../src/client/skeleton/InputBar.tsx'
|
||||||
import type {
|
import type {
|
||||||
ComposerBarOwnerProps, ConversationSessionOwnerProps,
|
ComposerBarOwnerProps,
|
||||||
} from '../src/client/contract/slots.ts'
|
} from '../src/client/contract/slots.ts'
|
||||||
|
|
||||||
/** Machine-backed wiring over a sink spy. */
|
/** Machine-backed wiring over a sink spy. */
|
||||||
@@ -88,10 +88,8 @@ function mount(
|
|||||||
slotCalls.push(key)
|
slotCalls.push(key)
|
||||||
if (key === 'conversation.hero.workspace') { pickerOwner = owner; return null }
|
if (key === 'conversation.hero.workspace') { pickerOwner = owner; return null }
|
||||||
if (key === 'conversation.session') {
|
if (key === 'conversation.session') {
|
||||||
const sessionOwner = owner as ConversationSessionOwnerProps
|
|
||||||
return (
|
return (
|
||||||
<ConversationSession
|
<ConversationSession
|
||||||
composer={sessionOwner.composer}
|
|
||||||
sessionId={SID}
|
sessionId={SID}
|
||||||
SessionProvider={({ children }) => children(SID)}
|
SessionProvider={({ children }) => children(SID)}
|
||||||
useSession={useSession}
|
useSession={useSession}
|
||||||
|
|||||||
@@ -49,15 +49,17 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
/* Drag handles are frame children (columns clip overflow): an 8px hit strip
|
/* 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 {
|
.handle {
|
||||||
position: absolute;
|
position: absolute;
|
||||||
z-index: 6;
|
|
||||||
top: 0;
|
top: 0;
|
||||||
bottom: 0;
|
bottom: 0;
|
||||||
width: 8px;
|
width: 8px;
|
||||||
margin-left: -4px;
|
margin-left: -4px;
|
||||||
cursor: col-resize;
|
cursor: col-resize;
|
||||||
|
z-index: 2;
|
||||||
touch-action: none;
|
touch-action: none;
|
||||||
/* Rides the same curve as the tracks so the pill stays on the moving
|
/* Rides the same curve as the tracks so the pill stays on the moving
|
||||||
border during collapse/expand; paused while dragging (frame rule). */
|
border during collapse/expand; paused while dragging (frame rule). */
|
||||||
@@ -73,3 +75,36 @@
|
|||||||
transition: none;
|
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;
|
font: inherit;
|
||||||
}
|
}
|
||||||
|
|
||||||
.noLanguage .bannerWrap {
|
|
||||||
position: absolute;
|
|
||||||
right: 0;
|
|
||||||
background: transparent;
|
|
||||||
}
|
|
||||||
|
|
||||||
.noLanguage .banner {
|
|
||||||
padding-left: 8px;
|
|
||||||
background: transparent;
|
|
||||||
}
|
|
||||||
|
|
||||||
.block :where(pre) {
|
.block :where(pre) {
|
||||||
font: var(--dsl-code-block-content-font);
|
font: var(--dsl-code-block-content-font);
|
||||||
padding: 16px;
|
padding: 16px;
|
||||||
@@ -87,10 +76,6 @@
|
|||||||
background: var(--dsw-alias-markdown-code-block);
|
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. */
|
/* Shiki inlines its theme background var; route it to the repo token. */
|
||||||
.block :where(pre.shiki) {
|
.block :where(pre.shiki) {
|
||||||
background: var(--dsw-alias-markdown-code-block) !important;
|
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) {
|
export function CodeBlock({ code, lang, className }: CodeBlockProps) {
|
||||||
const trimmed = code.endsWith('\n') ? code.slice(0, -1) : code
|
const trimmed = code.endsWith('\n') ? code.slice(0, -1) : code
|
||||||
const hasLanguage = lang !== undefined && lang !== ''
|
|
||||||
const html = useMemo(() => highlightToHtml(trimmed, lang), [trimmed, lang])
|
const html = useMemo(() => highlightToHtml(trimmed, lang), [trimmed, lang])
|
||||||
const rootRef = useRef<HTMLDivElement>(null)
|
const rootRef = useRef<HTMLDivElement>(null)
|
||||||
const [copied, setCopied] = useState(false)
|
const [copied, setCopied] = useState(false)
|
||||||
@@ -88,10 +87,7 @@ export function CodeBlock({ code, lang, className }: CodeBlockProps) {
|
|||||||
)
|
)
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div
|
<div ref={rootRef} className={clsx(css.block, 'md-code-block', className)}>
|
||||||
ref={rootRef}
|
|
||||||
className={clsx(css.block, !hasLanguage && css.noLanguage, 'md-code-block', className)}
|
|
||||||
>
|
|
||||||
<div className={css.bannerWrap}>
|
<div className={css.bannerWrap}>
|
||||||
<div className={css.banner}>
|
<div className={css.banner}>
|
||||||
<div className={css.infostring}>{lang ?? ''}</div>
|
<div className={css.infostring}>{lang ?? ''}</div>
|
||||||
|
|||||||
@@ -2,8 +2,10 @@
|
|||||||
|
|
||||||
import { useCallback, useEffect, useMemo, useRef, useState } from 'react'
|
import { useCallback, useEffect, useMemo, useRef, useState } from 'react'
|
||||||
import type { ConvViewProps } from '@deepseek-ai/dsh-client-ui-conversation/client'
|
import type { ConvViewProps } from '@deepseek-ai/dsh-client-ui-conversation/client'
|
||||||
|
import type { InjectFace } from '@deepseek-ai/dsh-client-ui-slots'
|
||||||
import type {
|
import type {
|
||||||
AssistantMessageNode, ConversationContext, ConversationNode, RequestView,
|
AssistantMessageNode, ConversationContext, RequestView,
|
||||||
|
SessionHistoryFace,
|
||||||
} from '@deepseek-ai/dsh-client-runtime/client'
|
} from '@deepseek-ai/dsh-client-runtime/client'
|
||||||
import {
|
import {
|
||||||
deriveTrajectoryContextBranches, trajectoryBranchContainsRequest,
|
deriveTrajectoryContextBranches, trajectoryBranchContainsRequest,
|
||||||
@@ -28,6 +30,7 @@ const EMPTY_REQUESTS: readonly RequestView[] = []
|
|||||||
|
|
||||||
/** Session-history paging needed by the event-complete trajectory view. */
|
/** Session-history paging needed by the event-complete trajectory view. */
|
||||||
export interface TrajectoryViewInjected {
|
export interface TrajectoryViewInjected {
|
||||||
|
hooks: { history: SessionHistoryFace }
|
||||||
loadAllHistory: (signal: AbortSignal) => Promise<void>
|
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(
|
function searchMatches(
|
||||||
turns: ReturnType<typeof deriveTrajectoryLayout>,
|
turns: ReturnType<typeof deriveTrajectoryLayout>,
|
||||||
query: string,
|
query: string,
|
||||||
@@ -137,7 +134,9 @@ function searchMatches(
|
|||||||
return matches
|
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 [collapsedTurns, setCollapsedTurns] = useState<ReadonlySet<number>>(EMPTY_IDS)
|
||||||
const [collapsedAssistants, setCollapsedAssistants] =
|
const [collapsedAssistants, setCollapsedAssistants] =
|
||||||
useState<ReadonlySet<number>>(EMPTY_IDS)
|
useState<ReadonlySet<number>>(EMPTY_IDS)
|
||||||
@@ -150,26 +149,22 @@ export function TrajectoryView({ useSession, loadAllHistory }: ConvViewProps & T
|
|||||||
const [searchQuery, setSearchQuery] = useState('')
|
const [searchQuery, setSearchQuery] = useState('')
|
||||||
const [selectedTimelineIndex, setSelectedTimelineIndex] = useState<number | null>(null)
|
const [selectedTimelineIndex, setSelectedTimelineIndex] = useState<number | null>(null)
|
||||||
const ledgerRef = useRef<HTMLDivElement>(null)
|
const ledgerRef = useRef<HTMLDivElement>(null)
|
||||||
const nodes = useSession(s => s.nodes)
|
const inspection = useHistory(snapshot => snapshot.inspection)
|
||||||
const inspection = useSession(s => s.inspection)
|
const nodes = inspection.eventNodes
|
||||||
const hasMore = useSession(s => s.hasMore)
|
const partial = inspection.partial
|
||||||
const openState = useSession(s => s.openState)
|
const runningCalls = inspection.runningCalls
|
||||||
const partial = useSession(s => s.partial)
|
const codeDispatches = inspection.codeDispatches
|
||||||
const runningCalls = useSession(s => s.runningCalls)
|
|
||||||
const codeDispatches = useSession(s => s.codeDispatches)
|
|
||||||
const loadAllHistoryRef = useRef(loadAllHistory)
|
const loadAllHistoryRef = useRef(loadAllHistory)
|
||||||
loadAllHistoryRef.current = loadAllHistory
|
loadAllHistoryRef.current = loadAllHistory
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const controller = new AbortController()
|
const controller = new AbortController()
|
||||||
if (openState === 'open' && hasMore) {
|
void loadAllHistoryRef.current(controller.signal)
|
||||||
void loadAllHistoryRef.current(controller.signal)
|
|
||||||
}
|
|
||||||
return () => { controller.abort() }
|
return () => { controller.abort() }
|
||||||
}, [hasMore, openState])
|
}, [])
|
||||||
const requests = inspection?.requests ?? EMPTY_REQUESTS
|
const requests = inspection.requests ?? EMPTY_REQUESTS
|
||||||
const callSchemas = inspection?.callSchemas
|
const callSchemas = inspection.callSchemas
|
||||||
const contexts = useMemo<readonly ConversationContext[]>(
|
const contexts = useMemo<readonly ConversationContext[]>(
|
||||||
() => inspection === undefined || inspection.contexts.length === 0
|
() => inspection.contexts.length === 0
|
||||||
? [{ id: 0, nodes }]
|
? [{ id: 0, nodes }]
|
||||||
: inspection.contexts,
|
: inspection.contexts,
|
||||||
[inspection, nodes],
|
[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')
|
if (currentBranch === undefined) throw new Error('trajectory branch projection must not be empty')
|
||||||
const selectedNodes = useMemo(() => {
|
const selectedNodes = useMemo(() => {
|
||||||
const selected = new Map(currentBranch.nodes.map(node => [node.seq, node]))
|
const selected = new Map(currentBranch.nodes.map(node => [node.seq, node]))
|
||||||
for (const node of nodes) {
|
for (const node of inspection.interruptedNodes) {
|
||||||
if (isInterruptedNode(node)) selected.set(node.seq, node)
|
selected.set(node.seq, node)
|
||||||
}
|
}
|
||||||
return [...selected.values()].sort((left, right) => left.seq - right.seq)
|
return [...selected.values()].sort((left, right) => left.seq - right.seq)
|
||||||
}, [currentBranch, nodes])
|
}, [currentBranch, inspection])
|
||||||
const selectedRequests = useMemo(
|
const selectedRequests = useMemo(
|
||||||
() => requests.filter(request =>
|
() => requests.filter(request =>
|
||||||
trajectoryBranchContainsRequest(currentBranch, 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
|
* into an undeclared slot throws — service waiting is what orders this
|
||||||
* apply after the declaring one.
|
* 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
|
* Client plugin body: register the trajectory view tab. The registration
|
||||||
@@ -30,12 +30,10 @@ export function apply(ctx: Context): void {
|
|||||||
order: 10,
|
order: 10,
|
||||||
label: 'Trajectory',
|
label: 'Trajectory',
|
||||||
inject: (sessionId: SessionId): TrajectoryViewInjected => {
|
inject: (sessionId: SessionId): TrajectoryViewInjected => {
|
||||||
const session = ctx.sessions.binding(sessionId)?.session
|
const history = ctx.sessionHistory.source(sessionId)
|
||||||
if (session === undefined) {
|
|
||||||
throw new Error(`ui-trajectory: session "${sessionId}" resolved no binding`)
|
|
||||||
}
|
|
||||||
return {
|
return {
|
||||||
loadAllHistory: signal => session.loadAllHistory(signal),
|
hooks: { history },
|
||||||
|
loadAllHistory: signal => history.loadAll(signal),
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
}, TrajectoryView)
|
}, TrajectoryView)
|
||||||
|
|||||||
@@ -10,13 +10,14 @@
|
|||||||
import { Context } from 'cordis'
|
import { Context } from 'cordis'
|
||||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||||
import { cleanup, fireEvent, render, screen } from '@testing-library/react'
|
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 { bindSnapshotSelector } from '@deepseek-ai/dsh-client-web-react'
|
||||||
import { createSnapshotStore } from '@deepseek-ai/dsh-client-runtime/client'
|
import { createSnapshotStore } from '@deepseek-ai/dsh-client-runtime/client'
|
||||||
import type { UseSession } from '@deepseek-ai/dsh-client-web-react'
|
import type { UseSession } from '@deepseek-ai/dsh-client-web-react'
|
||||||
import { SlotsService } from '@deepseek-ai/dsh-client-runtime/client'
|
import { SlotsService } from '@deepseek-ai/dsh-client-runtime/client'
|
||||||
import type {
|
import type {
|
||||||
ConversationSnapshot, RequestView, SessionId, SessionListState, WorkspaceListState,
|
ConversationSnapshot, RequestView, SessionHistoryFace, SessionHistoryInspection,
|
||||||
|
SessionHistorySnapshot, SessionId, SessionListState, WorkspaceListState,
|
||||||
} from '@deepseek-ai/dsh-client-runtime/client'
|
} from '@deepseek-ai/dsh-client-runtime/client'
|
||||||
import type { ConvViewProps, ViewTab } from '@deepseek-ai/dsh-client-ui-conversation/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'
|
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, inject } from '@deepseek-ai/dsh-client-ui-trajectory/client'
|
||||||
import { apply as nodeApply } from '@deepseek-ai/dsh-client-ui-trajectory'
|
import { apply as nodeApply } from '@deepseek-ai/dsh-client-ui-trajectory'
|
||||||
import type { TrajectoryTurnModel } from '../src/client/layout.ts'
|
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'
|
import { deriveTrajectoryTimeline } from '../src/client/timeline.ts'
|
||||||
|
|
||||||
const SID = 's1' as SessionId
|
const SID = 's1' as SessionId
|
||||||
@@ -54,6 +57,38 @@ const NODES = [
|
|||||||
},
|
},
|
||||||
] as unknown as ConversationSnapshot['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']) {
|
function fakeSession(nodes: ConversationSnapshot['nodes']) {
|
||||||
const store = createSnapshotStore({
|
const store = createSnapshotStore({
|
||||||
nodes, pending: [], partial: null,
|
nodes, pending: [], partial: null,
|
||||||
@@ -93,6 +128,13 @@ async function bench() {
|
|||||||
const ctx = new Context()
|
const ctx = new Context()
|
||||||
const slots = new SlotsService(ctx)
|
const slots = new SlotsService(ctx)
|
||||||
const loadAllHistory = vi.fn((_signal: AbortSignal) => Promise.resolve())
|
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.
|
// The conversation entry's role: declare the ring, then seed the chat entry.
|
||||||
slots.register({
|
slots.register({
|
||||||
name: 'root',
|
name: 'root',
|
||||||
@@ -104,11 +146,7 @@ async function bench() {
|
|||||||
// 'conversation' inject is an ordering edge; the bench declares the ring
|
// 'conversation' inject is an ordering edge; the bench declares the ring
|
||||||
// itself, so a stub satisfies the wait.
|
// itself, so a stub satisfies the wait.
|
||||||
ctx.provide('conversation', {})
|
ctx.provide('conversation', {})
|
||||||
ctx.provide('sessions', {
|
ctx.provide('sessionHistory', { source: () => history })
|
||||||
binding: (sessionId: SessionId) => sessionId === SID
|
|
||||||
? { session: { loadAllHistory } }
|
|
||||||
: undefined,
|
|
||||||
})
|
|
||||||
const fiber = ctx.plugin({ inject: [...inject], apply })
|
const fiber = ctx.plugin({ inject: [...inject], apply })
|
||||||
await fiber.await()
|
await fiber.await()
|
||||||
return { ctx, slots, fiber, loadAllHistory }
|
return { ctx, slots, fiber, loadAllHistory }
|
||||||
@@ -141,9 +179,17 @@ function mount(slots: SlotsService, nodes: ConversationSnapshot['nodes'] = NODES
|
|||||||
const injected = injectEntry === undefined
|
const injected = injectEntry === undefined
|
||||||
? {}
|
? {}
|
||||||
: injectEntry(SID)
|
: injectEntry(SID)
|
||||||
|
const injectedProps = 'hooks' in injected
|
||||||
|
? {
|
||||||
|
loadAllHistory: (injected as TrajectoryViewInjected).loadAllHistory,
|
||||||
|
useHistory: bindSnapshotSelector(
|
||||||
|
(injected as TrajectoryViewInjected).hooks.history,
|
||||||
|
),
|
||||||
|
}
|
||||||
|
: injected
|
||||||
return (
|
return (
|
||||||
<View
|
<View
|
||||||
{...injected}
|
{...injectedProps}
|
||||||
{...({ sessionId: SID, useSession, useSessions: emptySessions(), useWorkspaces: emptyWorkspaces() } as unknown as ConvViewProps)}
|
{...({ sessionId: SID, useSession, useSessions: emptySessions(), useWorkspaces: emptyWorkspaces() } as unknown as ConvViewProps)}
|
||||||
key={key}
|
key={key}
|
||||||
/>
|
/>
|
||||||
@@ -151,7 +197,6 @@ function mount(slots: SlotsService, nodes: ConversationSnapshot['nodes'] = NODES
|
|||||||
}) as unknown as ConversationSessionProps['renderSlot']
|
}) as unknown as ConversationSessionProps['renderSlot']
|
||||||
return render(
|
return render(
|
||||||
<ConversationSession
|
<ConversationSession
|
||||||
composer={null}
|
|
||||||
sessionId={SID}
|
sessionId={SID}
|
||||||
SessionProvider={({ children }) => children(SID)}
|
SessionProvider={({ children }) => children(SID)}
|
||||||
useSession={useSession}
|
useSession={useSession}
|
||||||
@@ -345,7 +390,10 @@ describe('timeline projection', () => {
|
|||||||
expect(deriveTrajectoryTimeline([])).toBeNull()
|
expect(deriveTrajectoryTimeline([])).toBeNull()
|
||||||
render(createElement(
|
render(createElement(
|
||||||
TrajectoryView,
|
TrajectoryView,
|
||||||
{ ...standaloneProps([]), loadAllHistory: () => Promise.resolve() },
|
{
|
||||||
|
...standaloneProps([]),
|
||||||
|
...standaloneHistory(historySnapshot([])),
|
||||||
|
},
|
||||||
))
|
))
|
||||||
expect(screen.getByRole('toolbar', { name: 'Trajectory toolbar' })).toBeTruthy()
|
expect(screen.getByRole('toolbar', { name: 'Trajectory toolbar' })).toBeTruthy()
|
||||||
expect(screen.queryByRole('row')).toBeNull()
|
expect(screen.queryByRole('row')).toBeNull()
|
||||||
@@ -386,9 +434,9 @@ describe('TrajectoryView branches', () => {
|
|||||||
completedAt: startSeq * 1_000 + 100,
|
completedAt: startSeq * 1_000 + 100,
|
||||||
status: 'complete',
|
status: 'complete',
|
||||||
})
|
})
|
||||||
const store = createSnapshotStore({
|
const store = createSnapshotStore(historySnapshot(
|
||||||
nodes: [retained, current],
|
[retained, abandoned, current],
|
||||||
inspection: {
|
{
|
||||||
eventNodes: [retained, abandoned, current],
|
eventNodes: [retained, abandoned, current],
|
||||||
contexts: [
|
contexts: [
|
||||||
{ id: 0, nodes: [retained, abandoned] },
|
{ id: 0, nodes: [retained, abandoned] },
|
||||||
@@ -403,17 +451,12 @@ describe('TrajectoryView branches', () => {
|
|||||||
requests: [request(2, 1), request(4, 2)],
|
requests: [request(2, 1), request(4, 2)],
|
||||||
callSchemas: new Map(),
|
callSchemas: new Map(),
|
||||||
},
|
},
|
||||||
openState: 'open' as const,
|
))
|
||||||
hasMore: false,
|
|
||||||
partial: null,
|
|
||||||
runningCalls: [] as ConversationSnapshot['runningCalls'],
|
|
||||||
codeDispatches: new Map(),
|
|
||||||
})
|
|
||||||
|
|
||||||
const view = render(
|
const view = render(
|
||||||
<TrajectoryView
|
<TrajectoryView
|
||||||
{...standaloneProps([])}
|
{...standaloneProps([])}
|
||||||
useSession={bindSnapshotSelector(store) as unknown as UseSession<ConversationSnapshot>}
|
useHistory={bindSnapshotSelector(store)}
|
||||||
loadAllHistory={vi.fn(() => Promise.resolve())}
|
loadAllHistory={vi.fn(() => Promise.resolve())}
|
||||||
/>,
|
/>,
|
||||||
)
|
)
|
||||||
@@ -441,25 +484,21 @@ describe('TrajectoryView branches', () => {
|
|||||||
error: { name: 'Interrupted', code: 'interrupted' },
|
error: { name: 'Interrupted', code: 'interrupted' },
|
||||||
callView: null, resultView: null,
|
callView: null, resultView: null,
|
||||||
} as unknown as ConversationSnapshot['nodes'][number]
|
} as unknown as ConversationSnapshot['nodes'][number]
|
||||||
const store = createSnapshotStore({
|
const store = createSnapshotStore(historySnapshot(
|
||||||
nodes: [retained, interruptedAssistant, interruptedTool],
|
[retained],
|
||||||
inspection: {
|
{
|
||||||
eventNodes: [retained],
|
eventNodes: [retained],
|
||||||
contexts: [{ id: 0, nodes: [retained] }],
|
contexts: [{ id: 0, nodes: [retained] }],
|
||||||
requests: [],
|
requests: [],
|
||||||
callSchemas: new Map(),
|
callSchemas: new Map(),
|
||||||
|
interruptedNodes: [interruptedAssistant, interruptedTool],
|
||||||
},
|
},
|
||||||
openState: 'open' as const,
|
))
|
||||||
hasMore: false,
|
|
||||||
partial: null,
|
|
||||||
runningCalls: [] as ConversationSnapshot['runningCalls'],
|
|
||||||
codeDispatches: new Map(),
|
|
||||||
})
|
|
||||||
|
|
||||||
render(
|
render(
|
||||||
<TrajectoryView
|
<TrajectoryView
|
||||||
{...standaloneProps([])}
|
{...standaloneProps([])}
|
||||||
useSession={bindSnapshotSelector(store) as unknown as UseSession<ConversationSnapshot>}
|
useHistory={bindSnapshotSelector(store)}
|
||||||
loadAllHistory={vi.fn(() => Promise.resolve())}
|
loadAllHistory={vi.fn(() => Promise.resolve())}
|
||||||
/>,
|
/>,
|
||||||
)
|
)
|
||||||
|
|||||||
Reference in New Issue
Block a user