Merge worktree/schedule-conversational-after into worktree/schedule-explicit-at

This commit is contained in:
Tianyi Cui
2026-08-11 19:20:38 +08:00
3663 changed files with 86658 additions and 37212 deletions

View File

@@ -15,8 +15,8 @@
* — a cold session's host Agent is already disposed while its client actx
* stays alive for history viewing.
*/
import { Context as CordisContext } from 'cordis'
import type { Context, Fiber } from 'cordis'
import { Context as CordisContext } from '@deepseek-ai/cordis'
import type { Context, Fiber } from '@deepseek-ai/cordis'
import type { SessionId } from '@deepseek-ai/dsh-client-connection/client'
import type { TypeRTClientRemote, TypeRTRemoteScopeApi } from '@deepseek-ai/dsh-type-meta'

View File

@@ -110,6 +110,17 @@ export interface ConversationViewNode {
readonly data: unknown
}
/** Merge-extensible immutable snapshots published by registered view targets. */
export interface ConversationViewSnapshotMap {}
/** Stable reader over the latest snapshot of every registered view target. */
export interface ConversationViewSnapshotStore {
/** @param target - registered view target. @returns its current snapshot. */
get<Target extends Extract<keyof ConversationViewSnapshotMap, string>>(
target: Target,
): ConversationViewSnapshotMap[Target] | undefined
}
/** Final Chat render unit produced directly by a business Definition. */
export interface ChatConversationViewNode extends ConversationViewNode {
readonly target: 'chat'
@@ -159,6 +170,8 @@ export type ConversationLocationDataScope = 'step' | 'turn'
/** One independently registered business Event-to-Node state machine. */
export interface ConversationNodeDefinition<State = unknown> {
readonly kind: string
/** Sole view target owned by this Definition; omitted for state-only Contexts. */
readonly target?: string
/**
* Extract this Definition's stable business identity from one event.
* @param event - raw Session event; no Context or history access is available.
@@ -207,15 +220,11 @@ export interface ConversationNodeDefinition<State = unknown> {
scope: ConversationLocationDataScope,
): ConversationLocationData | null
/**
* Materialize one final Node for a registered view target.
* Materialize one final Node for this Definition's declared view target.
* @param context - latest complete Context.
* @param target - registered view target such as `chat`.
* @returns final Node, or null when this Context is not currently visible.
*/
buildViewNode(
context: ConversationNodeContext<State>,
target: string,
): ConversationViewNode | null
buildViewNode?(context: ConversationNodeContext<State>): ConversationViewNode | null
}
/** Reference-stable Turn/Step facts published beside view Nodes. */

View File

@@ -1,43 +0,0 @@
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
/** Absolute sequence of the first loaded raw event, or zero for an empty window. */
baseSeq: number
inspection: SessionHistoryInspection
}
/** Read-only history source addressed by session id. */
export interface SessionHistoryFace
extends ObservableSnapshot<SessionHistorySnapshot> {
readonly sessionId: SessionId
/**
* Load the current tail without reading older pages.
* @param signal - Consumer lifetime.
* @returns When the tail is ready or loading fails.
*/
loadTail(signal?: AbortSignal): Promise<void>
/**
* Prepend one older page when the current window has a predecessor.
* @param signal - Consumer lifetime.
* @returns Whether the loaded window advanced.
*/
loadOlder(signal?: AbortSignal): Promise<boolean>
}
/** 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
}

View File

@@ -7,9 +7,9 @@
* must stub); runtime-internal entry points (history staging, wire-frame
* dispatch) stay on the class, invisible out here.
*/
import type { ContentBlock } from '@deepseek-ai/dsh-llm/types'
import type { AttachmentIdType, ImageAttachmentRef } from '@deepseek-ai/dsh-attachment'
import type {
MessageId, QueueAction, RpcResult, SessionId,
MessageId, PromptContentPart, QueueAction, RpcResult, SessionId,
} from '@deepseek-ai/dsh-client-connection/client'
import type { ConversationSnapshot } from '../sessions/conversation.ts'
import type { ObservableSnapshot } from './store.ts'
@@ -33,11 +33,19 @@ export interface ISession {
readonly projections: ProjectionsFace
/**
* Send a prompt into the session.
* @param content - model-facing content blocks.
* @param content - text plus browser-owned temporary image uploads.
* @param mode - 'queue' appends a turn; 'steer' interrupts the running one.
* @returns acceptance, or the business error (also mirrored into snapshot.promptError).
*/
prompt(content: ContentBlock[], mode: 'queue' | 'steer'): Promise<RpcResult<{ accepted: true }>>
prompt(content: PromptContentPart[], mode: 'queue' | 'steer'): Promise<RpcResult<{ accepted: true }>>
/**
* Resolve one durable image referenced by this session.
* @param attachmentId - opaque id found in the folded session log.
* @returns the authenticated reference and decoded bytes.
*/
readAttachment(
attachmentId: AttachmentIdType,
): Promise<RpcResult<{ attachment: ImageAttachmentRef; data: Uint8Array }>>
/**
* Apply one edit, remove, or strict steer action to a still-pending queue occurrence.
* @param itemId - agent-owned inbox occurrence identity.

View File

@@ -7,7 +7,7 @@
* [SessionsPort](./sessions-port.ts). Widening this interface is the
* explicit act of widening what features may do to the sessions domain.
*/
import type { Context } from 'cordis'
import type { Context } from '@deepseek-ai/cordis'
import type {
RpcResult, SessionId, SubagentAddress,
} from '@deepseek-ai/dsh-client-connection/client'
@@ -62,6 +62,15 @@ export interface ISessions {
* @returns completion of the current or newly started refresh.
*/
refreshSubagents(parentSessionId: SessionId): Promise<void>
/**
* Record the composition one session now runs. The agent-preset seat calls
* this after a successful blank-session switch, so the header label moves
* with the composition instead of waiting for the next full list refresh.
* @param sessionId - the switched session.
* @param agentPreset - the preset id the host confirmed.
*/
noteAgentPreset(sessionId: SessionId, agentPreset: string): void
/** Clear the current selection into the no-session view state. */
clear(): void
/**

View File

@@ -18,7 +18,7 @@ import type {
} from '@deepseek-ai/dsh-client-ui-slots'
// Store contract types are ui-slots authority; re-exported beside the engine
// so store consumers get one import surface.
// so store consumers get one import path.
export type {
ActionsDecl, BakedActions, BoundActions, StoreFactory, StoreHandle, StoreInstance, StoreSpec,
} from '@deepseek-ai/dsh-client-ui-slots'
@@ -165,7 +165,7 @@ function deepFreeze(value: unknown): void {
/** A live engine instance: the contract instance plus the raw engine store. */
export interface EngineStoreInstance<T, A extends ActionsDecl<T>> extends StoreInstance<T, A> {
/** The underlying engine store (framework/test surface; components never see it). */
/** The underlying engine store (framework/test API; components never see it). */
readonly store: SnapshotStore<T>
}

View File

@@ -27,11 +27,11 @@ export interface IWorkspaces {
*/
startSession(workspaceId?: WorkspaceId): void
/**
* Create a Workspace by name or register an existing path.
* @param input - exactly one Host create spelling.
* Register an existing path as a Workspace.
* @param input - the Host create payload.
* @returns the created or idempotently resolved Workspace.
*/
create(input: { name: string } | { path: string }): Promise<WorkspaceView>
create(input: { path: string }): Promise<WorkspaceView>
/**
* Open the Host's native directory picker.
* @returns the selected path, or null when the user cancelled.

View File

@@ -1,4 +1,4 @@
import { Service } from 'cordis'
import { Service } from '@deepseek-ai/cordis'
/** Shared lifecycle and stable-entry storage for one Conversation Definition registry. */
export abstract class ConversationDefinitionRegistry<Definition> extends Service {

View File

@@ -1,4 +1,4 @@
import type { Context } from 'cordis'
import type { Context } from '@deepseek-ai/cordis'
import type { ConversationNodeDefinition } from '../contract/conversation.ts'
import { ConversationDefinitionRegistry } from './definition-registry.ts'
@@ -17,6 +17,7 @@ export class ConversationEventRegistry extends ConversationDefinitionRegistry<Co
* @returns idempotent disposer.
*/
register(definition: ConversationNodeDefinition): () => void {
assertDefinitionTarget(definition)
return this.registerDefinition(
definition.kind,
definition,
@@ -31,6 +32,9 @@ export class ConversationEventRegistry extends ConversationDefinitionRegistry<Co
* @returns idempotent disposer.
*/
registerFallback(definition: ConversationNodeDefinition): () => void {
assertDefinitionTarget(definition)
const target = definition.target
if (target === undefined) throw new Error('conversation fallback Definition must declare a target')
if (this.fallback !== undefined) throw new Error('conversation fallback Definition is already registered')
const owner = this.ctx
const dispose = owner.effect(() => {
@@ -52,5 +56,12 @@ export class ConversationEventRegistry extends ConversationDefinitionRegistry<Co
fallbackEntry(): ConversationNodeDefinition | undefined {
return this.fallback
}
}
function assertDefinitionTarget(definition: ConversationNodeDefinition): void {
if ((definition.target === undefined) !== (definition.buildViewNode === undefined)) {
throw new Error(
`conversation Definition "${definition.kind}" must declare target and buildViewNode together`,
)
}
}

View File

@@ -1,4 +1,4 @@
import type { Context } from 'cordis'
import type { Context } from '@deepseek-ai/cordis'
import type { ConversationViewDefinition } from '../contract/conversation.ts'
import { ConversationDefinitionRegistry } from './definition-registry.ts'

View File

@@ -1,12 +1,11 @@
/** Browser runtime services for slots, sessions, workspaces, and connection-stream delivery. */
import type { Context } from 'cordis'
import type { Context } from '@deepseek-ai/cordis'
import type { ConnectionHandle, SessionId } from '@deepseek-ai/dsh-client-connection/client'
import type { TypeRTContext } from '@deepseek-ai/dsh-type-meta'
import type { MaybeSnapshotSelectorHook, SnapshotSelectorHook } from '@deepseek-ai/dsh-client-ui-slots'
import { SlotsService } from './slots.ts'
import { SessionsService } from './sessions/service.ts'
import type { SessionListState } from './sessions/service.ts'
import { SessionHistoryService } from './session-history/service.ts'
import { WorkspacesService } from './workspaces/service.ts'
import type { ConversationSnapshot } from './sessions/conversation.ts'
import type { UseProjection } from './sessions/projection-store.ts'
@@ -28,12 +27,12 @@ export type {
ConversationLocation, ConversationMatch, ConversationMatchResult,
ConversationNodeContext, ConversationNodeDefinition, ConversationPreviousContext,
ConversationPublication, ConversationTimelineSnapshot, ConversationTurnDataMap, ConversationViewBuilder,
ConversationViewDefinition, ConversationViewNode, StepLocation, TurnLocation,
ConversationViewDefinition, ConversationViewNode, ConversationViewSnapshotMap,
ConversationViewSnapshotStore, StepLocation, TurnLocation,
} from './contract/conversation.ts'
export type { ConversationRuntime } from './sessions/conversation-assembler.ts'
export type { RootOwnerProps } from './slots.ts'
export { SessionCreateError, SessionsService, scopeOf, workspaceTitleOf } from './sessions/service.ts'
export { SessionHistoryService } from './session-history/service.ts'
export { indexSubagentDescendants } from './sessions/subagent-lineage.ts'
export type { SubagentDescendantSummary } from './sessions/subagent-lineage.ts'
// The provide channel is shared with the client test runtime (one
@@ -43,19 +42,18 @@ export type { SessionProvideChannelHost } from './sessions/provide.ts'
export { createScope } from './agents/scope.ts'
export type { AgentScopeHandle } from './agents/scope.ts'
export { DirectoryBrowseError, WorkspaceCreateError, WorkspacesService } from './workspaces/service.ts'
export { bindSettingsScope, SettingsScopeController } from './settings-scope.ts'
export type { SettingsScope, SettingsScopeSnapshot, SettingsScopeSpec } from './settings-scope.ts'
export { resolveWorkspacePath } from './workspaces/path.ts'
export type { Session } from './sessions/session.ts'
export type { ISession, ProjectionsFace, SessionFace } from './contract/session.ts'
export type {
ISessionHistory, SessionHistoryFace, SessionHistorySnapshot,
} from './contract/session-history.ts'
export type { AgentContext, ISessions } from './contract/sessions.ts'
export type { IWorkspaces } from './contract/workspaces.ts'
export type {
SessionBinding, SessionListState, SessionProvideContribution, SessionProvideDescriptor, SessionSummary,
} from './sessions/service.ts'
export type { SessionListPhase, SessionSearchResultItem, SubagentCatalogSnapshot } from './sessions/manager.ts'
export type { SubagentAddress } from '@deepseek-ai/dsh-client-connection/client'
export type { SubagentAddress, TaskView } from '@deepseek-ai/dsh-client-connection/client'
export type { WorkspaceListPhase } from './workspaces/manager.ts'
export type { WorkspaceListState } from './workspaces/service.ts'
export type {
@@ -74,7 +72,9 @@ export type {
LegacyConversationSlice, PartialAssistant, RunningToolCall,
SteeringMessageNode, TodoItem, ToolCallBlock, ToolResultNode, TurnErrorNode, UnknownSurfaceNode, UserMessageNode,
} from './sessions/conversation.ts'
export { EMPTY_CHAT_SNAPSHOT, toAssistantBlock, toAssistantBlocks } from './sessions/conversation.ts'
export {
EMPTY_CHAT_SNAPSHOT, EMPTY_CONVERSATION_VIEWS, toAssistantBlock, toAssistantBlocks,
} from './sessions/conversation.ts'
export { emptyAssistantBlock } from './sessions/partial.ts'
export { isTokenDelta } from './sessions/assistant-timing.ts'
export { contextForm, contextProvenance } from './sessions/context-provenance.ts'
@@ -88,8 +88,6 @@ export type {
export type {
ConversationPromptSnapshot, RequestInspectionSnapshot, RequestPromptChange, RequestView,
} from './sessions/request-inspection.ts'
export type { ConversationHistoryProjection } from './session-history/history-fold.ts'
export type { SessionHistoryInspection } from './sessions/history.ts'
export { PendingWait } from './sessions/pending.ts'
export type {
PendingInteraction, PendingInteractionStatus, PendingKind, PendingPayloads,
@@ -144,7 +142,7 @@ declare module '@deepseek-ai/dsh-client-ui-slots' {
}
}
declare module 'cordis' {
declare module '@deepseek-ai/cordis' {
interface Events {
/**
* A slot's definition or registration set changed.
@@ -181,6 +179,18 @@ declare module 'cordis' {
* @mode emit
*/
'models/changed'(): void
/**
* One session's agent preset changed (host/session-preset-changed
* passthrough), so everything its composition decides — the command
* catalog, the skill catalog — is stale for that session and no other.
* Every connected client observes it, not only the one that issued the
* switch. Subscribers refetch their own session-keyed caches; the frame
* carries no catalog.
* @mode emit
* @param sessionId - the session whose composition changed.
* @param agentPreset - the preset it now runs.
*/
'session/preset-changed'(sessionId: SessionId, agentPreset: string): void
/**
* A connection generation was (re-)established. Wire-derived caches must
* treat their state as stale and repull (commands directory; the queue
@@ -197,8 +207,6 @@ declare module 'cordis' {
conversationViews: import('./conversation/view-registry.ts').ConversationViewRegistry
/** The outward face only; the concrete service stays inside the runtime. */
sessions: import('./contract/sessions.ts').ISessions
/** Read-only history sources isolated from Chat sessions and workspace state. */
sessionHistory: import('./contract/session-history.ts').ISessionHistory
/** The outward face only; the concrete service stays inside the runtime. */
workspaces: import('./contract/workspaces.ts').IWorkspaces
}
@@ -221,7 +229,6 @@ export function apply(ctx: Context): void {
ctx.typert.contexts.registerClient('agent', {
identity: candidate => sessions.scopeOf(candidate),
})
const sessionHistory = new SessionHistoryService(ctx, connection.api)
const workspaces = new WorkspacesService(ctx, connection.api, sessions)
ctx.effect(
() => workspaces.startInitialSelection(),
@@ -230,38 +237,26 @@ export function apply(ctx: Context): void {
const loop = connection.start({
onMuxEnvelope: (envelope) => {
sessions.handleMuxEnvelope(envelope)
try {
sessionHistory.handleMuxEnvelope(envelope)
} catch (error) {
console.error('[web-runtime] history frame routing failed:', error)
}
},
onHostEnvelope: (envelope) => {
sessions.handleHostEnvelope(envelope)
workspaces.handleHostEnvelope(envelope)
// Typed-event bridge: the session layer ignores registry frames (no
// session routing); consumers (command directory caches, the settings
// and model surfaces) subscribe on ctx.
// and model services) subscribe on ctx.
const frame = envelope.payload
if (frame.type === 'host/commands-changed') ctx.emit('commands/changed')
else if (frame.type === 'host/session-preset-changed') {
ctx.emit('session/preset-changed', frame.sessionId, frame.agentPreset)
}
else if (frame.type === 'host/settings-changed') ctx.emit('settings/changed', frame.ns)
else if (frame.type === 'host/credentials-changed') ctx.emit('credentials/changed', frame.ref)
else if (frame.type === 'host/models-changed') ctx.emit('models/changed')
try {
sessionHistory.handleHostEnvelope(envelope)
} catch (error) {
console.error('[web-runtime] history host-frame routing failed:', error)
}
},
onConnected: () => {
sessions.handleConnected()
workspaces.handleConnected()
ctx.emit('connection/reset')
try {
sessionHistory.handleConnected()
} catch (error) {
console.error('[web-runtime] history reconnect failed:', error)
}
},
onStateChange: (state) => {
// Generation death fires before any next-generation frame can arrive
@@ -269,11 +264,6 @@ export function apply(ctx: Context): void {
// the only safe moment to drop generation-scoped interaction state.
if (state === 'reconnecting') {
sessions.handleDisconnected()
try {
sessionHistory.handleDisconnected()
} catch (error) {
console.error('[web-runtime] history disconnect failed:', error)
}
}
},
})

View File

@@ -1,428 +0,0 @@
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, ConversationNode,
PartialAssistant, RunningToolCall,
} from '../sessions/conversation.ts'
import { toAssistantBlocks } from '../sessions/conversation.ts'
import { contextForm, contextProvenance } from '../sessions/context-provenance.ts'
import { SteeringHistory } from '../sessions/steering-history.ts'
import type {
ConversationContext, ConversationContextOriginKind,
} from '../sessions/conversation-context.ts'
import type { ConversationPromptSnapshot } from '../sessions/request-inspection.ts'
import { PartialAccumulator } from '../sessions/partial.ts'
import type { AssistantStepMetadata } from '../sessions/assistant-timing.ts'
import { indexAssistantStepTiming, settledAssistantTiming } from '../sessions/assistant-timing.ts'
import { ToolCallTree } from '../sessions/tool-call-tree.ts'
interface CallIndexEntry {
name: string
argsRaw: string
time: number
callView: ToolCallView | null
}
interface FoldedContext {
generation: number
nodes: readonly number[]
originSeq?: number
}
/** 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[]
}
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 foldContexts(events: readonly SessionEvent[]): readonly FoldedContext[] {
const replay: SessionEvent[] = []
const originalSeqs: number[] = []
const rebasedSeqByOriginal = new Map<number, number>()
const surface = new SurfaceManager(replay)
const contexts: FoldedContext[] = []
let generation = 0
let originSeq: number | undefined
const originalNodes = () => surface.nodes.map((seq) => {
const original = originalSeqs[seq]
if (original === undefined) throw new Error(`rebased surface seq ${seq} has no origin`)
return original
})
for (const event of events) {
if (!isSurfaceEvent(event)) continue
if (event.surfaceOp !== 'append') {
contexts.push({
generation,
nodes: originalNodes(),
...(originSeq === undefined ? {} : { originSeq }),
})
generation++
originSeq = event.seq
}
const rebasedSeq = replay.length
const {
sourceEventSeqs: rawSources,
...eventWithoutSources
} = event as SessionEvent & { sourceEventSeqs?: readonly number[] }
const mappedSourceEventSeqs = rawSources?.flatMap((seq) => {
const rebased = rebasedSeqByOriginal.get(seq)
return rebased === undefined ? [] : [rebased]
})
const sourceEventSeqs = mappedSourceEventSeqs?.length === 0
? undefined
: mappedSourceEventSeqs
const surfaceOp = event.surfaceOp === 'append'
? event.surfaceOp
: {
...event.surfaceOp,
start: rebasedSeqByOriginal.get(event.surfaceOp.start) ?? event.surfaceOp.start,
end: rebasedSeqByOriginal.get(event.surfaceOp.end) ?? event.surfaceOp.end,
}
originalSeqs.push(event.seq)
rebasedSeqByOriginal.set(event.seq, rebasedSeq)
replay.push({
...eventWithoutSources,
seq: rebasedSeq,
surfaceOp,
...(sourceEventSeqs === undefined ? {} : { sourceEventSeqs }),
} as SessionEvent)
}
contexts.push({
generation,
nodes: originalNodes(),
...(originSeq === undefined ? {} : { originSeq }),
})
return contexts
}
// History projection owns its node mapping so Chat's live adapter remains free
// of inspection metadata and lifecycle coupling.
/* jscpd:ignore-start */
function materializeNode(
event: SessionEvent,
callIndex: ReadonlyMap<string, CallIndexEntry>,
resultView: ToolResultView | null,
assistantTiming: AssistantTiming | undefined,
requestConfig: AssistantRequestConfig | undefined,
steering: boolean,
): 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,
provenance: contextProvenance(event.data.source),
form: contextForm(event.data.source),
}
}
if (steering) {
return {
kind: 'steering', messageId: event.data.id,
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 '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,
subCalls: [],
}
}
default:
return {
kind: 'unknown', seq: event.seq, time: event.time,
type: event.type, data: (event as { data?: unknown }).data,
}
}
}
/* jscpd:ignore-end */
interface TransientProjection extends Pick<
ConversationHistoryProjection,
'interruptedNodes' | 'partial' | 'runningCalls'
> {
toolCallTree: ToolCallTree
}
function projectTransient(entries: readonly HistoryEntry[]): TransientProjection {
let partial: PartialAccumulator | null = null
const openCalls = new Map<string, RunningToolCall>()
const interruptedNodes: ConversationNode[] = []
const toolCallTree = new ToolCallTree()
for (const entry of entries) {
const { event } = entry
if (toolCallTree.apply(event)) 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':
// History reconstructs its own in-flight index; this intentionally
// mirrors the published Chat node shape, not Chat's mutable state.
/* jscpd:ignore-start */
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,
subCalls: [],
})
/* jscpd:ignore-end */
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)
// Interrupted terminal nodes are reconstructed independently so a
// Trajectory replay cannot observe Session's frozen-node lifecycle.
/* jscpd:ignore-start */
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,
subCalls: [],
})
/* jscpd:ignore-end */
}
break
}
default:
break
}
}
return {
interruptedNodes,
partial: partial?.toPartial() ?? null,
runningCalls: [...openCalls.values()],
toolCallTree,
}
}
/**
* 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 steeringHistory = new SteeringHistory()
const steeringSeqs = new Set<number>()
for (const event of events) {
if (steeringHistory.apply(event)) steeringSeqs.add(event.seq)
}
const baseSeq = events[0]?.seq ?? 0
const eventsBySeq = new Map(events.map(event => [event.seq, event]))
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)
}
indexAssistantStepTiming(assistantSteps, event)
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 === 'assistant/message') {
assistantTimings.set(
event.seq,
settledAssistantTiming(assistantSteps, event.data.turn, event.data.step, 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 = eventsBySeq.get(seq)
if (event === undefined || !isSurfaceEligibleType(event.type)) return
const node = materializeNode(
event,
callIndex,
resultViews.get(seq) ?? null,
assistantTimings.get(seq),
assistantRequestConfigs.get(seq),
steeringSeqs.has(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(events).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 = eventsBySeq.get(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,
}]
}
}
const transient = projectTransient(entries)
const projectedEventNodes = transient.toolCallTree.projectNodes(eventNodes)
const projectedContexts = contexts.map((context): ConversationContext => {
const nodes = transient.toolCallTree.projectNodes(context.nodes)
return nodes === context.nodes ? context : { ...context, nodes }
})
return {
eventNodes: projectedEventNodes,
contexts: projectedContexts,
interruptedNodes: transient.toolCallTree.projectNodes(transient.interruptedNodes),
partial: transient.partial,
runningCalls: transient.toolCallTree.projectRunningCalls(transient.runningCalls),
}
}

View File

@@ -1,66 +0,0 @@
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()
}
}

View File

@@ -1,432 +0,0 @@
import type {
HistoryEntry, IApiClient, MuxFrame, RpcError, SessionId,
} from '@deepseek-ai/dsh-client-connection/client'
import type { SessionEvent } from '@deepseek-ai/dsh-session/types'
import { transportError } from '@deepseek-ai/dsh-host-apiproxy/api'
import type {
SessionHistoryFace, SessionHistorySnapshot,
} from '../contract/session-history.ts'
import {
compactHistoryInspectionEntries, createHistoryInspection,
} from '../sessions/history.ts'
import { Notifier } from '../sessions/notifier.ts'
import { isVisibleAssistantChunk, PartialAccumulator } from '../sessions/partial.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: HistoryEntry[] = []
private inspectionEntries: 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 streamPublishToken: object | null = null
private streamPartial: PartialAccumulator | 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 current tail without reading older pages.
* @param signal - Consumer lifetime.
* @returns When the tail is ready or loading fails.
*/
async loadTail(signal?: AbortSignal): Promise<void> {
if (isAborted(signal)) return
this.trackConsumer(signal)
await this.open()
}
/**
* Prepend one older page when the current window has a predecessor.
* @param signal - Consumer lifetime.
* @returns Whether the loaded window advanced.
*/
async loadOlder(signal?: AbortSignal): Promise<boolean> {
if (isAborted(signal)) return false
this.trackConsumer(signal)
await this.open()
if (isAborted(signal)) return false
const previousBaseSeq = this.baseSeq
await this.loadOlderPage()
return this.baseSeq !== previousBaseSeq
}
/**
* 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.publishDirtyNow()
}
}
/** 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.inspectionEntries = []
this.baseSeq = 0
this.hasMore = false
this.state = 'cold'
this.error = null
this.publishDirtyNow()
void this.open()
}
/** 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 = []
this.streamPublishToken = null
this.streamPartial = null
}
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.publishDirtyNow()
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.publishDirtyNow()
}
}
private loadOlderPage(): 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.inspectionEntries = compactHistoryInspectionEntries([...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.publishDirtyNow()
})
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
this.inspectionEntries = compactHistoryInspectionEntries([...this.entries])
const buffered = this.liveBuffer
this.liveBuffer = []
for (const entry of buffered) this.appendLive(entry)
this.publishDirtyNow()
}
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
}
if (
entry.event.type === 'assistant/chunk'
&& entry.event.data.chunk.type !== 'usage'
) {
if (!this.appendIncrementalChunk(entry, entry.event)) return
this.publishStreamDirty()
return
}
this.appendLive(entry)
this.publishDirtyNow()
}
private appendLive(entry: HistoryEntry): void {
const tailSeq = this.tailSeq()
if (tailSeq !== null && entry.event.seq <= tailSeq) return
this.entries.push(entry)
this.inspectionEntries = [...this.inspectionEntries, entry]
if (entry.event.type === 'assistant/message') {
this.inspectionEntries = compactHistoryInspectionEntries(this.inspectionEntries)
}
}
/** Append a chunk against the cached finalized projection; false means no visible publish. */
private appendIncrementalChunk(
entry: HistoryEntry,
event: SessionEvent<'assistant/chunk'>,
): boolean {
const { turn, step, chunk } = event.data
if (!isVisibleAssistantChunk(chunk.type)) {
const inspection = this.currentInspection()
this.appendLive(entry)
this.inspectionCache = { entries: this.inspectionEntries, value: inspection }
return false
}
const base = this.currentInspection()
if (
this.streamPartial === null
|| this.streamPartial.turn !== turn
|| this.streamPartial.step !== step
) {
const current = base.partial
this.streamPartial = new PartialAccumulator(
turn,
step,
current?.turn === turn && current.step === step ? current.blocks : [],
)
}
this.streamPartial.push(chunk)
this.appendLive(entry)
this.inspectionCache = {
entries: this.inspectionEntries,
value: { ...base, partial: this.streamPartial.toPartial() },
}
return true
}
/** Coalesce token-stream projection and rendering work to one publish per browser frame. */
private publishStreamDirty(): void {
if (this.streamPublishToken !== null) return
const token = {}
this.streamPublishToken = token
const publish = () => {
if (this.streamPublishToken !== token) return
this.streamPublishToken = null
this.notifier.markDirty()
}
if (typeof globalThis.requestAnimationFrame === 'function') {
globalThis.requestAnimationFrame(publish)
} else {
queueMicrotask(publish)
}
}
/** Publish structural changes immediately and invalidate an older scheduled stream publish. */
private publishDirtyNow(): void {
this.streamPublishToken = null
this.streamPartial = null
this.notifier.markDirty()
}
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 {
return {
state: this.state,
error: this.error,
hasMore: this.hasMore,
baseSeq: this.baseSeq,
inspection: this.currentInspection(),
}
}
/** Inspection pinned to the source's current immutable entry array. */
private currentInspection(): SessionHistorySnapshot['inspection'] {
if (this.inspectionCache?.entries !== this.inspectionEntries) {
const entries = this.inspectionEntries
this.inspectionCache = {
entries,
value: createHistoryInspection(() => entries),
}
}
return this.inspectionCache.value
}
}

View File

@@ -2,7 +2,8 @@ import type {
ConversationContextReader, ConversationEventInput, ConversationLocationData, ConversationMatch,
ConversationNodeContext, ConversationNodeDefinition, ConversationPreviousContext,
ConversationLocationDataScope, ConversationPublication, ConversationViewBuilder,
ConversationViewDefinition, ConversationViewNode,
ConversationViewDefinition, ConversationViewNode, ConversationViewSnapshotMap,
ConversationViewSnapshotStore,
} from '../contract/conversation.ts'
import { conversationContextKey } from '../contract/conversation.ts'
import {
@@ -133,7 +134,7 @@ export interface ConversationViewDefinitions {
* Session-owned incremental engine that assembles business Contexts from a
* contiguous Event window and materializes registered view snapshots.
*/
export class ConversationNodeAssembler {
export class ConversationNodeAssembler implements ConversationViewSnapshotStore {
private readonly contexts = new Map<string, InternalContext>()
private readonly contextsByKind = new Map<string, InternalContext[]>()
private readonly contextsBySeq = new Map<number, Set<InternalContext>>()
@@ -266,11 +267,11 @@ export class ConversationNodeAssembler {
const allByTarget = new Map<string, ConversationViewNode[]>()
for (const target of this.views.keys()) allByTarget.set(target, [])
for (const context of this.contexts.values()) {
for (const target of this.views.keys()) {
const node = this.buildNode(context, target)
context.current.set(target, node)
if (node !== null) allByTarget.get(target)?.push(node)
}
const target = context.definition.target
if (target === undefined || !this.views.has(target)) continue
const node = this.buildNode(context, target)
context.current.set(target, node)
if (node !== null) allByTarget.get(target)?.push(node)
}
for (const view of this.views.values()) {
view.snapshot = view.builder.replace({
@@ -288,17 +289,17 @@ export class ConversationNodeAssembler {
for (const target of this.views.keys()) upsertsByTarget.set(target, [])
if (this.applyDirtyLocationData()) this.timelineDirty = true
for (const context of this.dirty) {
for (const target of this.views.keys()) {
const previous = context.current.get(target) ?? null
const node = this.buildNode(context, target)
if (node === null && previous !== null) {
throw new Error(
`conversation Definition "${context.kind}" withdrew materialized target "${target}"; return the same key with hidden visibility instead`,
)
}
context.current.set(target, node)
if (node !== null) upsertsByTarget.get(target)?.push(node)
const target = context.definition.target
if (target === undefined || !this.views.has(target)) continue
const previous = context.current.get(target) ?? null
const node = this.buildNode(context, target)
if (node === null && previous !== null) {
throw new Error(
`conversation Definition "${context.kind}" withdrew materialized target "${target}"; return the same key with hidden visibility instead`,
)
}
context.current.set(target, node)
if (node !== null) upsertsByTarget.get(target)?.push(node)
}
this.dirty.clear()
const timelineDirty = this.timelineDirty
@@ -323,6 +324,12 @@ export class ConversationNodeAssembler {
return this.views.get(target)?.snapshot
}
get<Target extends Extract<keyof ConversationViewSnapshotMap, string>>(
target: Target,
): ConversationViewSnapshotMap[Target] | undefined {
return this.snapshot(target) as ConversationViewSnapshotMap[Target] | undefined
}
private sortedInputs(): ConversationEventInput[] {
return [...this.inputs.values()].sort((left, right) => left.event.seq - right.event.seq)
}
@@ -358,18 +365,19 @@ export class ConversationNodeAssembler {
role: ConversationMatch['role'],
) => ConversationPublication,
): ConversationPublication {
let matched = false
const matchedTargets = new Set<string>()
let publication: ConversationPublication = 'none'
for (const definition of this.eventDefinitions.entries()) {
const result = definition.match(input.event)
if (result === null) continue
matched = true
if (definition.target !== undefined) matchedTargets.add(definition.target)
publication = maximumPublication(publication, accept(definition, result.id, result.role))
}
if (!matched) {
const fallback = this.eventDefinitions.fallbackEntry()
const result = fallback?.match(input.event) ?? null
if (fallback !== undefined && result !== null) {
const fallback = this.eventDefinitions.fallbackEntry()
const target = fallback?.target
if (fallback !== undefined && target !== undefined && !matchedTargets.has(target)) {
const result = fallback.match(input.event)
if (result !== null) {
publication = maximumPublication(publication, accept(fallback, result.id, result.role))
}
}
@@ -697,7 +705,8 @@ export class ConversationNodeAssembler {
}
private buildNode(context: InternalContext, target: string): ConversationViewNode | null {
const node = context.definition.buildViewNode(contextSnapshot(context), target)
if (context.definition.target !== target || context.definition.buildViewNode === undefined) return null
const node = context.definition.buildViewNode(contextSnapshot(context))
if (node === null) return null
if (node.key !== context.key) {
throw new Error(`conversation Definition "${context.kind}" returned unstable key "${node.key}"; expected "${context.key}"`)

View File

@@ -8,6 +8,7 @@
import type { CommandId } from '@deepseek-ai/dsh-commands/brand'
import type { MessageId } from '@deepseek-ai/dsh-llm/brand'
import type { ContentBlock } from '@deepseek-ai/dsh-llm/types'
import type { ImageAttachmentRef } from '@deepseek-ai/dsh-attachment'
import type { LlmRetryEventData } from '@deepseek-ai/dsh-llm-retry/types'
import type { TodoItem } from '@deepseek-ai/dsh-session/types'
import type {
@@ -16,7 +17,7 @@ import type {
import type { PendingInteraction } from './pending.ts'
import type { ContextProvenanceView, KnownContextForm } from './context-provenance.ts'
import type {
ChatConversationViewNode, ConversationTimelineSnapshot,
ChatConversationViewNode, ConversationTimelineSnapshot, ConversationViewSnapshotStore,
} from '../contract/conversation.ts'
export type { TodoItem }
@@ -43,6 +44,7 @@ export interface AssistantProvenanceView {
export type AssistantBlock =
| { kind: 'text'; text: string }
| { kind: 'reasoning'; text: string }
| { kind: 'image'; attachment: ImageAttachmentRef }
| { kind: 'tool-call'; callId: string; name: string; argsRaw: string }
| { kind: 'other'; block: unknown }
@@ -64,6 +66,7 @@ export function toAssistantBlock(block: ContentBlock): AssistantBlock {
switch (block.type) {
case 'text': return { kind: 'text', text: block.text }
case 'reasoning': return { kind: 'reasoning', text: block.text }
case 'image': return { kind: 'image', attachment: block.attachment }
case 'tool-call': return { kind: 'tool-call', callId: String(block.id), name: block.name, argsRaw: block.arguments }
default: return { kind: 'other', block }
}
@@ -321,8 +324,9 @@ export type OpenState = 'cold' | 'loading' | 'open' | 'error'
* - `engaging`: a first prompt was attempted, but no accepted turn or other
* authoritative activity signal has arrived — the UI keeps the composer
* visible through admission and error frames.
* - `active`: the session is non-blank beyond its pending first prompt, is
* running, or owns a pending interaction — the ordinary conversation view.
* - `active`: the session is non-blank beyond its pending first prompt,
* contains visible non-command Chat content, is running, or owns a pending
* interaction — the ordinary conversation view.
*
* A failed first prompt stays `engaging` (composer + error strip — retry
* semantics; returning to the hero would discard the error context).
@@ -381,6 +385,11 @@ export interface ChatSnapshot {
const EMPTY_LIST: readonly never[] = []
const EMPTY_TIMELINE: ConversationTimelineSnapshot = { turnOrder: EMPTY_LIST, turns: new Map() }
/** Empty target store used by fixtures and Sessions without registered views. */
export const EMPTY_CONVERSATION_VIEWS: ConversationViewSnapshotStore = {
get: () => undefined,
}
/** Empty Chat target used before a view builder is registered. */
export const EMPTY_CHAT_SNAPSHOT: ChatSnapshot = {
order: EMPTY_LIST,
@@ -405,6 +414,8 @@ export const EMPTY_CHAT_SNAPSHOT: ChatSnapshot = {
/** The immutable snapshot contract Session hands to uSES (see the web client architecture RFC). */
export interface ConversationSnapshot {
sessionId: SessionId
/** Registered target snapshots assembled from Session events. */
views: ConversationViewSnapshotStore
/** Final Chat target assembled from independently registered business Definitions. */
chat: ChatSnapshot
/** Legacy top-level compatibility field mirrored from the registered Chat Definitions. */

View File

@@ -1,121 +0,0 @@
import type { ToolSchema } from '@deepseek-ai/dsh-llm/types'
import type { HistoryEntry } from '@deepseek-ai/dsh-client-connection/client'
import type {
ConversationNode, PartialAssistant, RunningToolCall,
} from './conversation.ts'
import type { ConversationContext } from './conversation-context.ts'
import { projectConversationHistory } from '../session-history/history-fold.ts'
import { inspectRequests, type RequestView } from './request-inspection.ts'
function assistantStepKey(turn: number, step: number): string {
return `${turn}\u0000${step}`
}
function isFirstTokenCandidate(entry: HistoryEntry): boolean {
const event = entry.event
if (event.type !== 'assistant/chunk') return false
switch (event.data.chunk.type) {
case 'text-delta':
case 'reasoning-delta':
return event.data.chunk.text !== ''
case 'tool-call-delta':
return event.data.chunk.argumentsDelta !== '' || event.data.chunk.name !== undefined
default:
return false
}
}
/** Lazily derived inspection data for one immutable session-history window. */
export interface SessionHistoryInspection {
eventNodes: readonly ConversationNode[]
contexts: readonly ConversationContext[]
requests: readonly RequestView[]
callSchemas: ReadonlyMap<string, ToolSchema>
interruptedNodes: readonly ConversationNode[]
partial: PartialAssistant | null
runningCalls: readonly RunningToolCall[]
}
/**
* Remove completed-step token payloads that no inspection projection reads.
* The first visible token preserves timing, usage chunks preserve accounting,
* and unfinished steps retain every chunk for live or interrupted content.
* @param entries - Contiguous raw history entries in sequence order.
* @returns A projection-equivalent, usually much smaller entry ledger.
*/
export function compactHistoryInspectionEntries(
entries: readonly HistoryEntry[],
): readonly HistoryEntry[] {
const completedSteps = new Set<string>()
for (const { event } of entries) {
if (event.type === 'assistant/message') {
completedSteps.add(assistantStepKey(event.data.turn, event.data.step))
}
}
const firstTokenSteps = new Set<string>()
const compacted: HistoryEntry[] = []
let changed = false
for (const entry of entries) {
const event = entry.event
if (event.type !== 'assistant/chunk') {
compacted.push(entry)
continue
}
const key = assistantStepKey(event.data.turn, event.data.step)
if (!completedSteps.has(key) || event.data.chunk.type === 'usage') {
compacted.push(entry)
continue
}
if (isFirstTokenCandidate(entry) && !firstTokenSteps.has(key)) {
firstTokenSteps.add(key)
compacted.push(entry)
} else {
changed = true
}
}
return changed ? compacted : entries
}
/**
* Create a lazy inspection projection over an immutable history window.
* Conversation consumers retain the cheap wrapper; only Trajectory snapshots
* the entries and replays event order and request lifecycle state.
* @param loadEntries - Lazily snapshots contiguous raw entries in sequence order.
* @returns Lazy, memoized inspection fields for that exact window.
*/
export function createHistoryInspection(
loadEntries: () => readonly HistoryEntry[],
): SessionHistoryInspection {
let entries: readonly HistoryEntry[] | undefined
let conversation: ReturnType<typeof projectConversationHistory> | undefined
let requests: ReturnType<typeof inspectRequests> | undefined
const historyEntries = () => entries ??= loadEntries()
const conversationProjection = () =>
conversation ??= projectConversationHistory(historyEntries())
const requestProjection = () =>
requests ??= inspectRequests(historyEntries())
return {
get eventNodes() {
return conversationProjection().eventNodes
},
get contexts() {
return conversationProjection().contexts
},
get interruptedNodes() {
return conversationProjection().interruptedNodes
},
get partial() {
return conversationProjection().partial
},
get runningCalls() {
return conversationProjection().runningCalls
},
get requests() {
return requestProjection().requests
},
get callSchemas() {
return requestProjection().callSchemas
},
}
}

View File

@@ -25,6 +25,8 @@ export interface SessionListEntry {
/** Coarse durable origin for navigation filtering; not a continuation capability. */
origin?: 'subagent'
cwd?: string
/** Agent preset the session's agent was composed from (summary passthrough). */
agentPreset?: string
/** Current host-computed projection values for list consumers. */
projectionValues?: Readonly<Partial<SessionProjectionMap>>
/** User interaction currently blocking this session, derived from live mux frames. */

View File

@@ -4,7 +4,7 @@
import type {
IApiClient, HostFrame, MuxFrame, RpcError, RpcRequest, RpcResult, SessionId,
SessionSummary, SubagentAddress, SubagentCatalog, WorkspaceId,
SessionSummary, SubagentAddress, SubagentCatalog, TaskView, WorkspaceId,
} from '@deepseek-ai/dsh-client-connection/client'
// Value import from the inline-safe wire layer (not the connection plugin):
// plugin-to-plugin value imports are a bundle purity error.
@@ -48,6 +48,8 @@ export interface SessionListSnapshot {
phase: SessionListPhase
error: RpcError | null
subagentsByParent: Readonly<Record<SessionId, SubagentCatalogSnapshot>>
/** Background tasks per session; an absent key is an empty set. */
tasksBySession: Readonly<Record<SessionId, readonly TaskView[]>>
currentAddress: SubagentAddress | undefined
}
@@ -138,6 +140,11 @@ export class SessionManager {
private readonly catalogStale = new Set<SessionId>()
private readonly openCatalogs = new Set<SessionId>()
private readonly catalogDebounce = new Map<SessionId, ReturnType<typeof setTimeout>>()
/**
* Background tasks per session, last-wins from `session/tasks`. An empty set
* is stored as an absent key, so absence and `[]` are one representation.
*/
private readonly tasksBySession = new Map<SessionId, readonly TaskView[]>()
private selected: SessionId | undefined
@@ -423,7 +430,7 @@ export class SessionManager {
}
}
// ---- List surface ----
// ---- List API ----
/** Full refresh via session.list (single-flight: an in-flight call is reused). */
refreshList(): Promise<void> {
@@ -536,6 +543,7 @@ export class SessionManager {
this.recordMutation({ kind: 'upsert', summary: {
sessionId: result.value.sessionId, updatedAt: Date.now(), running: false, blank: true,
...(opts.cwd !== undefined ? { cwd: opts.cwd } : {}),
...(result.value.agentPreset !== undefined ? { agentPreset: result.value.agentPreset } : {}),
} })
} else {
const publishedSessionId = workspaceAttachSessionId(result.error)
@@ -601,6 +609,17 @@ export class SessionManager {
this.recordMutation({ kind: 'upsert', summary })
}
/**
* Record a host-confirmed composition switch (see ISessions.noteAgentPreset).
* @param sessionId - the switched session.
* @param agentPreset - the preset id the host confirmed.
*/
noteAgentPreset(sessionId: SessionId, agentPreset: string): void {
this.recordMutation({ kind: 'upsert', summary: {
sessionId, updatedAt: Date.now(), running: false, blank: true, agentPreset,
} })
}
/** Apply immediately and retain for replay when a list response is in flight. */
private recordMutation(mutation: SessionListMutation): void {
this.listMutations?.push(mutation)
@@ -610,7 +629,7 @@ export class SessionManager {
this.notifier.markDirty()
}
// ---- Subscription surface (for useSessionList) ----
// ---- Subscription API (for useSessionList) ----
/**
* uSES subscription entry for useSessionList.
@@ -670,10 +689,23 @@ export class SessionManager {
this.notifier.markDirty()
return
}
if (frame.type === 'session/tasks') {
// Whole-set snapshot, so last-wins with no reconciliation. The Host omits
// the baseline for an empty set, which is the same fact an emptying change
// reports as `[]` — both land as an absent key.
if (frame.tasks.length === 0) this.tasksBySession.delete(frame.sessionId)
else this.tasksBySession.set(frame.sessionId, frame.tasks)
this.notifier.markDirty()
return
}
if (frame.type === 'session/subscribed') {
// Rows past the host's durable baseline rode state a restart lost; drop
// them so last-wins cannot pin a phantom value over recomputed truth.
this.projectionStores.get(frame.sessionId)?.truncate(frame.lastSeq)
// Same re-baseline reasoning as the queue below: this generation sends a
// task baseline only when the set is non-empty, so a mirror kept from the
// previous generation would survive as a phantom list.
this.tasksBySession.delete(frame.sessionId)
this.notifier.markDirty()
// New mux-generation baseline: discard the previous queue snapshot.
// The host omits session/queue when the live queue is empty, so retaining
@@ -756,6 +788,7 @@ export class SessionManager {
...(frame.parentSessionId !== undefined ? { parentSessionId: frame.parentSessionId } : {}),
...(frame.origin !== undefined ? { origin: frame.origin } : {}),
...(frame.cwd !== undefined ? { cwd: frame.cwd } : {}),
...(frame.agentPreset !== undefined ? { agentPreset: frame.agentPreset } : {}),
})
this.sessions.get(frame.sessionId)?.handleBlank(frame.blank)
if (frame.origin === 'subagent' && frame.parentSessionId !== undefined) {
@@ -767,6 +800,14 @@ export class SessionManager {
}
return
}
case 'host/session-preset-changed': {
// Every connected client observes the switch here; only the tab that
// issued it also gets the RPC echo. The merge keeps the row's own
// updatedAt and lowers `blank` only, so re-applying the switching
// tab's own frame is a no-op.
this.noteAgentPreset(frame.sessionId, frame.agentPreset)
return
}
case 'host/session-removed': {
const summary = this.summaries.find(candidate => candidate.sessionId === frame.sessionId)
const durableSubagent = summary?.origin === 'subagent' || this.addresses.has(frame.sessionId)
@@ -783,6 +824,11 @@ export class SessionManager {
}
this.pendingBuffers.delete(frame.sessionId) // a removed session's buffered frames must not replay on a future instantiation
this.pendingInteractions.delete(frame.sessionId) // a removed session cannot wait on anyone
// Owner disposal already dropped these registry-side, but that lands on
// the mux stream while this frame rides the host stream, so the two have
// no relative order. Clearing here makes a detached Activation's rows
// disappear whichever arrives first.
this.tasksBySession.delete(frame.sessionId)
if (!durableSubagent) this.projectionStores.delete(frame.sessionId)
// A pull already in flight was requested before this removal and can
// carry the pre-removal parentAvailable:true, which would resurrect
@@ -992,7 +1038,7 @@ export class SessionManager {
const prev = this.entryCache.get(entry.sessionId)
if (
prev !== undefined && prev.updatedAt === entry.updatedAt && prev.running === entry.running
&& prev.blank === entry.blank
&& prev.blank === entry.blank && prev.agentPreset === entry.agentPreset
&& prev.parentSessionId === entry.parentSessionId && prev.cwd === entry.cwd
&& prev.origin === entry.origin && prev.title === entry.title && prev.depth === entry.depth
&& prev.pendingInteraction === entry.pendingInteraction
@@ -1019,6 +1065,7 @@ export class SessionManager {
phase: this.listPhase,
error: this.listError,
subagentsByParent: Object.fromEntries(this.catalogs),
tasksBySession: Object.fromEntries(this.tasksBySession),
currentAddress: current === undefined ? undefined : this.addresses.get(current),
}
}
@@ -1040,9 +1087,15 @@ function applyMutation(summaries: readonly SessionSummary[], mutation: SessionLi
? { parentSessionId: mutation.summary.parentSessionId } : {}),
...(existing.origin === undefined && mutation.summary.origin !== undefined
? { origin: mutation.summary.origin } : {}),
// Newest wins, not fill-only: a blank-session preset switch replaces
// the creation-time value, and every producer of this field (the
// create echo, the select echo, a list row) reports the CURRENT one.
...(mutation.summary.agentPreset !== undefined
? { agentPreset: mutation.summary.agentPreset } : {}),
}
if (filled.cwd === existing.cwd && filled.parentSessionId === existing.parentSessionId
&& filled.origin === existing.origin && filled.blank === existing.blank) return [...summaries]
&& filled.origin === existing.origin && filled.blank === existing.blank
&& filled.agentPreset === existing.agentPreset) return [...summaries]
return summaries.map(summary => summary.sessionId === mutation.summary.sessionId ? filled : summary)
}
case 'remove':

View File

@@ -1,17 +1,7 @@
// Request-centric inspection read model. Ordinary generation and compaction
// calls share one chronological projection; presentation-specific grouping
// remains in the trajectory consumer.
import type { ContentBlock, TokenUsage, ToolSchema } from '@deepseek-ai/dsh-llm/types'
import type { HistoryEntry } from '@deepseek-ai/dsh-client-connection/client'
import type { SessionEvent } from '@deepseek-ai/dsh-session/types'
import type {} from '@deepseek-ai/dsh-compact/types'
import type {} from '@deepseek-ai/dsh-llm-retry/types'
import type {} from '@deepseek-ai/dsh-tools/types'
import type { ContentBlock, ToolSchema } from '@deepseek-ai/dsh-llm/types'
import type {
AssistantProvenanceView, AssistantRequestConfig,
} from './conversation.ts'
import { displayFailureMessage } from './failure-display.ts'
export type {
AssistantProvenanceView, AssistantRequestConfig,
@@ -54,7 +44,7 @@ interface RequestViewBase {
resultSeq?: number
}
/** One ordinary assistant generation reconstructed from durable request events. */
/** One ordinary assistant generation assembled from durable request events. */
interface AssistantRequestView extends RequestViewBase {
purpose: 'assistant'
turn: number
@@ -85,321 +75,11 @@ interface CompactionRequestView extends RequestViewBase {
rawOutput?: readonly ContentBlock[]
}
/** One provider request reconstructed from durable request lifecycle events. */
/** One provider request assembled from durable request lifecycle events. */
export type RequestView = AssistantRequestView | CompactionRequestView
/** Immutable request-centric projection derived from one history window. */
/** Request data consumed by the stage-oriented Trajectory layout. */
export interface RequestInspectionSnapshot {
requests: readonly RequestView[]
callSchemas: ReadonlyMap<string, ToolSchema>
}
/**
* Derive the request-centric read model from one immutable history window.
* Compaction participates as a request purpose rather than a parallel
* top-level collection. A leading resume/change header exposes its prompt but
* cannot project a change until the preceding header enters the window.
* @param entries - Contiguous raw session history.
* @returns Requests and call-time schemas derived from that history.
*/
export function inspectRequests(
entries: readonly HistoryEntry[],
): RequestInspectionSnapshot {
const events = entries.map(entry => entry.event)
return {
requests: deriveRequests(events),
callSchemas: deriveCallSchemas(events),
}
}
function requestKey(turn: number, step: number): string {
return `${turn}\u0000${step}`
}
function addTokenUsage(current: unknown, next: TokenUsage): TokenUsage {
const previous = current as TokenUsage | undefined
return {
inputTokens: (previous?.inputTokens ?? 0) + next.inputTokens,
outputTokens: (previous?.outputTokens ?? 0) + next.outputTokens,
...(previous?.cacheReadTokens === undefined && next.cacheReadTokens === undefined
? {}
: {
cacheReadTokens:
(previous?.cacheReadTokens ?? 0) + (next.cacheReadTokens ?? 0),
}),
...(previous?.cacheWriteTokens === undefined && next.cacheWriteTokens === undefined
? {}
: {
cacheWriteTokens:
(previous?.cacheWriteTokens ?? 0) + (next.cacheWriteTokens ?? 0),
}),
...(previous?.reasoningTokens === undefined && next.reasoningTokens === undefined
? {}
: {
reasoningTokens:
(previous?.reasoningTokens ?? 0) + (next.reasoningTokens ?? 0),
}),
}
}
function deriveCallSchemas(
events: readonly SessionEvent[],
): ReadonlyMap<string, ToolSchema> {
let active = new Map<string, ToolSchema>()
const calls = new Map<string, ToolSchema>()
const capture = (callId: string, name: string): void => {
if (calls.has(callId)) return
const schema = active.get(name)
if (schema !== undefined) calls.set(callId, schema)
}
for (const event of events) {
if (event.type === 'request/header') {
const tools: unknown = event.data.header.tools
active = new Map(
Array.isArray(tools)
? (tools as ToolSchema[]).map(schema => [schema.name, schema])
: [],
)
continue
}
if (event.type === 'tool/call') {
capture(String(event.data.callId), event.data.name)
continue
}
if (event.type === 'tool/code-dispatch-start' || event.type === 'tool/code-dispatch') {
capture(String(event.data.subCallId), event.data.name)
}
}
return calls
}
function promptChange(
previous: ConversationPromptSnapshot | undefined,
prompt: ConversationPromptSnapshot,
event: SessionEvent<'request/header'>,
): RequestPromptChange | undefined {
if (previous === undefined && event.data.reason !== 'initial') return
const systemChanged = previous !== undefined && previous.system !== prompt.system
const toolsChanged = previous !== undefined
&& JSON.stringify(previous.tools) !== JSON.stringify(prompt.tools)
if (previous !== undefined && !systemChanged && !toolsChanged) return
return {
seq: event.seq,
time: event.time,
kind: previous === undefined
? 'initial'
: systemChanged && toolsChanged
? 'system-and-tools'
: systemChanged
? 'system'
: 'tools',
...(previous === undefined ? {} : { previous }),
}
}
/** Project ordinary and compaction provider calls into one chronological request stream. */
function deriveRequests(events: readonly SessionEvent[]): readonly RequestView[] {
const requests: RequestView[] = []
const ordinaryByStep = new Map<string, number>()
const lastStepByTurn = new Map<number, string>()
let activeStep: string | undefined
let activePrompt: ConversationPromptSnapshot | undefined
let activeCompaction: number | undefined
const updateAssistant = (
index: number | undefined,
change: Partial<Omit<AssistantRequestView, 'purpose'>>,
): void => {
if (index === undefined) return
const request = requests[index]
if (request?.purpose === 'assistant') requests[index] = { ...request, ...change }
}
const updateCompaction = (
index: number | undefined,
change: Partial<Omit<CompactionRequestView, 'purpose'>>,
): void => {
if (index === undefined) return
const request = requests[index]
if (request?.purpose === 'compaction') requests[index] = { ...request, ...change }
}
for (const sourceEvent of events) {
if (sourceEvent.type === 'step/start') {
const { turn, step } = sourceEvent.data
const key = requestKey(turn, step)
ordinaryByStep.set(key, requests.length)
lastStepByTurn.set(turn, key)
requests.push({
purpose: 'assistant',
startSeq: sourceEvent.seq,
turn,
step,
startedAt: sourceEvent.time,
completedAt: null,
status: 'running',
...(activePrompt === undefined
? {}
: { prompt: activePrompt, requestConfig: activePrompt.config }),
})
activeStep = key
continue
}
if (sourceEvent.type === 'request/header') {
const tools: unknown = sourceEvent.data.header.tools
const prompt: ConversationPromptSnapshot = {
config: sourceEvent.data.header.config,
system: sourceEvent.data.header.system ?? '',
tools: Array.isArray(tools) ? tools as ToolSchema[] : [],
}
const change = promptChange(activePrompt, prompt, sourceEvent)
activePrompt = prompt
updateAssistant(activeStep === undefined ? undefined : ordinaryByStep.get(activeStep), {
prompt,
requestConfig: prompt.config,
...(change === undefined ? {} : { promptChange: change }),
})
continue
}
if (
sourceEvent.type === 'assistant/chunk'
&& sourceEvent.data.chunk.type === 'usage'
) {
const index = ordinaryByStep.get(
requestKey(sourceEvent.data.turn, sourceEvent.data.step),
)
const request = index === undefined ? undefined : requests[index]
updateAssistant(index, {
usage: addTokenUsage(
request?.purpose === 'assistant' ? request.usage : undefined,
sourceEvent.data.chunk.usage,
),
})
continue
}
if (sourceEvent.type === 'assistant/message') {
const index = ordinaryByStep.get(
requestKey(sourceEvent.data.turn, sourceEvent.data.step),
)
const request = index === undefined ? undefined : requests[index]
updateAssistant(index, {
completedAt: sourceEvent.time,
status: 'complete',
resultSeq: sourceEvent.seq,
provenance: {
provider: sourceEvent.data.message.source.provider,
model: sourceEvent.data.message.source.model,
},
...(request?.purpose === 'assistant'
&& request.usage !== undefined
|| sourceEvent.data.usage === undefined
? {}
: { usage: sourceEvent.data.usage }),
})
continue
}
if (sourceEvent.type === 'step/end') {
const key = requestKey(sourceEvent.data.turn, sourceEvent.data.step)
const index = ordinaryByStep.get(key)
const request = index === undefined ? undefined : requests[index]
if (request?.purpose === 'assistant' && request.status === 'running') {
updateAssistant(index, {
completedAt: sourceEvent.time,
status: 'error',
})
}
if (activeStep === key) activeStep = undefined
continue
}
if (sourceEvent.type === 'llm/retry') {
const data = sourceEvent.data
updateAssistant(ordinaryByStep.get(requestKey(data.turn, data.step)), {
status: 'error',
error: displayFailureMessage(data.failure),
retry: data.retry,
...data.mode === 'normal' ? { maxRetries: data.maxRetries } : {},
retryDelayMs: data.delayMs,
})
continue
}
if (sourceEvent.type === 'turn/end') {
const lastStep = lastStepByTurn.get(sourceEvent.data.turn)
if (sourceEvent.data.reason.kind === 'error') {
updateAssistant(lastStep === undefined ? undefined : ordinaryByStep.get(lastStep), {
status: 'error',
error: displayFailureMessage(sourceEvent.data.reason.error),
})
}
lastStepByTurn.delete(sourceEvent.data.turn)
continue
}
if (sourceEvent.type === 'session/end-seed' && activeCompaction !== undefined) {
updateCompaction(activeCompaction, {
completedAt: sourceEvent.time,
status: 'error',
error: 'Compaction was interrupted before completion.',
})
activeCompaction = undefined
continue
}
if (sourceEvent.type === 'compact/start') {
activeCompaction = requests.length
requests.push({
purpose: 'compaction',
startSeq: sourceEvent.seq,
turn: sourceEvent.data.turn,
step: 0,
startedAt: sourceEvent.time,
completedAt: null,
status: 'running',
})
continue
}
if (sourceEvent.type === 'compact/summary' && activeCompaction !== undefined) {
const data = sourceEvent.data
updateCompaction(activeCompaction, {
resultSeq: sourceEvent.seq,
summary: data.summary,
...(data.rawOutput === undefined ? {} : { rawOutput: data.rawOutput }),
provenance: {
provider: data.provider,
model: data.model,
},
requestConfig: {
provider: data.provider,
model: data.model,
purpose: 'compaction',
...(data.maxTokens === undefined ? {} : { maxTokens: data.maxTokens }),
},
...(data.usage === undefined ? {} : { usage: data.usage }),
})
continue
}
if (
sourceEvent.type === 'user/message'
&& activeCompaction !== undefined
&& isCompactionSource(sourceEvent.data.source)
) {
updateCompaction(activeCompaction, { replacementSeq: sourceEvent.seq })
continue
}
if (sourceEvent.type !== 'compact/end' || activeCompaction === undefined) continue
updateCompaction(activeCompaction, {
completedAt: sourceEvent.time,
status: sourceEvent.data.error === undefined ? 'complete' : 'error',
...(sourceEvent.data.error === undefined ? {} : { error: sourceEvent.data.error }),
})
activeCompaction = undefined
}
return requests.sort((left, right) => left.startSeq - right.startSeq)
}
function isCompactionSource(source: unknown): boolean {
return typeof source === 'object'
&& source !== null
&& 'kind' in source
&& source.kind === 'plugin'
&& 'plugin' in source
&& source.plugin === 'compact'
}

View File

@@ -14,9 +14,9 @@
* tears its scope down immediately unless it is the staged one, whose scope
* survives frozen (read-only view) until the stage moves on.
*/
import type { Context, Fiber } from 'cordis'
import type { Context, Fiber } from '@deepseek-ai/cordis'
import type {
IApiClient, RpcError, RpcResult, SessionId, SubagentAddress, WorkspaceId,
IApiClient, RpcError, RpcResult, SessionId, SubagentAddress, TaskView, WorkspaceId,
} from '@deepseek-ai/dsh-client-connection/client'
// Value import from the inline-safe wire layer (not the connection plugin):
// plugin-to-plugin value imports are a bundle purity error.
@@ -45,6 +45,12 @@ export interface SessionSummary {
/** Human-facing label: durable title, project basename, then session id. */
displayTitle: string
cwd?: string
/**
* Agent preset this session's agent was composed from; absent when the
* deployment composes no presets. The session header labels what the
* session actually runs rather than the deployment's current default.
*/
agentPreset?: string
parentId?: SessionId
/** Coarse durable origin for navigation filtering; not a continuation capability. */
origin?: 'subagent'
@@ -80,6 +86,12 @@ export interface SessionListState {
phase: SessionListPhase
/** Direct durable catalogs keyed by their selected parent address. */
subagentsByParent: Readonly<Record<SessionId, SubagentCatalogSnapshot>>
/**
* Background tasks each session can see, mirrored last-wins from
* `session/tasks`. A missing key is an empty set — the Host sends no baseline
* for a session without tasks — so consumers read absence, never a sentinel.
*/
tasksBySession: Readonly<Record<SessionId, readonly TaskView[]>>
/** Current session's catalog-derived address, absent on ordinary navigation. */
currentAddress: SubagentAddress | undefined
}
@@ -285,7 +297,7 @@ export class SessionsService implements ISessions {
)
this.list = createSnapshotStore<SessionListState>({
ids: [], byId: {}, current: undefined, phase: 'pending',
subagentsByParent: {}, currentAddress: undefined,
subagentsByParent: {}, tasksBySession: {}, currentAddress: undefined,
})
// The manager owns wire truth; the store is its projection. Manager
// notifications are already microtask-batched.
@@ -392,6 +404,10 @@ export class SessionsService implements ISessions {
return this.manager.refreshSubagents(parentSessionId)
}
noteAgentPreset(sessionId: SessionId, agentPreset: string): void {
this.manager.noteAgentPreset(sessionId, agentPreset)
}
/**
* Clear the current selection so the layout shows the no-session empty
* state (new-session affordance and the workspace preselection flow).
@@ -639,7 +655,7 @@ export class SessionsService implements ISessions {
/** Project the manager's list snapshot into the store (title derivation is display-only). */
private projectList(): void {
const {
items, current, phase, subagentsByParent, currentAddress,
items, current, phase, subagentsByParent, tasksBySession, currentAddress,
} = this.manager.getListSnapshot()
const ids: SessionId[] = []
const byId: Record<SessionId, SessionSummary> = {}
@@ -662,6 +678,7 @@ export class SessionsService implements ISessions {
...(entry.cwd !== undefined ? { cwd: entry.cwd } : {}),
...(entry.parentSessionId !== undefined ? { parentId: entry.parentSessionId } : {}),
...(entry.origin !== undefined ? { origin: entry.origin } : {}),
...(entry.agentPreset !== undefined ? { agentPreset: entry.agentPreset } : {}),
}
}
if (current !== undefined && currentAddress !== undefined) {
@@ -708,7 +725,7 @@ export class SessionsService implements ISessions {
...(currentAddress === undefined ? {} : { subagentAddress: currentAddress }),
})
}
this.list.set({ ids, byId, current, phase, subagentsByParent, currentAddress })
this.list.set({ ids, byId, current, phase, subagentsByParent, tasksBySession, currentAddress })
this.pruneScopes()
}

View File

@@ -1,10 +1,10 @@
// Sessions remain resident after creation so they continue consuming mux frames off-screen.
import type { Context } from 'cordis'
import type { ContentBlock } from '@deepseek-ai/dsh-llm/types'
import type { Context } from '@deepseek-ai/cordis'
import type { AttachmentIdType, ImageAttachmentRef } from '@deepseek-ai/dsh-attachment'
import type { SessionEvent } from '@deepseek-ai/dsh-session/types'
import type {
HistoryEntry, IApiClient, MessageId, MuxFrame, QueueAction, RpcError,
HistoryEntry, IApiClient, MessageId, MuxFrame, PromptContentPart, QueueAction, RpcError,
RpcId, RpcResponse, RpcResult, SessionId, SubagentAddress, ToolEventView,
} from '@deepseek-ai/dsh-client-connection/client'
// Value import from the inline-safe wire layer (not the connection plugin):
@@ -61,7 +61,7 @@ export interface SessionOptions {
* remaining public members are manager/runtime entry points.
*/
export class Session implements SessionFace {
// ---- Window and derived state (all private; the snapshot is the only read surface) ----
// ---- Window and derived state (all private; the snapshot is the only read API) ----
private events: SessionEvent[] = []
/** Wire views aligned with `events` by index (envelope-level annotations; undefined = no view).
* Kept parallel rather than merged so `events` stays the raw log slice (model-visible ⟺ logged). */
@@ -179,11 +179,11 @@ export class Session implements SessionFace {
/**
* Send (queue/steer passed through 1:1); failures land in the snapshot's promptError.
* @param content - core content blocks verbatim.
* @param content - text plus browser-owned temporary image uploads.
* @param mode - queue appends after the current turn; steer interrupts it.
* @returns the prompt result (also mirrored into promptError on failure).
*/
async prompt(content: ContentBlock[], mode: 'queue' | 'steer'): Promise<RpcResult<{ accepted: true }>> {
async prompt(content: PromptContentPart[], mode: 'queue' | 'steer'): Promise<RpcResult<{ accepted: true }>> {
this.promptError = null
this.lastAgentError = null
// Synchronous, before the first await: the blank → engaging edge must be
@@ -211,12 +211,25 @@ export class Session implements SessionFace {
},
}
} else {
const routed = (await this.api.subagents.prompt({
...this.address,
content,
clientTimeZone: resolvedClientTimeZone(),
})).result
result = routed.ok ? { ok: true, value: { accepted: true } } : routed
if (content.some(part => part.type === 'image')) {
result = {
ok: false,
error: {
code: 'attachment-error',
message: 'Image input is unavailable for subagent continuations.',
details: { reason: 'SUBAGENT_IMAGE_UNSUPPORTED' },
},
}
} else {
const routed = (await this.api.subagents.prompt({
...this.address,
content: content.flatMap(part => part.type === 'text'
? [{ type: 'text' as const, text: part.text }]
: []),
clientTimeZone: resolvedClientTimeZone(),
})).result
result = routed.ok ? { ok: true, value: { accepted: true } } : routed
}
}
} catch (error) {
result = transportError(error)
@@ -242,6 +255,28 @@ export class Session implements SessionFace {
return result
}
/**
* Resolve one image referenced by this session into browser-consumable bytes.
* @param attachmentId - opaque id found in the folded session log.
* @returns the authenticated reference and decoded bytes.
*/
async readAttachment(
attachmentId: AttachmentIdType,
): Promise<RpcResult<{ attachment: ImageAttachmentRef; data: Uint8Array }>> {
try {
const result = (await this.api.sessions.attachment({
sessionId: this.sessionId,
attachmentId,
})).result
if (!result.ok) return result
const binary = atob(result.value.data)
const data = Uint8Array.from(binary, char => char.charCodeAt(0))
return { ok: true, value: { attachment: result.value.attachment, data } }
} catch (error) {
return transportError(error)
}
}
/** Apply one operation to a still-pending queue occurrence. */
async updateQueue(itemId: MessageId, action: QueueAction): Promise<RpcResult<{ accepted: true }>> {
try {
@@ -400,7 +435,7 @@ export class Session implements SessionFace {
await this.open()
}
// ---- Subscription surface (useSyncExternalStore direct wiring) ----
// ---- Subscription API (useSyncExternalStore direct wiring) ----
/**
* uSES subscription entry.
@@ -699,6 +734,7 @@ export class Session implements SessionFace {
const legacy = chat.legacy
return {
sessionId: this.sessionId,
views: this.conversation,
chat,
nodes: legacy.nodes,
turnTimings: legacy.turnTimings,
@@ -712,7 +748,8 @@ export class Session implements SessionFace {
? null
: { address: this.address, parentAvailable: this.parentAvailable },
composerPhase: derivePhase(
(!this.blankBit && !this.firstPromptPendingTurn)
hasVisibleConversationContent(chat)
|| (!this.blankBit && !this.firstPromptPendingTurn)
|| this.running
|| this.pendingCache.value.length > 0,
this.promptAttempted,
@@ -745,13 +782,18 @@ function conversationInput(entry: HistoryEntry): ConversationEventInput {
return { event: entry.event, view: entry.view }
}
/** A generic command row alone remains control-plane content; every other visible Chat Node activates the conversation. */
function hasVisibleConversationContent(chat: ChatSnapshot): boolean {
return chat.order.some(key => chat.nodes.get(key)?.kind !== 'command')
}
/**
* The composerPhase judgment — the single site that knows the predicate
* (consumers switch on the result, never re-derive). A failed first prompt
* stays engaging until an authoritative accepted-turn, running, or pending
* signal arrives (retry semantics — see ComposerPhase).
* @param hasContent - authoritative non-blank activity beyond a pending first
* prompt, a running turn, or a pending interaction.
* prompt, visible non-command Chat content, a running turn, or a pending interaction.
* @param promptAttempted - a prompt was initiated on this session object.
* @returns the derived phase.
*/

View File

@@ -0,0 +1,261 @@
/** Host-backed settings-namespace synchronization for browser plugins. */
import type { Context } from '@deepseek-ai/cordis'
import type {
ConnectionHandle, IApiClient, SettingsNamespaceView,
} from '@deepseek-ai/dsh-client-connection/client'
import { rehydrateSchema, validateDraft } from '@deepseek-ai/dsh-client-schema-form'
import { createSnapshotStore, type SnapshotStore } from './contract/store.ts'
/** Client-side sync state of one settings namespace. */
export interface SettingsScopeSnapshot<T> {
/**
* `loading` until the first accepted section, `ready` while one stands, and
* `unavailable` when the namespace is not exposed to this client or the
* connection keeps preferences process-local (memory mode).
*/
status: 'loading' | 'ready' | 'unavailable'
/** Last accepted schema-resolved section; undefined before the first acceptance. */
value: T | undefined
/** Namespace revision fencing the next write; undefined before the first Host view. */
revision: number | undefined
/** Whether the Host document accepts writes; memory mode never does. */
writable: boolean
/** `host` syncs with the Host document; `memory` keeps a remote browser process-local. */
mode: 'host' | 'memory'
}
/** Domain-owned description of one settings namespace consumed by a browser plugin. */
export interface SettingsScopeSpec<T> {
/** Settings namespace registered by the owning Host plugin. */
namespace: string
/**
* Narrow one wire section; undefined keeps the last accepted value. The
* default validates the section against the namespace's own serialized wire
* schema, so domains add a decoder only to narrow beyond that schema.
*/
decode?: (section: unknown) => T | undefined
}
/**
* Reactive owner handle over one namespace's durable section — the browser
* mirror of the Host-side `SettingsScope` owner seam. Domain services read
* and observe the snapshot and route explicit user choices through `set`.
*/
export interface SettingsScope<T> {
/** @returns the current sync snapshot (stable reference until the next change). */
getSnapshot(): SettingsScopeSnapshot<T>
/**
* Observe snapshot replacements.
* @param listener - invoked after each snapshot change.
* @returns the disposer removing this listener.
*/
subscribe(listener: () => void): () => void
/**
* Queue one field write. Rapid writes preserve mutation order, each carries
* the latest known namespace revision, and only the latest settlement may
* publish; a rejected or failed latest write reloads Host state instead.
* @param field - scalar field inside the namespace section.
* @param value - JSON-shaped value selected by the user.
* @returns settlement after the write and any latest-write recovery read.
*/
set(field: string, value: unknown): Promise<void>
}
type SettingsFace = Pick<IApiClient, 'settings'>
/**
* Serializes one namespace's Host reads and writes behind a snapshot store.
* Reads never block plugin activation; writes carry the latest known
* namespace revision and teardown waits for the operation already crossing
* the wire.
*/
export class SettingsScopeController<T> implements SettingsScope<T> {
private readonly store: SnapshotStore<SettingsScopeSnapshot<T>>
private tail: Promise<void> = Promise.resolve()
private readGeneration = 0
private writeGeneration = 0
private disposed = false
/**
* @param api - settings wire face.
* @param spec - namespace identity and optional narrowing decoder.
* @param persistence - remote browsers remain process-local because settings RPCs are loopback-only.
*/
constructor(
private readonly api: SettingsFace,
private readonly spec: SettingsScopeSpec<T>,
private readonly persistence: 'host' | 'memory' = 'host',
) {
this.store = createSnapshotStore<SettingsScopeSnapshot<T>>({
status: persistence === 'host' ? 'loading' : 'unavailable',
value: undefined,
revision: undefined,
writable: false,
mode: persistence,
})
}
/** @returns the current sync snapshot (stable reference until the next change). */
getSnapshot(): SettingsScopeSnapshot<T> {
return this.store.getSnapshot()
}
/**
* Observe snapshot replacements.
* @param listener - invoked after each snapshot change.
* @returns the disposer removing this listener.
*/
subscribe(listener: () => void): () => void {
return this.store.subscribe(listener)
}
/**
* Queue a Host refresh; a newer read or user write suppresses stale publication.
* @returns settlement after the queued read completes or is skipped.
*/
load(): Promise<void> {
const generation = ++this.readGeneration
return this.enqueue(() => this.read(generation))
}
/**
* Queue one field write; see {@link SettingsScope.set} for the ordering,
* revision, and recovery contract.
* @param field - scalar field inside the namespace section.
* @param value - JSON-shaped value selected by the user.
* @returns settlement after the write and any latest-write recovery read.
*/
set(field: string, value: unknown): Promise<void> {
this.readGeneration += 1
const generation = ++this.writeGeneration
return this.enqueue(async () => {
const revision = this.getSnapshot().revision
let response: Awaited<ReturnType<SettingsFace['settings']['mutate']>>
try {
response = await this.api.settings.mutate({
ns: this.spec.namespace,
ops: [{ op: 'set', path: [field], value }],
...(revision === undefined ? {} : { expectedRevision: revision }),
})
} catch (_settingsWriteFailure) {
if (!this.disposed && generation === this.writeGeneration) await this.read(++this.readGeneration)
return
}
if (!response.result.ok) {
if (!this.disposed && generation === this.writeGeneration) await this.read(++this.readGeneration)
return
}
this.accept(response.result.value, generation === this.writeGeneration)
})
}
/**
* Stop queued operations and wait for the current wire call to settle.
* @returns settlement after the controller reaches quiescence.
*/
async dispose(): Promise<void> {
this.disposed = true
this.readGeneration += 1
this.writeGeneration += 1
await this.tail
}
private enqueue(operation: () => Promise<void>): Promise<void> {
if (this.persistence === 'memory' || this.disposed) return Promise.resolve()
const task = this.tail.then(async () => {
if (this.disposed) return
await operation()
})
// The returned task carries its own settlement to the caller; the queue
// tail is kept fulfilled so one failed subscriber cannot strand later operations.
this.tail = task.catch(() => {})
return task
}
private async read(generation: number): Promise<void> {
let response: Awaited<ReturnType<SettingsFace['settings']['describe']>>
try {
response = await this.api.settings.describe({})
} catch (_settingsReadFailure) {
return
}
if (!response.result.ok || this.disposed) return
const { namespaces, writable } = response.result.value
const view = namespaces.find(candidate => candidate.ns === this.spec.namespace)
const publish = generation === this.readGeneration
if (view === undefined) {
if (publish) {
this.store.update((draft) => {
draft.status = 'unavailable'
draft.writable = writable
})
}
return
}
this.accept(view, publish, writable)
}
private accept(view: SettingsNamespaceView, publish: boolean, writable?: boolean): void {
const decoded = publish ? this.decode(view) : undefined
this.store.update((draft) => {
draft.revision = view.revision
if (writable !== undefined) draft.writable = writable
if (decoded === undefined) return
draft.status = 'ready'
draft.value = decoded
})
}
private decode(view: SettingsNamespaceView): T | undefined {
if (this.spec.decode !== undefined) return this.spec.decode(view.value)
// Sections are plain objects by construction; schemastery alone would
// resolve null or an array through object defaults instead of refusing.
if (typeof view.value !== 'object' || view.value === null || Array.isArray(view.value)) return undefined
let failure: string | undefined
try {
failure = validateDraft(rehydrateSchema(view.schema), view.value)
} catch (_malformedSchemaEnvelope) {
// A schema envelope this client cannot rehydrate vouches for no section;
// the value is treated exactly like a schema-invalid one.
return undefined
}
return failure === undefined ? view.value as T : undefined
}
}
/**
* Bind one namespace scope to settings and connection invalidations on the
* caller's plugin lifecycle. Listeners exist before the initial background
* read starts, so activation never blocks on the settings transport.
* @param ctx - owning browser plugin context.
* @param spec - domain-owned namespace contract.
* @returns the bound scope consumed by the domain's services and rows.
*/
export function bindSettingsScope<T>(
ctx: Context,
spec: SettingsScopeSpec<T>,
): SettingsScope<T> {
const connection = ctx.get('connection') as ConnectionHandle
const controller = new SettingsScopeController<T>(
connection.api,
spec,
connection.isLoopback ? 'host' : 'memory',
)
ctx.effect(() => {
const refresh = (namespace?: string): void => {
if (namespace !== undefined && namespace !== spec.namespace) return
void controller.load()
}
const disposers = [
ctx.on('settings/changed', refresh),
ctx.on('connection/reset', () => { refresh() }),
]
void controller.load()
return async () => {
for (const dispose of disposers) dispose()
await controller.dispose()
}
}, `runtime: ${spec.namespace} settings scope`)
return controller
}

View File

@@ -14,8 +14,8 @@
* holds this package's 'root' row in this compilation unit, but consumers
* merge keys in; the rule fires on the narrow-map view, not on real
* redundancy. */
import { Service } from 'cordis'
import type { Context } from 'cordis'
import { Service } from '@deepseek-ai/cordis'
import type { Context } from '@deepseek-ai/cordis'
import { SlotCore } from '@deepseek-ai/dsh-client-ui-slots'
import type {
LocaleFace, OwnerOf, SlotEntryDef, SlotMap, SlotRenderer, SlotRendererHost,

View File

@@ -120,7 +120,7 @@ export class WorkspaceManager {
/**
* Create or resolve a real Workspace, then publish its returned snapshot
* without waiting for the changed frame.
* @param input - name under workspaceRoot or an existing absolute path.
* @param input - the existing absolute path to adopt.
* @returns the wire result.
*/
async create(input: WorkspaceCreateInput): Promise<RpcResult<{ workspace: WorkspaceView; created: boolean }>> {

View File

@@ -1,6 +1,6 @@
/** WorkspacesService projects the Workspace object manager for UI consumers. */
import type { Context } from 'cordis'
import type { Context } from '@deepseek-ai/cordis'
import type {
DirectoryListing, IApiClient, RpcError,
SessionId, WorkspaceId, WorkspaceView,
@@ -186,11 +186,11 @@ export class WorkspacesService implements IWorkspaces {
}
/**
* Create a Workspace by name or register an existing path.
* @param input - exactly one Host create spelling.
* Register an existing path as a Workspace.
* @param input - the Host create payload.
* @returns the created or idempotently resolved Workspace.
*/
async create(input: { name: string } | { path: string }): Promise<WorkspaceView> {
async create(input: { path: string }): Promise<WorkspaceView> {
const result = await this.manager.create(input)
if (!result.ok) throw new WorkspaceCreateError(result.error)
return result.value.workspace

View File

@@ -8,7 +8,7 @@ import type { ObservableSnapshot } from '../contract/store.ts'
import { Notifier } from '../sessions/notifier.ts'
/** Host input retained by a local Workspace until materialization succeeds. */
export type WorkspaceCreateInput = { name: string } | { path: string }
export type WorkspaceCreateInput = { path: string }
/** Observable state of a client-local Workspace intent. */
export interface WorkspaceIntentSnapshot {
@@ -137,7 +137,6 @@ export class Workspace implements ObservableSnapshot<WorkspaceSnapshot> {
}
function intentName(input: WorkspaceCreateInput): string {
if ('name' in input) return input.name
const trimmed = input.path.replace(/[\\/]+$/, '')
return trimmed.split(/[\\/]/).pop() ?? input.path
}

View File

@@ -8,7 +8,7 @@
* `keyof SlotMap & string` is the declare-merge key pattern: SlotMap is empty
* in this compilation unit (intersection reads `never`) but consumers merge
* keys in; the rule fires on the empty-map view, not on real redundancy. */
import type { Context } from 'cordis'
import type { Context } from '@deepseek-ai/cordis'
import type { SlotMap } from '@deepseek-ai/dsh-client-ui-slots'
import type { InvariantInstaller } from '@deepseek-ai/dsh-invariants'