Merge remote-tracking branch 'origin/master' into xtr/react-loop-simplification

# Conflicts:
#	.agents/notes/implemented/architecture/2026-06-21-bounded-llm-request-recovery.i18n.yaml
#	.agents/notes/implemented/feature/2026-06-18-compaction-capability-seam.i18n.yaml
#	.agents/notes/implemented/feature/2026-06-18-compaction-capability-seam.md
#	.agents/notes/implemented/feature/2026-06-18-compaction-capability-seam.zh.md
#	.agents/notes/implemented/feature/2026-07-06-sandbox.i18n.yaml
#	.agents/notes/implemented/feature/2026-07-06-sandbox.md
#	.agents/notes/implemented/feature/2026-07-06-sandbox.zh.md
#	.agents/notes/implemented/feature/2026-07-27-tmux-location-context.i18n.yaml
#	.agents/notes/implemented/simplification/2026-06-20-public-agent-stop-surface.i18n.yaml
#	.agents/notes/implemented/simplification/2026-07-28-remove-synthetic-log-only-turns.i18n.yaml
#	.agents/notes/implemented/simplification/2026-07-30-private-agent-send.i18n.yaml
#	docs/architecture.i18n.yaml
#	docs/architecture.md
#	docs/architecture.zh.md
#	docs/config-catalog.md
#	docs/cordis-catalog/events.md
#	docs/cordis-catalog/services.md
#	docs/core-data-structures/compaction.i18n.yaml
#	docs/core-data-structures/core.i18n.yaml
#	docs/core-data-structures/core.md
#	docs/core-data-structures/core.zh.md
#	docs/core-data-structures/llm-streaming.i18n.yaml
#	docs/core-data-structures/llm-streaming.md
#	docs/core-data-structures/llm-streaming.zh.md
#	docs/core-data-structures/session.i18n.yaml
#	docs/event-producer-consumer.md
#	docs/module-graph.md
#	docs/persistence-catalog.md
#	examples/acp-agent/tests/goal-snapshots/goal-session/session.expected.jsonl
#	examples/acp-agent/tests/snapshots/advanced-toolchain/session.1.jsonl
#	examples/acp-agent/tests/snapshots/advanced-toolchain/session.2.jsonl
#	examples/acp-agent/tests/snapshots/advanced-toolchain/session.jsonl
#	examples/acp-agent/tests/snapshots/bash-spill/session.jsonl
#	examples/acp-agent/tests/snapshots/bash-tool-turn/session.jsonl
#	examples/acp-agent/tests/snapshots/both-mode-turn/session.jsonl
#	examples/acp-agent/tests/snapshots/cancel-tool-calls/session.jsonl
#	examples/acp-agent/tests/snapshots/cancel/session.jsonl
#	examples/acp-agent/tests/snapshots/code-mode-turn/session.jsonl
#	examples/acp-agent/tests/snapshots/code-mode-workspace-context/session.jsonl
#	examples/acp-agent/tests/snapshots/cordis-inspect-jsdoc/session.jsonl
#	examples/acp-agent/tests/snapshots/empty-response-retry/session.jsonl
#	examples/acp-agent/tests/snapshots/error-finish/session.jsonl
#	examples/acp-agent/tests/snapshots/escalation-approved/session.jsonl
#	examples/acp-agent/tests/snapshots/escalation-rejected/session.jsonl
#	examples/acp-agent/tests/snapshots/fs-edit/session.jsonl
#	examples/acp-agent/tests/snapshots/fs-escalation-approved/session.jsonl
#	examples/acp-agent/tests/snapshots/fs-glob-sampling/session.jsonl
#	examples/acp-agent/tests/snapshots/fs-policy-reject/session.jsonl
#	examples/acp-agent/tests/snapshots/fs-read-window/session.jsonl
#	examples/acp-agent/tests/snapshots/fs-read/session.jsonl
#	examples/acp-agent/tests/snapshots/fs-write-overwrite/session.jsonl
#	examples/acp-agent/tests/snapshots/fs-write/session.jsonl
#	examples/acp-agent/tests/snapshots/hook-cc-invalid-matcher/session.jsonl
#	examples/acp-agent/tests/snapshots/hook-cc-posttool-block/session.jsonl
#	examples/acp-agent/tests/snapshots/hook-cc-posttool-context/session.jsonl
#	examples/acp-agent/tests/snapshots/hook-cc-pretool-ask/session.jsonl
#	examples/acp-agent/tests/snapshots/hook-cc-pretool-deny/session.jsonl
#	examples/acp-agent/tests/snapshots/hook-cc-promptsubmit-context/session.jsonl
#	examples/acp-agent/tests/snapshots/hook-cc-stop-continue/session.jsonl
#	examples/acp-agent/tests/snapshots/hook-codex-invalid-matcher/session.jsonl
#	examples/acp-agent/tests/snapshots/hook-codex-posttool-block/session.jsonl
#	examples/acp-agent/tests/snapshots/hook-codex-posttool-context/session.jsonl
#	examples/acp-agent/tests/snapshots/hook-codex-pretool-block/session.jsonl
#	examples/acp-agent/tests/snapshots/hook-codex-promptsubmit-context/session.jsonl
#	examples/acp-agent/tests/snapshots/hook-codex-stop-continue/session.jsonl
#	examples/acp-agent/tests/snapshots/lsp-definition/session.jsonl
#	examples/acp-agent/tests/snapshots/multi-turn/session.jsonl
#	examples/acp-agent/tests/snapshots/packed-chunks/session.jsonl
#	examples/acp-agent/tests/snapshots/parallel-tool-calls/session.jsonl
#	examples/acp-agent/tests/snapshots/pty-tools/session.jsonl
#	examples/acp-agent/tests/snapshots/repeat-tool-guard/session.jsonl
#	examples/acp-agent/tests/snapshots/session-query-spill/session.jsonl
#	examples/acp-agent/tests/snapshots/session-sandbox-root/session.jsonl
#	examples/acp-agent/tests/snapshots/session-title-after-turn/session.jsonl
#	examples/acp-agent/tests/snapshots/skill-load/session.jsonl
#	examples/acp-agent/tests/snapshots/subagent-depth-two-rejection/session.1.jsonl
#	examples/acp-agent/tests/snapshots/subagent-depth-two-rejection/session.2.jsonl
#	examples/acp-agent/tests/snapshots/subagent-depth-two-rejection/session.jsonl
#	examples/acp-agent/tests/snapshots/subagent-fork/session.1.jsonl
#	examples/acp-agent/tests/snapshots/subagent-fork/session.jsonl
#	examples/acp-agent/tests/snapshots/subagent-mixed/session.1.jsonl
#	examples/acp-agent/tests/snapshots/subagent-mixed/session.2.jsonl
#	examples/acp-agent/tests/snapshots/subagent-mixed/session.jsonl
#	examples/acp-agent/tests/snapshots/subagent-multi/session.1.jsonl
#	examples/acp-agent/tests/snapshots/subagent-multi/session.2.jsonl
#	examples/acp-agent/tests/snapshots/subagent-multi/session.jsonl
#	examples/acp-agent/tests/snapshots/subagent-spawn/session.1.jsonl
#	examples/acp-agent/tests/snapshots/subagent-spawn/session.jsonl
#	examples/acp-agent/tests/snapshots/text-turn/session.jsonl
#	examples/acp-agent/tests/snapshots/todo-write/session.jsonl
#	examples/acp-agent/tests/snapshots/tool-call-turn/session.jsonl
#	examples/acp-agent/tests/snapshots/web-fetch/session.jsonl
#	examples/acp-agent/tests/snapshots/workflow-run/session.1.jsonl
#	examples/acp-agent/tests/snapshots/workflow-run/session.jsonl
#	examples/acp-agent/tests/snapshots/workspace-context/session.jsonl
#	examples/acp-agent/tests/snapshots/workspace-edit/session.jsonl
#	examples/headless-agent/tests/semantic-checkpoint-snapshots/tool-outcome-unknown/session.expected.jsonl
#	examples/headless-agent/tests/snapshots/advanced-toolchain/session.1.jsonl
#	examples/headless-agent/tests/snapshots/advanced-toolchain/session.2.jsonl
#	examples/headless-agent/tests/snapshots/advanced-toolchain/session.jsonl
#	examples/headless-agent/tests/snapshots/advanced-toolchain/stream-json.expected.jsonl
#	examples/headless-agent/tests/snapshots/goal-tools/stream-json.expected.jsonl
#	examples/headless-agent/tests/snapshots/missing-credential/stream-json.expected.jsonl
#	examples/headless-agent/tests/snapshots/provider-retry/stream-json.expected.jsonl
#	examples/headless-agent/tests/snapshots/pty-tools/session.jsonl
#	examples/headless-agent/tests/snapshots/pty-tools/stream-json.expected.jsonl
#	examples/headless-agent/tests/snapshots/ralph-loop/stream-json.expected.jsonl
#	examples/headless-agent/tests/subagent-inheritance-snapshots/parent-override/child.expected.jsonl
#	examples/headless-agent/tests/subagent-inheritance-snapshots/parent-override/parent.expected.jsonl
#	examples/jsonrpc-agent/tests/snapshots/bash-tool/notifications.expected.jsonl
#	examples/jsonrpc-agent/tests/snapshots/bash-tool/session.jsonl
#	examples/jsonrpc-agent/tests/snapshots/persistent-tools/notifications.expected.jsonl
#	examples/jsonrpc-agent/tests/snapshots/persistent-tools/session.jsonl
#	examples/jsonrpc-agent/tests/snapshots/subagent-spawn/notifications.expected.jsonl
#	examples/jsonrpc-agent/tests/snapshots/subagent-spawn/session.1.jsonl
#	examples/jsonrpc-agent/tests/snapshots/subagent-spawn/session.jsonl
#	examples/jsonrpc-agent/tests/snapshots/text-turn/notifications.expected.jsonl
#	examples/jsonrpc-agent/tests/snapshots/text-turn/session.jsonl
#	packages/client/runtime/README.i18n.yaml
#	packages/client/runtime/src/client/sessions/request-inspection.ts
#	packages/compact/compact-basic/README.i18n.yaml
#	packages/compact/compact-basic/README.md
#	packages/compact/compact-basic/README.zh.md
#	packages/compact/compact-basic/src/index.ts
#	packages/context/time-context/tests/time-context.spec.ts
#	packages/context/tmux-context/README.i18n.yaml
#	packages/context/tmux-context/tests/tmux-context.spec.ts
#	packages/context/workspace-context/tests/workspace-context.spec.ts
#	packages/cordis/tool-cordis/src/api-catalog.ts
#	packages/core/agent-loop/README.i18n.yaml
#	packages/core/agent-loop/README.md
#	packages/core/agent-loop/README.zh.md
#	packages/core/agent-loop/src/agent.ts
#	packages/core/agent/README.i18n.yaml
#	packages/core/agent/README.md
#	packages/core/agent/README.zh.md
#	packages/core/agent/src/types.ts
#	packages/core/session/README.i18n.yaml
#	packages/core/session/README.md
#	packages/core/session/README.zh.md
#	packages/fs/tool-str-replace-editor/tests/tools.spec.ts
#	packages/goal/command-goal/tests/command-goal.spec.ts
#	packages/goal/goal/tests/goal.spec.ts
#	packages/host/apiproxy/README.i18n.yaml
#	packages/host/apiproxy/README.md
#	packages/host/apiproxy/README.zh.md
#	packages/host/apiproxy/src/api/index.ts
#	packages/host/apiproxy/tests/api-proxy-workspace.spec.ts
#	packages/llm/llm/README.i18n.yaml
#	packages/llm/llm/README.md
#	packages/llm/llm/README.zh.md
#	packages/llm/llm/src/index.ts
#	packages/pty/pty-local/tests/index.spec.ts
#	packages/pty/pty-local/tests/local.spec.ts
#	packages/pty/pty/tests/service.spec.ts
#	packages/pty/tool-bash-persistent/tests/loader-composition.spec.ts
#	packages/pty/tool-bash-persistent/tests/tools.spec.ts
#	packages/pty/tool-pty/tests/loader-composition.spec.ts
#	packages/pty/tool-pty/tests/tools.spec.ts
#	packages/session-persistence/session-checkpoint-policy/tests/crash-recovery.e2e.ts
#	packages/skill/tool-skill/tests/tool-skill.spec.ts
#	packages/tasks/tasks-local/tests/tasks.spec.ts
#	packages/ui/tui/README.i18n.yaml
#	packages/ui/tui/tests/tui.spec.ts
#	packages/ui/user-approval/src/index.ts
#	packages/ui/user-approval/tests/approval.spec.ts
This commit is contained in:
_Kerman
2026-07-31 22:16:40 +08:00
1010 changed files with 105572 additions and 5504 deletions

View File

@@ -46,7 +46,8 @@ export interface ISession {
*/
updateQueue(itemId: MessageId, action: QueueAction): Promise<RpcResult<{ accepted: true }>>
/**
* Cancel the running turn.
* Cancel the running turn. Pending queued work remains and resumes in FIFO
* order after the Host reaches cancellation quiescence.
* @returns acceptance, or the business error.
*/
cancel(): Promise<RpcResult<{ accepted: true }>>

View File

@@ -8,8 +8,9 @@
* explicit act of widening what features may do to the sessions domain.
*/
import type { Context } from 'cordis'
import type { SessionId } from '@deepseek-ai/dsh-client-connection/client'
import type { RpcResult, SessionId } from '@deepseek-ai/dsh-client-connection/client'
import type { HostObservable, SessionMaybeProvideInfo } from '@deepseek-ai/dsh-client-ui-slots'
import type { SessionSearchResultItem } from '../sessions/manager.ts'
import type {
SessionBinding, SessionListState, SessionProvideDescriptor,
} from '../sessions/service.ts'
@@ -22,6 +23,12 @@ export interface ISessions {
readonly list: ObservableSnapshot<SessionListState>
/** Atomic current-session provide projection (the renderer host's `sessions.provideInfo` feed). */
readonly currentProvideInfo: HostObservable<SessionMaybeProvideInfo>
/**
* The `session.search` result bound the wire schema fixes, exposed to
* presentation as injected data. Not per-connection state: every transport
* (fixture included) reports the same number.
*/
readonly searchResultLimit: number
/**
* Select a session as current.
* @param id - session id (must exist in the list; unknown ids fail loud).
@@ -29,6 +36,17 @@ export interface ISessions {
open(id: SessionId): void
/** Clear the current selection into the no-session view state. */
clear(): void
/**
* Search the Host's visible message-content index. Results stay
* request-local; the list snapshot remains the metadata authority.
* @param query - non-blank literal phrase.
* @param signal - cancellation for a superseded search.
* @returns bounded results, or a business/transport error.
*/
search(
query: string,
signal: AbortSignal,
): Promise<RpcResult<{ items: SessionSearchResultItem[]; hasMore: boolean }>>
/**
* Fork a session from a completed-turn prefix of the source; on resolution
* the child is in the list store and `open()` can target it.

View File

@@ -76,4 +76,11 @@ export interface IWorkspaces {
* @returns the updated Workspace view.
*/
insertSessionBefore(workspaceId: WorkspaceId, sessionId: SessionId, beforeSessionId?: SessionId): Promise<WorkspaceView>
/**
* Archive a session into the registry-global set (hidden from grouping
* surfaces; session log and accounting slot remain). Archiving the current
* session clears the selection into the New Session view state.
* @param sessionId - session to archive.
*/
archiveSession(sessionId: SessionId): Promise<void>
}

View File

@@ -31,7 +31,7 @@ export type { IWorkspaces } from './contract/workspaces.ts'
export type {
SessionBinding, SessionListState, SessionProvideContribution, SessionProvideDescriptor, SessionSummary,
} from './sessions/service.ts'
export type { SessionListPhase } from './sessions/manager.ts'
export type { SessionListPhase, SessionSearchResultItem } from './sessions/manager.ts'
export type { WorkspaceListPhase } from './workspaces/manager.ts'
export type { WorkspaceListState } from './workspaces/service.ts'
export type {
@@ -44,9 +44,10 @@ export type {
} from './contract/store.ts'
export type {
AssistantBlock, AssistantMessageNode, AssistantProvenanceView, AssistantRequestConfig,
AssistantTiming, CodeSubCall, CommandNode, ComposerPhase, ContextMessageNode, ConversationNode,
ConversationSnapshot, QueuedMessage, RunningToolCall,
SteeringMessageNode, TodoItem, ToolResultNode, UnknownSurfaceNode, UserMessageNode,
AssistantTiming, CodeSubCall, CommandNode, CompactionSummaryNode, ComposerPhase,
ContextMessageNode, ConversationNode, ConversationSnapshot, ModelRetryNode, QueuedMessage,
RunningToolCall,
SteeringMessageNode, TodoItem, ToolResultNode, TurnErrorNode, UnknownSurfaceNode, UserMessageNode,
} from './sessions/conversation.ts'
export type {
ConversationContext, ConversationContextOriginKind,

View File

@@ -5,6 +5,7 @@
import type { CommandId } from '@deepseek-ai/dsh-commands/brand'
import type { ContentBlock } from '@deepseek-ai/dsh-llm/types'
import type { LlmRetryEventData } from '@deepseek-ai/dsh-llm-retry/types'
import type { TodoItem } from '@deepseek-ai/dsh-session/types'
import type {
MessageId, RpcError, SessionId, ToolCallView, ToolResultView,
@@ -121,6 +122,32 @@ export interface ContextMessageNode {
source: unknown
}
/** Durable notice that a closed failed step is waiting for a model-request retry. */
export type ModelRetryNode = LlmRetryEventData & {
kind: 'model-retry'
seq: number
/** Unix epoch ms from the llm/retry session event. */
time: number
/**
* Client-derived lifecycle: scheduled until a retry turn starts, started
* once it does, or cancelled when the failed turn aborts first.
*/
retryState: 'scheduled' | 'started' | 'cancelled'
}
/** Durable terminal failure for a turn that has no scheduled retry. */
export interface TurnErrorNode {
kind: 'turn-error'
/** Seq of the owning turn/end event. */
seq: number
/** Unix epoch ms from the turn/end event. */
time: number
turn: number
step: number
message: string
code?: string
}
/** A tool result paired (when in-window) with its call head. */
export interface ToolResultNode {
kind: 'tool-result'
@@ -142,7 +169,32 @@ export interface ToolResultNode {
resultView: ToolResultView | null
}
/** Fallback for surface events this UI version does not know. */
/**
* One landed compaction, marked at the checkpoint's own log position. The
* conversation it shadowed on the model surface stays in the transcript above
* it: the marker reports where the model stopped seeing that history, it does
* not replace it. The framed checkpoint payload is an instruction envelope
* written for the model and never renders.
*/
export interface CompactionSummaryNode {
kind: 'compaction'
/** Seq of the replacement `user/message` that landed the checkpoint. */
seq: number
/** Unix epoch ms of the checkpoint event. */
time: number
/** Summary text from the checkpoint's `compact/summary` provenance; null when
* the window cut left that provenance outside (the marker is then not expandable). */
summary: string | null
}
/**
* Fallback for surface events this UI version does not know: the documented
* default arm of `SessionEventMap`, which is merge-extensible, so the
* projection's switch cannot end in `assertNever`. No event produces this node
* today — `isAppendSurfaceEvent` admits only the four types in core's
* `SurfaceEventType`, and each has its own arm — and it exists so widening that
* set core-side degrades to a raw row instead of dropping the event silently.
*/
export interface UnknownSurfaceNode {
kind: 'unknown'
seq: number
@@ -155,7 +207,7 @@ export interface UnknownSurfaceNode {
/**
* One slash-command lifecycle folded from the log-only `command/run` /
* `command/done` pair (paired by commandId, mirroring tool call↔result).
* Log-only events never enter the surface fold, so the FoldAdapter indexes
* Log-only events are not surface events, so the TranscriptAdapter indexes
* them separately and merges the nodes into the flow by seq. A window cut
* between the pair soft-falls like tool pairs: a done with no in-window run
* still builds a node (name/args null), and a run with no done renders as
@@ -183,8 +235,11 @@ export type ConversationNode =
| AssistantMessageNode
| SteeringMessageNode
| ContextMessageNode
| ModelRetryNode
| TurnErrorNode
| ToolResultNode
| CommandNode
| CompactionSummaryNode
| UnknownSurfaceNode
/**
@@ -194,7 +249,7 @@ export type ConversationNode =
* {@link RunningToolCall} (rows derive the running state from the shape,
* exactly as for native calls) and its `tool/code-dispatch` settlement
* replaces it in place with the {@link ToolResultNode} form. Never part of
* the surface `nodes` flow — sub-calls live under their parent via
* the transcript `nodes` flow — sub-calls live under their parent via
* {@link ConversationSnapshot.codeDispatches}. `callId` is the deterministic
* sub-call id (`<parent>:code:<n>`); the call side carries the sub-tool name
* and its JSON-stringified logged arguments; `content`/`isError` are the
@@ -265,10 +320,8 @@ export interface PromptError {
/** The immutable snapshot contract Session hands to uSES (see the web client architecture RFC). */
export interface ConversationSnapshot {
sessionId: SessionId
/** Surface fold product (finalized conversation nodes in surface order). */
/** Human transcript plus retry notices and interrupted-turn terminal nodes in event order. */
nodes: readonly ConversationNode[]
/** Fold degradation flag (cross-window replace defense): when true, nodes come from the lenient linear scan. */
foldDegraded: boolean
partial: PartialAssistant | null
runningCalls: readonly RunningToolCall[]
/**

View File

@@ -0,0 +1,10 @@
/**
* Convert a durable failure into copy that is safe to expose in the GUI.
* @param failure - Structured failure preserved by the session event.
* @returns Display-safe copy for client projections.
*/
export function displayFailureMessage(failure: { code?: string; message: string }): string {
// Provider AUTH messages may echo a masked or partially preserved credential.
// Keep the raw diagnostic in the session log, but never project it into UI state.
return failure.code === 'AUTH' ? 'API key is invalid' : failure.message
}

View File

@@ -1,283 +0,0 @@
// FoldAdapter: core SurfaceManager wiring + node materialization cache.
// Padding sentinels solve the paged-window seq offset (core fold asserts seq === index);
// a cross-window replace throw degrades to a lenient linear scan (foldDegraded —
// the degradation lives in one branch function in this file, zero scattered removal points).
import type { SessionEvent } from '@deepseek-ai/dsh-session/types'
// Subpath export (package.json exports "./surface", alias added for this): all value imports
// go through it — the package root points at lib/index.js (needs a build) which the vite
// browser bundle cannot resolve; surface.ts has no Node dependencies.
import {
SurfaceManager, isSurfaceEligibleType, isSurfaceEvent,
} from '@deepseek-ai/dsh-session/surface'
import type { CommandId } from '@deepseek-ai/dsh-commands/brand'
import type { ToolCallView, ToolEventView, ToolResultView } from '@deepseek-ai/dsh-client-connection/client'
import type { CommandNode, ConversationNode } from './conversation.ts'
import { toAssistantBlocks } from './conversation.ts'
/** In-window tool/call index entry (result-card backfill + runningCalls material). */
export interface CallIndexEntry {
name: string
argsRaw: string
turn: number
step: number
/** Unix epoch ms of the tool/call event. */
time: number
/** Wire view riding the tool/call (envelope-level; never inside the event). */
callView: ToolCallView | null
}
/** Non-surface sentinel used to preserve paged-window sequence offsets.
* `noop/padding` is deliberately not a real event type, so it cannot acquire
* surface behavior; this cast is the only synthetic event entry point.
*/
function paddingEvent(seq: number): SessionEvent {
return { type: 'noop/padding', seq, time: 0, data: {} } as unknown as SessionEvent
}
function replacementCrossesWindowHead(event: SessionEvent, baseSeq: number): boolean {
if (!isSurfaceEvent(event) || event.surfaceOp === 'append') return false
return event.surfaceOp.start < baseSeq || event.surfaceOp.end < baseSeq
}
/** One event -> UI node (pure function; the six-variant ConversationNode union). */
function materializeNode(
event: SessionEvent,
callIndex: ReadonlyMap<string, CallIndexEntry>,
resultView: ToolResultView | null,
): ConversationNode {
switch (event.type) {
case 'user/message':
// Injected context (plugin/goal source) folds to a context node, not a
// user message; only a direct human prompt is a user node.
if (event.data.source.kind !== 'user') {
return {
kind: 'context', seq: event.seq, time: event.time,
content: event.data.content, source: event.data.source,
}
}
return {
kind: 'user', seq: event.seq, time: event.time,
content: event.data.content, source: event.data.source,
}
case 'assistant/message':
return {
kind: 'assistant', seq: event.seq, time: event.time,
turn: event.data.turn, step: event.data.step,
blocks: toAssistantBlocks(event.data.message.content), usage: event.data.usage,
}
case 'steering/message':
return {
kind: 'steering', seq: event.seq, time: event.time, turn: event.data.turn,
content: event.data.message.content, source: event.data.message.source,
}
case 'tool/result': {
const result = event.data.message.content[0]
const callId = String(event.data.message.source.callId)
const call = callIndex.get(callId)
return {
kind: 'tool-result', seq: event.seq, time: event.time,
callId,
call: call ? { name: call.name, argsRaw: call.argsRaw } : null,
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,
}
}
/* v8 ignore next 2 -- defensive arm: fold output only carries the four
surface-eligible types, and each has a case above; reachable only if core
adds an eligible type. */
default:
return {
kind: 'unknown', seq: event.seq, time: event.time,
type: event.type, data: (event as { data?: unknown }).data,
}
}
}
/** Window fold over the core SurfaceManager (sentinel padding for the seq offset; degrades to a linear scan on cross-window replace). */
export class FoldAdapter {
/** padded = [sentinel x baseSeq, ...window events]; SurfaceManager borrows this reference for lazy incremental folding. */
private padded: SessionEvent[] = []
private baseSeq = 0
private surface = new SurfaceManager(this.padded)
private nodeCache = new Map<number, ConversationNode>()
private degraded = false
private callIdx = new Map<string, CallIndexEntry>()
/** Wire result views keyed by the tool/result event's seq (views ride the envelope, not the event). */
private resultViews = new Map<number, ToolResultView>()
/**
* Command lifecycle nodes by commandId (insertion = run order). The
* `command/run`/`command/done` pair is log-only, so the surface fold never
* emits it; this index folds the pair (done settles its run's node in
* place) and nodes() merges the products into the flow by seq. Window cuts
* soft-fall like tool pairs: a done with no in-window run still builds a
* node.
*/
private commandIdx = new Map<string, CommandNode>()
/** Window revision (bumped on reset/append) keying the nodes() result cache: an unchanged
* window returns the previous ARRAY reference, not just cached elements — the snapshot's
* reference-stability contract (§A.9.4) starts here. */
private rev = 0
private nodesResult: { rev: number; value: { nodes: ConversationNode[]; degraded: boolean } } | null = null
/** In-window tool/call index (Session uses it for runningCalls and result-card backfill). */
get callIndex(): ReadonlyMap<string, CallIndexEntry> {
return this.callIdx
}
/**
* Window rebuild (after open/resync/page prepend): new padded array, new
* SurfaceManager, cleared cache, rebuilt callIndex.
* @param events - the new window contents (seq-ascending).
* @param baseSeq - seq of the window head (sentinels pad below it).
* @param views - per-event wire views aligned with `events` by index (undefined slots for view-less events).
*/
reset(events: readonly SessionEvent[], baseSeq: number, views?: readonly (ToolEventView | undefined)[]): void {
this.rev++
this.baseSeq = baseSeq
this.padded = []
for (let i = 0; i < baseSeq; i++) this.padded.push(paddingEvent(i))
for (const event of events) this.padded.push(event)
this.surface = new SurfaceManager(this.padded)
this.nodeCache.clear()
this.degraded = events.some(event => replacementCrossesWindowHead(event, baseSeq))
this.callIdx = new Map()
this.resultViews.clear()
this.commandIdx = new Map()
for (let i = 0; i < events.length; i++) {
const event = events[i]
/* v8 ignore next -- dense-array guard: i stays within events.length, so the undefined arm needs a sparse array no caller builds. */
if (event !== undefined) {
this.indexCall(event, views?.[i])
this.indexCommand(event)
}
}
}
/**
* Tail append (live session/event): push into the same array (incremental
* lazy fold applies) + incremental callIndex upkeep.
* @param event - the live event (seq = window tail + 1).
* @param view - host-computed tool view paired with the event when it is a tool call/result; indexed for card rendering.
*/
append(event: SessionEvent, view?: ToolEventView): void {
this.rev++
this.padded.push(event)
if (replacementCrossesWindowHead(event, this.baseSeq)) this.degraded = true
this.indexCall(event, view)
this.indexCommand(event)
}
/**
* Current node array + degradation flag. Same revision -> same array
* reference (memo boundary); node object references always come from the per-seq cache.
* @returns the fold projection for the current window revision.
*/
nodes(): { nodes: ConversationNode[]; degraded: boolean } {
if (this.nodesResult !== null && this.nodesResult.rev === this.rev) return this.nodesResult.value
let seqs: readonly number[]
if (this.degraded) {
seqs = this.degradedSeqs()
} else {
try {
seqs = this.surface.nodes
} catch (error) {
console.error('[web-runtime] surface fold failed, degrading to linear scan:', error)
this.degraded = true
seqs = this.degradedSeqs()
}
}
const out: ConversationNode[] = []
for (const seq of seqs) {
const cached = this.nodeCache.get(seq)
if (cached !== undefined) {
out.push(cached)
continue
}
const event = this.padded[seq]
/* v8 ignore next -- sparse guard: both seq sources (surface fold and degradedSeqs) only emit indexes present in padded. */
if (event === undefined) continue
const node = materializeNode(event, this.callIdx, this.resultViews.get(seq) ?? null)
this.nodeCache.set(seq, node)
out.push(node)
}
// Command nodes fold outside the surface (log-only events); merge by seq.
// Both inputs are seq-ascending (surface order and run-index insertion
// order share the log order), so one linear merge keeps flow order.
let nodes = out
if (this.commandIdx.size > 0) {
nodes = []
const commands = [...this.commandIdx.values()]
let next = 0
for (const node of out) {
for (let cmd = commands[next]; cmd !== undefined && cmd.seq < node.seq; cmd = commands[++next]) {
nodes.push(cmd)
}
nodes.push(node)
}
for (let cmd = commands[next]; cmd !== undefined; cmd = commands[++next]) nodes.push(cmd)
}
const value = { nodes, degraded: this.degraded }
this.nodesResult = { rev: this.rev, value }
return value
}
/** Degradation branch: lenient linear scan ignoring surfaceOp/replace (all surface-eligible events in append order). */
private degradedSeqs(): number[] {
const seqs: number[] = []
for (let i = this.baseSeq; i < this.padded.length; i++) {
const event = this.padded[i]
if (event !== undefined && isSurfaceEligibleType(event.type)) seqs.push(event.seq)
}
return seqs
}
/** Fold one command lifecycle event into its node (run mints, done settles in place; done-only soft-falls). */
private indexCommand(event: SessionEvent): void {
// Log-only plugin events: the host-side dsh-commands declaration cannot
// enter the client program, so this wire consumer narrows structurally
// (the same posture as tool/code-dispatch in session.ts).
if ((event.type as string) === 'command/run') {
const data = event.data as unknown as { commandId: CommandId; name: string; args: string }
this.commandIdx.set(data.commandId, {
kind: 'command', seq: event.seq, time: event.time,
commandId: data.commandId, name: data.name, args: data.args, outcome: null,
})
return
}
if ((event.type as string) !== 'command/done') return
const data = event.data as unknown as { commandId: CommandId; kind: 'success' | 'error'; text?: string }
const run = this.commandIdx.get(data.commandId)
const outcome = { kind: data.kind, ...data.text === undefined ? {} : { text: data.text } }
if (run === undefined) {
// Cross-window cut: the run page fell out of the window — build the
// node from the done alone (same soft-fall as a call-less tool result).
this.commandIdx.set(data.commandId, {
kind: 'command', seq: event.seq, time: event.time,
commandId: data.commandId, name: null, args: null, outcome,
})
return
}
// Settle in place: a fresh node object (published references stay immutable).
this.commandIdx.set(data.commandId, { ...run, outcome })
}
private indexCall(event: SessionEvent, view?: ToolEventView): void {
if (event.type === 'tool/result') {
if (view?.for === 'result') this.resultViews.set(event.seq, view.view)
return
}
if (event.type !== 'tool/call') return
this.callIdx.set(String(event.data.callId), {
name: event.data.name, argsRaw: event.data.arguments, turn: event.data.turn, step: event.data.step,
time: event.time,
callView: view?.for === 'call' ? view.view : null,
})
// No backfill into already-materialized tool-result nodes for this callId
// (window order puts the call before its result; cannot happen on the normal path).
}
}

View File

@@ -2,7 +2,10 @@
// dispatch entry + list state, constructed and held by SessionsService (one per client runtime).
// List data never enters zustand; React connects via subscribe/getListSnapshot.
import type { IApiClient, HostFrame, MuxFrame, RpcError, RpcRequest, RpcResult, SessionId, SessionSummary, WorkspaceId } from '@deepseek-ai/dsh-client-connection/client'
import type {
IApiClient, HostFrame, MuxFrame, RpcError, RpcRequest, RpcResult, SessionId,
SessionSummary, 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.
import { transportError } from '@deepseek-ai/dsh-host-apiproxy/api'
@@ -27,6 +30,12 @@ import { Session } from './session.ts'
*/
export type SessionListPhase = 'pending' | 'ready'
/** Request-local content hit returned to sidebar search consumers. */
export interface SessionSearchResultItem {
sessionId: SessionId
snippet: string
}
/** Immutable session-list snapshot for useSessionList. */
export interface SessionListSnapshot {
items: readonly SessionListEntry[]
@@ -248,6 +257,24 @@ export class SessionManager {
return this.listInflight
}
/**
* Search visible session message content without adding transient query
* state to the list snapshot.
* @param query - non-blank literal phrase.
* @param signal - cancellation for superseded UI queries.
* @returns the Host result or a folded transport error.
*/
async search(
query: string,
signal: AbortSignal,
): Promise<RpcResult<{ items: SessionSearchResultItem[]; hasMore: boolean }>> {
try {
return (await this.api.sessions.search({ query }, signal)).result
} catch (error: unknown) {
return transportError(error)
}
}
/**
* Contract session.create; on success merge into summaries immediately (no
* wait for the next refresh). A created session is blank by definition

View File

@@ -8,6 +8,7 @@ import type { SessionEvent } from '@deepseek-ai/dsh-session/types'
import type {
AssistantProvenanceView, AssistantRequestConfig,
} from './conversation.ts'
import { displayFailureMessage } from './failure-display.ts'
export type {
AssistantProvenanceView, AssistantRequestConfig,
@@ -35,40 +36,55 @@ export interface RequestPromptChange {
previous?: ConversationPromptSnapshot
}
/** One provider request reconstructed from durable request lifecycle events. */
export interface RequestView {
/** Request category; compaction is a purpose, not a separate projection. */
purpose: 'assistant' | 'compaction'
/** Lifecycle fields shared by ordinary generation and compaction requests. */
interface RequestViewBase {
/** Sequence that opened the operation represented by this request. */
startSeq: number
turn: number
/** Agent-loop step, or zero for a direct compaction request. */
step: number
startedAt: number
completedAt: number | null
status: 'running' | 'complete' | 'error'
error?: string
/** Effective ordinary request input, inherited until a later header changes it. */
prompt?: ConversationPromptSnapshot
/** Prompt change logged while preparing this request. */
promptChange?: RequestPromptChange
provenance?: AssistantProvenanceView
requestConfig?: AssistantRequestConfig
usage?: unknown
/** Assistant message or compaction summary sequence produced by this request. */
resultSeq?: number
}
/** One ordinary assistant generation reconstructed from durable request events. */
interface AssistantRequestView extends RequestViewBase {
purpose: 'assistant'
turn: number
/** Agent-loop step that issued this request. */
step: number
/** Effective ordinary request input, inherited until a later header changes it. */
prompt?: ConversationPromptSnapshot
/** Prompt change logged while preparing this request. */
promptChange?: RequestPromptChange
/** Retry ordinal scheduled after a failed ordinary request. */
retry?: number
maxRetries?: number
retryDelayMs?: number
}
/** One compaction provider request, either turn-owned or standalone between turns. */
interface CompactionRequestView extends RequestViewBase {
purpose: 'compaction'
/** Owning turn, or `null` when manual compaction ran between turns. */
turn: number | null
/** Direct compaction requests do not consume an agent-loop step. */
step: 0
/** Compaction replacement message sequence, when one was committed. */
replacementSeq?: number
/** Safe compaction summary projection. */
summary?: readonly ContentBlock[]
/** Complete compaction provider output before the safe projection. */
rawOutput?: readonly ContentBlock[]
/** Retry ordinal scheduled after a failed ordinary request. */
retry?: number
maxRetries?: number
retryDelayMs?: number
}
/** One provider request reconstructed from durable request lifecycle events. */
export type RequestView = AssistantRequestView | CompactionRequestView
/** Immutable request-centric projection derived from one history window. */
export interface RequestInspectionSnapshot {
requests: readonly RequestView[]
@@ -110,7 +126,7 @@ interface CompactionStartEvent {
type: 'compact/start'
seq: number
time: number
data: { turn: number }
data: { turn: number | null }
}
interface CompactionSummaryEvent {
@@ -131,7 +147,7 @@ interface CompactionEndEvent {
type: 'compact/end'
seq: number
time: number
data: { turn: number; error?: string }
data: { turn: number | null; error?: string }
}
function requestKey(turn: number, step: number): string {
@@ -228,10 +244,21 @@ function deriveRequests(events: readonly SessionEvent[]): readonly RequestView[]
let activePrompt: ConversationPromptSnapshot | undefined
let activeCompaction: number | undefined
const update = (index: number | undefined, change: Partial<RequestView>): void => {
const updateAssistant = (
index: number | undefined,
change: Partial<Omit<AssistantRequestView, 'purpose'>>,
): void => {
if (index === undefined) return
const request = requests[index]
if (request !== undefined) requests[index] = { ...request, ...change }
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) {
@@ -263,7 +290,7 @@ function deriveRequests(events: readonly SessionEvent[]): readonly RequestView[]
}
const change = promptChange(activePrompt, prompt, sourceEvent)
activePrompt = prompt
update(activeStep === undefined ? undefined : ordinaryByStep.get(activeStep), {
updateAssistant(activeStep === undefined ? undefined : ordinaryByStep.get(activeStep), {
prompt,
requestConfig: prompt.config,
...(change === undefined ? {} : { promptChange: change }),
@@ -278,8 +305,11 @@ function deriveRequests(events: readonly SessionEvent[]): readonly RequestView[]
requestKey(sourceEvent.data.turn, sourceEvent.data.step),
)
const request = index === undefined ? undefined : requests[index]
update(index, {
usage: addTokenUsage(request?.usage, sourceEvent.data.chunk.usage),
updateAssistant(index, {
usage: addTokenUsage(
request?.purpose === 'assistant' ? request.usage : undefined,
sourceEvent.data.chunk.usage,
),
})
continue
}
@@ -288,7 +318,7 @@ function deriveRequests(events: readonly SessionEvent[]): readonly RequestView[]
requestKey(sourceEvent.data.turn, sourceEvent.data.step),
)
const request = index === undefined ? undefined : requests[index]
update(index, {
updateAssistant(index, {
completedAt: sourceEvent.time,
status: 'complete',
resultSeq: sourceEvent.seq,
@@ -296,7 +326,9 @@ function deriveRequests(events: readonly SessionEvent[]): readonly RequestView[]
provider: sourceEvent.data.message.source.provider,
model: sourceEvent.data.message.source.model,
},
...(request?.usage !== undefined || sourceEvent.data.usage === undefined
...(request?.purpose === 'assistant'
&& request.usage !== undefined
|| sourceEvent.data.usage === undefined
? {}
: { usage: sourceEvent.data.usage }),
})
@@ -306,8 +338,8 @@ function deriveRequests(events: readonly SessionEvent[]): readonly RequestView[]
const key = requestKey(sourceEvent.data.turn, sourceEvent.data.step)
const index = ordinaryByStep.get(key)
const request = index === undefined ? undefined : requests[index]
if (request?.status === 'running') {
update(index, {
if (request?.purpose === 'assistant' && request.status === 'running') {
updateAssistant(index, {
completedAt: sourceEvent.time,
status: 'error',
})
@@ -317,16 +349,34 @@ function deriveRequests(events: readonly SessionEvent[]): readonly RequestView[]
}
if ((sourceEvent.type as string) === 'llm/retry') {
const event = sourceEvent as unknown as RetryEvent
update(ordinaryByStep.get(requestKey(event.data.turn, event.data.step)), {
updateAssistant(ordinaryByStep.get(requestKey(event.data.turn, event.data.step)), {
status: 'error',
error: event.data.failure.message,
error: displayFailureMessage(event.data.failure),
retry: event.data.retry,
maxRetries: event.data.maxRetries,
retryDelayMs: event.data.delayMs,
})
continue
}
if (sourceEvent.type === 'turn/end' && sourceEvent.data.reason.kind === 'error') {
const reason = sourceEvent.data.reason
updateAssistant(ordinaryByStep.get(requestKey(sourceEvent.data.turn, reason.step)), {
status: 'error',
error: displayFailureMessage('failure' in reason ? reason.failure : reason),
})
continue
}
const type = sourceEvent.type as string
if (type === 'session/end-seed' && activeCompaction !== undefined) {
updateCompaction(activeCompaction, {
completedAt: sourceEvent.time,
status: 'error',
error: 'Compaction was interrupted before completion.',
})
activeCompaction = undefined
continue
}
if (type === 'compact/start') {
const event = sourceEvent as unknown as CompactionStartEvent
activeCompaction = requests.length
@@ -343,7 +393,7 @@ function deriveRequests(events: readonly SessionEvent[]): readonly RequestView[]
}
if (type === 'compact/summary' && activeCompaction !== undefined) {
const event = sourceEvent as unknown as CompactionSummaryEvent
update(activeCompaction, {
updateCompaction(activeCompaction, {
resultSeq: event.seq,
summary: event.data.summary,
...(event.data.rawOutput === undefined ? {} : { rawOutput: event.data.rawOutput }),
@@ -366,12 +416,12 @@ function deriveRequests(events: readonly SessionEvent[]): readonly RequestView[]
&& activeCompaction !== undefined
&& isCompactionSource(sourceEvent.data.source)
) {
update(activeCompaction, { replacementSeq: sourceEvent.seq })
updateCompaction(activeCompaction, { replacementSeq: sourceEvent.seq })
continue
}
if (type !== 'compact/end' || activeCompaction === undefined) continue
const event = sourceEvent as unknown as CompactionEndEvent
update(activeCompaction, {
updateCompaction(activeCompaction, {
completedAt: event.time,
status: event.data.error === undefined ? 'complete' : 'error',
...(event.data.error === undefined ? {} : { error: event.data.error }),

View File

@@ -16,7 +16,12 @@
* survives frozen (read-only view) until the stage moves on.
*/
import type { Context, Fiber } from 'cordis'
import type { IApiClient, RpcError, SessionId, WorkspaceId } from '@deepseek-ai/dsh-client-connection/client'
import type {
IApiClient, RpcError, RpcResult, SessionId, 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.
import { SESSION_SEARCH_RESULT_LIMIT } from '@deepseek-ai/dsh-host-apiproxy/api'
import type {
HostObservable, SessionMaybeProvideInfo, SessionProvideInfo,
} from '@deepseek-ai/dsh-client-ui-slots'
@@ -26,7 +31,7 @@ import type { SessionFace } from '../contract/session.ts'
import type { ISessions } from '../contract/sessions.ts'
import { createScope, scopeOf as scopeTagOf } from '../agents/scope.ts'
import { SessionManager } from './manager.ts'
import type { SessionListPhase } from './manager.ts'
import type { SessionListPhase, SessionSearchResultItem } from './manager.ts'
import { SessionProvideChannel } from './provide.ts'
import type { Session } from './session.ts'
@@ -189,6 +194,13 @@ export interface SessionProvideDescriptor {
/** Root sessions service: list store, current selection, object-layer manager, scope tree, bindings, ancestry. */
export class SessionsService implements ISessions {
/**
* The wire schema's own result bound, re-exposed for presentation plugins as
* injected data. Not per-connection state: the `session.search` response
* schema caps `items` at this constant, so every transport (fixture included)
* reports the same number.
*/
readonly searchResultLimit = SESSION_SEARCH_RESULT_LIMIT
/** List snapshot store (list RPC + host stream increments; re-pulled on reconnect) — the useSessions standard feed, current included. */
readonly list: SnapshotStore<SessionListState>
/** The object-layer instance cluster and frame dispatch entry. */
@@ -228,7 +240,10 @@ export class SessionsService implements ISessions {
* @param ctx - client root context (scope fibers mount under it).
* @param api - wire client shared with every Session.
*/
constructor(private readonly rootCtx: Context, api: IApiClient) {
constructor(
private readonly rootCtx: Context,
api: IApiClient,
) {
this.selection = createSnapshotStore<{ sessionId?: SessionId }>(
{},
{ persist: { name: 'dsh.sessions.current' } })
@@ -307,6 +322,20 @@ export class SessionsService implements ISessions {
return this.manager.refreshList()
}
/**
* Search the Host's visible message-content index. Results stay
* request-local; the list snapshot remains the metadata authority.
* @param query - non-blank literal phrase.
* @param signal - cancellation for a superseded search.
* @returns bounded results or a business/transport error.
*/
search(
query: string,
signal: AbortSignal,
): Promise<RpcResult<{ items: SessionSearchResultItem[]; hasMore: boolean }>> {
return this.manager.search(query, signal)
}
/**
* Route a mux stream envelope into the Session object layer.
* @param envelope - validated mux stream envelope.

View File

@@ -2,6 +2,7 @@
import type { Context } from 'cordis'
import type { ContentBlock } from '@deepseek-ai/dsh-llm/types'
import type { LlmRetryEventData } from '@deepseek-ai/dsh-llm-retry/types'
import type { SessionEvent } from '@deepseek-ai/dsh-session/types'
import type {
HistoryEntry, IApiClient, MessageId, MuxFrame, QueueAction, RpcError,
@@ -12,12 +13,13 @@ import type {
import { transportError } from '@deepseek-ai/dsh-host-apiproxy/api'
import type { SessionFace } from '../contract/session.ts'
import type {
CodeSubCall, ComposerPhase, ConversationNode, ConversationSnapshot, OpenState,
PromptError, QueuedMessage, RunningToolCall,
CodeSubCall, ComposerPhase, ConversationNode, ConversationSnapshot, ModelRetryNode,
OpenState, PromptError, QueuedMessage, RunningToolCall,
} from './conversation.ts'
import type { PendingInteraction } from './pending.ts'
import { PendingWait } from './pending.ts'
import { FoldAdapter } from './fold-adapter.ts'
import { TranscriptAdapter } from './transcript-adapter.ts'
import { displayFailureMessage } from './failure-display.ts'
import { Notifier } from './notifier.ts'
import { PartialAccumulator } from './partial.ts'
import { ProjectionValueStore } from './projection-store.ts'
@@ -26,6 +28,10 @@ import type { ProjectionsBaseline } from './projection-store.ts'
/** Messages requested per history page. */
export const PAGE_MESSAGES = 50
// Browser bundles cannot value-import the host timeout library. This protocol
// bound is pinned to @deepseek-ai/dsh-timeout's MAX_TIMER_DELAY_MS in tests.
const MAX_RETRY_DELAY_MS = 2_147_483_647
/** Manager-owned observers of a Session object's local state edges. */
export interface SessionOptions {
/**
@@ -85,12 +91,13 @@ export class Session implements SessionFace {
* passes drop all writes once the generation moves on. */
private openGeneration = 0
private loadingOlder = false
private readonly foldAdapter = new FoldAdapter()
private readonly transcript = new TranscriptAdapter()
private partial: PartialAccumulator | null = null
private openCalls = new Map<string, RunningToolCall>()
/** Interrupted-turn terminal nodes (frozen partial text / aborted tool cards), merged into the flow by seq.
* Derived from window events (turn/end sweep) — rebuilt by rebuildDerivedFromWindow like partial/openCalls. */
private frozenNodes: ConversationNode[] = []
/** Operational notices and interrupted-turn terminal nodes merged into the flow by seq.
* Derived from window events and rebuilt with partial/openCalls; the transcript is
* seq-monotonic, so a plain seq merge preserves event order. */
private derivedNodes: ConversationNode[] = []
private pending = new Map<string, PendingInteraction>()
// Revision counters preserve array identity when derived content is unchanged, so
// React.memo children survive unrelated snapshot swaps (chunk storms must not re-render every
@@ -100,12 +107,12 @@ export class Session implements SessionFace {
private callsCache: { rev: number; value: RunningToolCall[] } | null = null
private pendingRev = 0
private pendingCache: { rev: number; value: PendingInteraction[] } | null = null
private derivedRev = 0
private nodesCache: { projected: readonly ConversationNode[]; derivedRev: number; value: readonly ConversationNode[] } | null = null
/** Authoritative stream-only inbox snapshot; pending work never hits history. */
private queued: QueuedMessage[] = []
private queueRev = 0
private queueCache: { rev: number; value: QueuedMessage[] } | null = null
private frozenRev = 0
private nodesCache: { folded: readonly ConversationNode[]; frozenRev: number; value: readonly ConversationNode[] } | null = null
/** `run_code` sub-dispatches by parent callId (window-derived, like openCalls). Appends
* copy-on-write the per-parent array so published snapshot references never mutate. */
private codeDispatches = new Map<string, readonly CodeSubCall[]>()
@@ -241,7 +248,8 @@ export class Session implements SessionFace {
}
/**
* Stop: contract session.cancel 1:1; failures land in promptError (same error-strip display slot).
* Stop the active turn while the Host preserves pending inbox work; failures
* land in promptError (same error-strip display slot).
* @returns the cancel result.
*/
async cancel(): Promise<RpcResult<{ accepted: true }>> {
@@ -331,7 +339,7 @@ export class Session implements SessionFace {
/* v8 ignore next -- the ?? arm needs older[0] undefined, but the empty-page branch above already returned. */
this.baseSeq = older[0]?.event.seq ?? this.baseSeq
this.hasMore = result.value.hasMore
this.foldAdapter.reset(this.events, this.baseSeq, this.views) // prepend forces a rebuild (sentinel count changed)
this.transcript.reset(this.events, this.views) // prepend forces a rebuild (the window grew at the head)
this.rebuildDerivedFromWindow()
} catch (error) {
console.error('[web-runtime] loadOlder failed:', error)
@@ -564,7 +572,7 @@ export class Session implements SessionFace {
this.views = entries.map(e => e.view)
this.baseSeq = this.events[0]?.seq ?? 0
this.hasMore = hasMore
this.foldAdapter.reset(this.events, this.baseSeq, this.views)
this.transcript.reset(this.events, this.views)
this.rebuildDerivedFromWindow()
if (projections !== undefined) this.projections.seed(projections)
const buffered = this.liveBuffer
@@ -579,14 +587,15 @@ export class Session implements SessionFace {
if (tailSeq !== null && event.seq <= tailSeq) return // replay overlap, drop
this.events.push(event)
this.views.push(view)
this.foldAdapter.append(event, view)
this.transcript.append(event, view)
this.applyEventSideEffects(event, view)
}
/** Land a live session/event (open/repair in flight -> buffer; overlapping seq -> drop;
* a seq gap -> buffer + tail-page repull instead of appending a hole (audit S3: a gap is an
* expected reconnect-window artifact, repaired by refetch — never fed to the fold to trip
* its continuity assertion into the degraded view). */
* expected reconnect-window artifact, repaired by refetch). The window stays one contiguous
* raw range, which is what lets the transcript render every event between its ends and lets a
* compaction checkpoint find its own provenance. */
private acceptLiveEvent(event: SessionEvent, view?: ToolEventView): void {
if (this.openState === 'loading' || this.stitching) {
this.liveBuffer.push({ event, view })
@@ -625,8 +634,28 @@ export class Session implements SessionFace {
}
/** Per-event side effects (right column of the §A.9 dispatch table):
* chunk accumulation / partial clear on finalize / openCalls add-remove. */
* chunk/retry projection and openCalls add-remove. */
private applyEventSideEffects(event: SessionEvent, view?: ToolEventView): void {
const eventType = event.type as string
if (eventType === 'llm/retry') {
const data = parseRetryEventData(event.data)
if (data === null) {
console.error(`[web-runtime] ignored malformed llm/retry event at seq ${event.seq}`)
return
}
if (this.partial !== null && this.partial.turn === data.turn && this.partial.step === data.step) {
this.partial = null
}
this.derivedNodes.push({
kind: 'model-retry',
seq: event.seq,
time: event.time,
retryState: 'scheduled',
...data,
})
this.derivedRev++
return
}
// The `tool/code-dispatch-start`/`tool/code-dispatch` pair is declared by
// the host-side dsh-tools plugin whose types cannot enter the client
// program (its host Context merges collide with the client's), so this
@@ -687,6 +716,10 @@ export class Session implements SessionFace {
return
}
switch (event.type) {
case 'turn/start': {
if (event.data.trigger.kind === 'retry') this.settleScheduledRetry('started')
return
}
case 'assistant/chunk': {
const { turn, step, chunk } = event.data
if (this.partial === null || this.partial.turn !== turn || this.partial.step !== step) {
@@ -715,6 +748,25 @@ export class Session implements SessionFace {
return
}
case 'turn/end': {
if (event.data.reason.kind === 'aborted' || event.data.reason.kind === 'disposed') {
this.settleScheduledRetry('cancelled', event.data.turn)
}
if (
event.data.reason.kind === 'error'
&& !this.derivedNodes.some(node => node.kind === 'model-retry' && node.turn === event.data.turn)
) {
const failure = 'failure' in event.data.reason ? event.data.reason.failure : event.data.reason
this.derivedNodes.push({
kind: 'turn-error',
seq: event.seq,
time: event.time,
turn: event.data.turn,
step: event.data.reason.step,
message: displayFailureMessage(failure),
...(failure.code === undefined ? {} : { code: failure.code }),
})
this.derivedRev++
}
// Aborted turns never finalize. The accumulated partial is VALUE, not residue: freeze it
// into an interrupted terminal node (pulse stops, text survives) instead of deleting it.
// Shared by live and window-replay paths, so a refresh reconstructs the same frozen node
@@ -724,12 +776,12 @@ export class Session implements SessionFace {
const visible = blocks.some(b => (b.kind === 'text' || b.kind === 'reasoning' ? b.text !== '' : true))
if (visible) {
// Fractional seq: strictly after every event of this turn (all < turn/end seq), before the next turn.
this.frozenNodes.push({
this.derivedNodes.push({
kind: 'assistant', seq: event.seq - 0.9, time: event.time,
turn: this.partial.turn, step: this.partial.step,
blocks, interrupted: true,
})
this.frozenRev++
this.derivedRev++
}
this.partial = null
}
@@ -739,7 +791,7 @@ export class Session implements SessionFace {
this.openCalls.delete(callId)
this.callsRev++
// The spinner card becomes an interrupted terminal card (never vanishes mid-flow).
this.frozenNodes.push({
this.derivedNodes.push({
kind: 'tool-result', seq: event.seq - 0.8 + callOffset++ * 0.01, time: event.time,
callId,
call: { name: call.name, argsRaw: call.argsRaw },
@@ -747,7 +799,7 @@ export class Session implements SessionFace {
content: [], isError: true, error: { name: 'Interrupted', code: 'interrupted' },
callView: call.callView, resultView: null,
})
this.frozenRev++
this.derivedRev++
}
return
}
@@ -756,15 +808,36 @@ export class Session implements SessionFace {
}
}
/** Re-derive state (partial/openCalls/frozenNodes) from raw window events after a rebuild — keeps
* paging/stitching consistent, and makes the live freeze and the history replay converge on the
* same interrupted nodes (chunks are logged, so the replayed sweep re-freezes identical text). */
/**
* Settle the newest scheduled retry, optionally restricted to its failed turn.
* @param retryState - next client projection state to publish.
* @param turn - failed turn required for cancellation; omitted for the next retry turn start.
*/
private settleScheduledRetry(
retryState: Exclude<ModelRetryNode['retryState'], 'scheduled'>,
turn?: number,
): void {
const index = this.derivedNodes.findLastIndex(node =>
node.kind === 'model-retry'
&& node.retryState === 'scheduled'
&& (turn === undefined || node.turn === turn))
if (index < 0) return
const node = this.derivedNodes[index]
/* v8 ignore next -- findLastIndex's predicate narrows the indexed node only at runtime. */
if (node?.kind !== 'model-retry') return
this.derivedNodes[index] = { ...node, retryState }
this.derivedRev++
}
/** Re-derive state (partial/openCalls/derivedNodes) from raw window events after a rebuild — keeps
* paging/stitching consistent, and makes live handling and history replay converge on the same
* retry notices and interrupted nodes. */
private rebuildDerivedFromWindow(): void {
this.partial = null
this.openCalls.clear()
this.callsRev++
this.frozenNodes = []
this.frozenRev++
this.derivedNodes = []
this.derivedRev++
this.codeDispatches = new Map()
this.dispatchesRev++
for (let i = 0; i < this.events.length; i++) {
@@ -780,18 +853,18 @@ export class Session implements SessionFace {
}
private buildSnapshot(): ConversationSnapshot {
const { nodes: folded, degraded } = this.foldAdapter.nodes()
// Frozen interrupted nodes ride fractional seqs: a stable merge keeps them in flow order.
// The merged array is cached on (folded reference, frozenRev) so an unchanged flow keeps its
// reference across snapshot swaps (§A.9.4).
const projected = this.transcript.nodes()
// Derived interruption nodes ride fractional seqs while retry notices keep their event seq.
// The transcript is seq-monotonic, so sorting the union preserves flow order. Cache the
// merge on (projected reference, derivedRev) to retain identity across unrelated swaps.
let nodes: readonly ConversationNode[]
if (this.nodesCache !== null && this.nodesCache.folded === folded && this.nodesCache.frozenRev === this.frozenRev) {
if (this.nodesCache !== null && this.nodesCache.projected === projected && this.nodesCache.derivedRev === this.derivedRev) {
nodes = this.nodesCache.value
} else {
nodes = this.frozenNodes.length === 0
? folded
: [...folded, ...this.frozenNodes].sort((a, b) => a.seq - b.seq)
this.nodesCache = { folded, frozenRev: this.frozenRev, value: nodes }
nodes = this.derivedNodes.length === 0
? projected
: [...projected, ...this.derivedNodes].sort((a, b) => a.seq - b.seq)
this.nodesCache = { projected, derivedRev: this.derivedRev, value: nodes }
}
if (this.callsCache === null || this.callsCache.rev !== this.callsRev) {
this.callsCache = { rev: this.callsRev, value: [...this.openCalls.values()] }
@@ -809,7 +882,6 @@ export class Session implements SessionFace {
return {
sessionId: this.sessionId,
nodes,
foldDegraded: degraded,
partial,
runningCalls: this.callsCache.value,
pending: this.pendingCache.value,
@@ -835,6 +907,58 @@ export class Session implements SessionFace {
}
}
/** Validate the plugin-owned payload at the session-event wire boundary. */
function parseRetryEventData(value: unknown): LlmRetryEventData | null {
if (value === null || typeof value !== 'object') return null
const data = value as Record<string, unknown>
const failure = data.failure
if (failure === null || typeof failure !== 'object') return null
const failureData = failure as Record<string, unknown>
if (!nonNegativeSafeInteger(data.turn)
|| !nonNegativeSafeInteger(data.step)
|| typeof data.provider !== 'string'
|| data.provider.length === 0
|| typeof data.policyKey !== 'string'
|| data.policyKey.length === 0
|| !positiveSafeInteger(data.retry)
|| typeof data.delayMs !== 'number'
|| !Number.isFinite(data.delayMs)
|| data.delayMs < 0
|| data.delayMs > MAX_RETRY_DELAY_MS
|| typeof failureData.message !== 'string'
|| failureData.message.length === 0
|| typeof failureData.code !== 'string'
|| failureData.code.length === 0) return null
if (data.mode === 'normal') {
if (!positiveSafeInteger(data.maxRetries) || data.retry > data.maxRetries) return null
} else if (data.mode === 'always') {
if ('maxRetries' in data) return null
} else {
return null
}
if (failureData.status !== undefined
&& (typeof failureData.status !== 'number'
|| !Number.isInteger(failureData.status)
|| failureData.status < 100
|| failureData.status > 599)) return null
if (failureData.providerRetryAfterMs !== undefined
&& (typeof failureData.providerRetryAfterMs !== 'number'
|| !Number.isFinite(failureData.providerRetryAfterMs)
|| failureData.providerRetryAfterMs <= 0)) return null
if (failureData.requestId !== undefined
&& (typeof failureData.requestId !== 'string'
|| failureData.requestId.length === 0)) return null
return data as unknown as LlmRetryEventData
}
function nonNegativeSafeInteger(value: unknown): value is number {
return typeof value === 'number' && Number.isSafeInteger(value) && value >= 0
}
function positiveSafeInteger(value: unknown): value is number {
return nonNegativeSafeInteger(value) && value > 0
}
/**
* The composerPhase judgment — the single site that knows the predicate
* (consumers switch on the result, never re-derive). Monotone per session

View File

@@ -0,0 +1,328 @@
// TranscriptAdapter: the human transcript projected from the raw event window
// in LOG order. The model-visible surface deliberately shadows replaced ranges,
// so it is the wrong source for conversation a reader already saw; this adapter
// keeps every append-origin event at its own log position and contributes one
// marker node per landed compaction checkpoint. Node order is therefore
// seq-monotonic by construction — no surface fold, no padding sentinels, no
// seq === index assertion to satisfy, and no degradation branch.
import type { SessionEvent } from '@deepseek-ai/dsh-session/types'
// Subpath export (package.json exports "./surface", alias added for this): all value imports
// go through it — the package root points at lib/index.js (needs a build) which the vite
// browser bundle cannot resolve; surface.ts has no Node dependencies.
import { isAppendSurfaceEvent, isReplacementSurfaceEvent } from '@deepseek-ai/dsh-session/surface'
import type { CommandId } from '@deepseek-ai/dsh-commands/brand'
// Cordis-free leaf subpath (the dsh-commands/brand shape): the seam's own
// declaration of the checkpoint source, reachable as a TYPE from this program.
// The package ROOT is not — it reaches dsh-session's root, whose Context merge
// declares the HOST `sessions: SessionStore` against this program's
// `sessions: ISessions` (TS2717, the one-program-per-side rule in
// docs/development.md).
import type { COMPACT_CHECKPOINT_SOURCE } from '@deepseek-ai/dsh-compact/checkpoint'
import type { ToolCallView, ToolEventView, ToolResultView } from '@deepseek-ai/dsh-client-connection/client'
import type { CommandNode, CompactionSummaryNode, ConversationNode } from './conversation.ts'
import { toAssistantBlocks } from './conversation.ts'
/**
* The compaction seam's checkpoint plugin, pinned to the seam's own declaration
* at COMPILE time: renaming it there fails this annotation (`TS2322`). The
* import stays type-only because a value import would fail the client purity
* gate (`packages/client/tsdown.client.ts`) — cross-plugin value imports are
* forbidden in a browser bundle — while an erased type never reaches it.
* `tests/compact-checkpoint-pin.spec.ts` covers the same drift behaviorally.
*/
const COMPACT_PLUGIN: typeof COMPACT_CHECKPOINT_SOURCE.plugin = 'compact'
/** In-window tool/call index entry used to materialize result cards. */
interface CallIndexEntry {
name: string
argsRaw: string
turn: number
step: number
/** Unix epoch ms of the tool/call event. */
time: number
/** Wire view riding the tool/call (envelope-level; never inside the event). */
callView: ToolCallView | null
}
/** One event -> UI node (pure function; the eight-variant ConversationNode union). */
function materializeNode(
event: SessionEvent,
callIndex: ReadonlyMap<string, CallIndexEntry>,
resultView: ToolResultView | null,
): ConversationNode {
switch (event.type) {
case 'user/message':
// Injected context (plugin/goal source) folds to a context node, not a
// user message; only a direct human prompt is a user node. A compaction
// checkpoint never reaches here (isCompactCheckpoint routes it away).
if (event.data.source.kind !== 'user') {
return {
kind: 'context', seq: event.seq, time: event.time,
content: event.data.content, source: event.data.source,
}
}
return {
kind: 'user', seq: event.seq, time: event.time,
content: event.data.content, source: event.data.source,
}
case 'assistant/message':
return {
kind: 'assistant', seq: event.seq, time: event.time,
turn: event.data.turn, step: event.data.step,
blocks: toAssistantBlocks(event.data.message.content), usage: event.data.usage,
}
case 'steering/message':
return {
kind: 'steering', seq: event.seq, time: event.time, turn: event.data.turn,
content: event.data.message.content, source: event.data.message.source,
}
case 'tool/result': {
const result = event.data.message.content[0]
const callId = String(event.data.message.source.callId)
const call = callIndex.get(callId)
return {
kind: 'tool-result', seq: event.seq, time: event.time,
callId,
call: call ? { name: call.name, argsRaw: call.argsRaw } : null,
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,
}
}
/* v8 ignore next 2 -- defensive arm: only the four surface-eligible types
can be append-origin, and each has a case above; reachable only if core
adds an eligible type. */
default:
return {
kind: 'unknown', seq: event.seq, time: event.time,
type: event.type, data: (event as { data?: unknown }).data,
}
}
}
/**
* Whether an event is a landed compaction checkpoint — all three conditions,
* matching the terminal's `isCompactCheckpoint`: a `user/message`, carrying the
* compaction seam's checkpoint plugin source, that REPLACED a surface range. A
* plugin-sourced `user/message` that appends is injected context (a
* session-reference card), not a compaction; a replacement `tool/result` is an
* in-place prune and a replacement `assistant/message` a generic rewrite, and
* both mark no boundary in the conversation.
* @param event - the raw window event.
* @returns true when the event compacted a surface range.
*/
function isCompactCheckpoint(event: SessionEvent): boolean {
if (event.type !== 'user/message') return false
const source = event.data.source
return source.kind === 'plugin' && source.plugin === COMPACT_PLUGIN
&& isReplacementSurfaceEvent(event)
}
/** Whether an event contributes a node to the human transcript. */
function isTranscriptEvent(event: SessionEvent): boolean {
return isAppendSurfaceEvent(event) || isCompactCheckpoint(event)
}
/**
* Concatenated text of a `compact/summary` payload, or null when it carries no
* usable text. The payload is a `ContentBlock[]` whose union is
* merge-extensible, so a non-text block is skipped rather than discarding the
* text beside it; a payload with no text block at all falls to null through the
* empty check.
*/
function compactSummaryText(event: SessionEvent): string | null {
const summary = (event.data as unknown as { summary?: unknown }).summary
if (!Array.isArray(summary)) return null
let text = ''
for (const block of summary as readonly unknown[]) {
const candidate = block as { type?: unknown; text?: unknown }
if (candidate.type !== 'text' || typeof candidate.text !== 'string') continue
text += candidate.text
}
return text.trim() === '' ? null : text
}
/**
* One landed checkpoint -> the human-facing compaction marker. The summary text
* comes from the checkpoint's own provenance (`sourceEventSeqs` names the
* `compact/summary` event), never from the framed checkpoint payload, which is
* an instruction envelope written for the model. A window cut that left the
* provenance outside soft-falls to `summary: null` (a non-expandable marker),
* the same posture as a call-less tool result.
*/
function materializeCompaction(
checkpoint: SessionEvent,
eventIndex: ReadonlyMap<number, SessionEvent>,
): CompactionSummaryNode {
const sources = (checkpoint as SessionEvent & { sourceEventSeqs?: number[] }).sourceEventSeqs
let summary: string | null = null
for (const seq of sources ?? []) {
const candidate = eventIndex.get(seq)
if (candidate === undefined || (candidate.type as string) !== 'compact/summary') continue
summary = compactSummaryText(candidate)
break
}
return { kind: 'compaction', seq: checkpoint.seq, time: checkpoint.time, summary }
}
/** Log-ordered human transcript over a paged raw event window (never consults surface order). */
export class TranscriptAdapter {
/** Window events by seq: provenance lookup for a checkpoint's summary. */
private eventIndex = new Map<number, SessionEvent>()
/** Transcript nodes in log order; copy-on-write so a published array never mutates. */
private projected: ConversationNode[] = []
private callIdx = new Map<string, CallIndexEntry>()
/** Wire result views keyed by the tool/result event's seq (views ride the envelope, not the event). */
private resultViews = new Map<number, ToolResultView>()
/**
* Command lifecycle nodes by commandId (insertion = run order). The
* `command/run`/`command/done` pair is log-only, so it is not a surface
* event and never joins the transcript projection; this index folds the pair
* (done settles its run's node in place) and nodes() merges the products in
* by seq. Window cuts soft-fall like tool pairs: a done with no in-window
* run still builds a node.
*/
private commandIdx = new Map<string, CommandNode>()
/** Projection revision, bumped only when a transcript node or a command node actually
* changed, keying the nodes() result cache: an unchanged projection returns the previous
* ARRAY reference, not just cached elements — the snapshot's reference-stability contract
* (§A.9.4) starts here, and a chunk storm bumps nothing at all. */
private rev = 0
private nodesResult: { rev: number; value: readonly ConversationNode[] } | null = null
/**
* Window rebuild (after open/resync/page prepend): re-index the raw window
* and re-project the transcript.
* @param events - the new window contents (seq-ascending).
* @param views - per-event wire views aligned with `events` by index (undefined slots for view-less events).
*/
reset(events: readonly SessionEvent[], views?: readonly (ToolEventView | undefined)[]): void {
this.rev++
this.eventIndex = new Map()
this.callIdx = new Map()
this.resultViews.clear()
this.commandIdx = new Map()
for (let i = 0; i < events.length; i++) {
const event = events[i]
/* v8 ignore next -- dense-array guard: i stays within events.length, so the undefined arm needs a sparse array no caller builds. */
if (event === undefined) continue
this.eventIndex.set(event.seq, event)
this.indexCall(event, views?.[i])
this.indexCommand(event)
}
// Indexes first, then project: a tool/result materializes against the
// complete call index, and a checkpoint against the complete event index.
const projected: ConversationNode[] = []
for (const event of events) {
if (isTranscriptEvent(event)) projected.push(this.materialize(event))
}
this.projected = projected
}
/**
* Tail append (live session/event): index the event and, when it belongs to
* the transcript, extend the projection by one copy-on-write node so a
* published array never mutates. An event that changes no node (a chunk
* storm) bumps no revision, so nodes() keeps returning the same array
* reference.
* @param event - the live event (seq = window tail + 1).
* @param view - host-computed tool view paired with the event when it is a tool call/result; indexed for card rendering.
*/
append(event: SessionEvent, view?: ToolEventView): void {
this.eventIndex.set(event.seq, event)
this.indexCall(event, view)
if (this.indexCommand(event)) this.rev++
if (!isTranscriptEvent(event)) return
this.projected = [...this.projected, this.materialize(event)]
this.rev++
}
/**
* The current transcript node array. Same revision -> same array reference
* (memo boundary); node objects are materialized once, so an unchanged node
* keeps its identity across appends.
* @returns transcript nodes in log order, command nodes merged in by seq.
*/
nodes(): readonly ConversationNode[] {
if (this.nodesResult !== null && this.nodesResult.rev === this.rev) return this.nodesResult.value
// Command nodes fold outside the transcript (log-only events); merge by
// seq. Both inputs are seq-ascending (log order and run-index insertion
// order are the same order), so one linear merge keeps flow order.
let nodes = this.projected
if (this.commandIdx.size > 0) {
nodes = []
const commands = [...this.commandIdx.values()]
let next = 0
for (const node of this.projected) {
for (let cmd = commands[next]; cmd !== undefined && cmd.seq < node.seq; cmd = commands[++next]) {
nodes.push(cmd)
}
nodes.push(node)
}
for (let cmd = commands[next]; cmd !== undefined; cmd = commands[++next]) nodes.push(cmd)
}
this.nodesResult = { rev: this.rev, value: nodes }
return nodes
}
/** Materialize one transcript event against the complete current indexes. */
private materialize(event: SessionEvent): ConversationNode {
return isCompactCheckpoint(event)
? materializeCompaction(event, this.eventIndex)
: materializeNode(event, this.callIdx, this.resultViews.get(event.seq) ?? null)
}
/**
* Fold one command lifecycle event into its node (run mints, done settles in
* place; done-only soft-falls).
* @returns whether the command index changed, so callers can bump the revision.
*/
private indexCommand(event: SessionEvent): boolean {
// Log-only plugin events: the host-side dsh-commands declaration cannot
// enter the client program, so this wire consumer narrows structurally
// (the same posture as tool/code-dispatch in session.ts).
if ((event.type as string) === 'command/run') {
const data = event.data as unknown as { commandId: CommandId; name: string; args: string }
this.commandIdx.set(data.commandId, {
kind: 'command', seq: event.seq, time: event.time,
commandId: data.commandId, name: data.name, args: data.args, outcome: null,
})
return true
}
if ((event.type as string) !== 'command/done') return false
const data = event.data as unknown as { commandId: CommandId; kind: 'success' | 'error'; text?: string }
const run = this.commandIdx.get(data.commandId)
const outcome = { kind: data.kind, ...data.text === undefined ? {} : { text: data.text } }
if (run === undefined) {
// Cross-window cut: the run page fell out of the window — build the
// node from the done alone (same soft-fall as a call-less tool result).
this.commandIdx.set(data.commandId, {
kind: 'command', seq: event.seq, time: event.time,
commandId: data.commandId, name: null, args: null, outcome,
})
return true
}
// Settle in place: a fresh node object (published references stay immutable).
this.commandIdx.set(data.commandId, { ...run, outcome })
return true
}
private indexCall(event: SessionEvent, view?: ToolEventView): void {
if (event.type === 'tool/result') {
if (view?.for === 'result') this.resultViews.set(event.seq, view.view)
return
}
if (event.type !== 'tool/call') return
this.callIdx.set(String(event.data.callId), {
name: event.data.name, argsRaw: event.data.arguments, turn: event.data.turn, step: event.data.step,
time: event.time,
callView: view?.for === 'call' ? view.view : null,
})
// No backfill into already-materialized tool-result nodes for this callId
// (window order puts the call before its result; cannot happen on the normal path).
}
}

View File

@@ -14,6 +14,14 @@ export type WorkspaceListPhase = 'pending' | 'ready'
/** Immutable workspace-list snapshot. */
export interface WorkspaceListSnapshot {
items: readonly WorkspaceView[]
/**
* Registry-global archive set in Host order (hidden from grouping
* surfaces; accounting slots retained). A plain array, not a Set: public
* snapshot state stays in the store engine's plain-data vocabulary
* (immer drafts reject Sets without the MapSet plugin); membership
* lookups build their own transient Set where they need one.
*/
archivedSessionIds: readonly SessionId[]
state: 'idle' | 'loading' | 'error'
phase: WorkspaceListPhase
error: RpcError | null
@@ -28,11 +36,21 @@ export class WorkspaceManager {
private items: Workspace[] = []
private itemViewsSource: readonly Workspace[] | null = null
private itemViewsCache: readonly WorkspaceView[] = []
// Full-snapshot state (list response / unary response / changed frame all
// carry the complete set), so deltas never merge — installs replace.
private archivedSessionIds: readonly SessionId[] = []
private state: WorkspaceListSnapshot['state'] = 'idle'
private phase: WorkspaceListPhase = 'pending'
private error: RpcError | null = null
private inflight: Promise<void> | null = null
private refreshFrames: WorkspaceDelta[] | null = null
/**
* True once a frame or unary echo installed the archive set while a list
* request was in flight: that install is newer than the pending baseline,
* so the baseline's (older) set must not roll it back — the archive
* mirror of replaying refreshFrames over the item baseline.
*/
private archivedSupersedesRefresh = false
/**
* Ids this process has seen removed, kept for the connection's lifetime so
* a late changed frame or a stale baseline row cannot resurrect a deleted
@@ -77,6 +95,7 @@ export class WorkspaceManager {
items = items.filter(workspace => !this.removedIds.has(workspace.workspaceId))
for (const delta of frames) items = applyWorkspaceDelta(items, delta)
this.installViews(items)
if (!this.archivedSupersedesRefresh) this.installArchived(result.value.archivedSessionIds)
this.state = 'idle'
this.phase = 'ready'
} else {
@@ -90,6 +109,7 @@ export class WorkspaceManager {
this.error = folded.ok ? null : folded.error
} finally {
this.refreshFrames = null
this.archivedSupersedesRefresh = false
this.inflight = null
this.notifier.markDirty()
}
@@ -158,6 +178,18 @@ export class WorkspaceManager {
return result
}
/**
* Archive one session in the registry-global set, then install the
* returned full set without waiting for the changed frame.
* @param sessionId - session to archive.
* @returns the wire result.
*/
async archiveSession(sessionId: SessionId): Promise<RpcResult<{ archivedSessionIds: SessionId[] }>> {
const { result } = await this.api.workspace.archiveSession({ sessionId })
if (result.ok) this.installArchived(result.value.archivedSessionIds)
return result
}
/**
* Host-frame entry. Non-workspace frames are ignored so the runtime can
* fan one host stream out to both object managers.
@@ -166,6 +198,9 @@ export class WorkspaceManager {
handleHostEnvelope(envelope: RpcRequest<HostFrame>): void {
if (envelope.payload.type === 'host/workspace-changed') this.upsert(envelope.payload.workspace)
else if (envelope.payload.type === 'host/workspace-removed') this.remove(envelope.payload.workspaceId)
else if (envelope.payload.type === 'host/archived-sessions-changed') {
this.installArchived(envelope.payload.archivedSessionIds)
}
}
/** Re-pull the baseline after each connection generation. */
@@ -194,12 +229,26 @@ export class WorkspaceManager {
private buildSnapshot(): WorkspaceListSnapshot {
return {
items: this.itemViews(),
archivedSessionIds: this.archivedSessionIds,
state: this.state,
phase: this.phase,
error: this.error,
}
}
/**
* Replace the archive set when membership actually changed (array identity
* backs Object.is short-circuits). Host snapshots are append-ordered, so
* positional comparison is exact, not merely heuristic.
*/
private installArchived(archivedSessionIds: readonly SessionId[]): void {
if (this.refreshFrames !== null) this.archivedSupersedesRefresh = true
if (archivedSessionIds.length === this.archivedSessionIds.length
&& archivedSessionIds.every((id, index) => id === this.archivedSessionIds[index])) return
this.archivedSessionIds = [...archivedSessionIds]
this.notifier.markDirty()
}
/** Upsert one Host view, optionally retaining the local object that materialized it. */
private upsert(view: WorkspaceView, identity?: Workspace): void {
if (this.removedIds.has(view.workspaceId)) return

View File

@@ -14,6 +14,14 @@ import { WorkspaceManager, type WorkspaceListPhase } from './manager.ts'
/** Workspace list plus the two-baseline readiness and default-target projection. */
export interface WorkspaceListState {
items: readonly WorkspaceView[]
/**
* Registry-global archive set in Host order: grouping surfaces hide these
* sessions everywhere (workspace groups and the ungrouped bucket) while
* their session logs and workspace accounting slots remain. A plain array
* (store-engine vocabulary; immer drafts reject Sets) — membership lookups
* build their own transient Set.
*/
archivedSessionIds: readonly SessionId[]
state: 'idle' | 'loading' | 'error'
phase: WorkspaceListPhase
error: RpcError | null
@@ -58,7 +66,7 @@ export class WorkspacesService implements IWorkspaces {
constructor(ctx: Context, private readonly api: IApiClient, private readonly sessions: SessionsPort) {
this.manager = new WorkspaceManager(api)
this.list = createSnapshotStore<WorkspaceListState>({
items: [], state: 'idle', phase: 'pending', error: null,
items: [], archivedSessionIds: [], state: 'idle', phase: 'pending', error: null,
baselinesReady: false, recentWorkspaceId: undefined,
})
this.manager.subscribe(() => { this.project() })
@@ -88,10 +96,14 @@ export class WorkspacesService implements IWorkspaces {
if (inflight !== undefined) return inflight
// Reuse: blank && same canonical cwd (workspace.path is the host realpath
// canon; summary cwd is the session header passthrough of the same canon).
// An archived blank is never reused: reuse would open a session no
// grouping surface can show, so New Session mints a fresh one instead.
const archived = this.list.getSnapshot().archivedSessionIds
const sessions = this.sessions.list.getSnapshot()
for (const id of sessions.ids) {
const summary = sessions.byId[id]
if (summary !== undefined && summary.blank && summary.cwd === workspace.path) return summary.id
if (summary !== undefined && summary.blank && summary.cwd === workspace.path
&& !archived.includes(summary.id)) return summary.id
}
const attempt = this.sessions.create({ workspaceId })
.finally(() => { this.connecting.delete(workspaceId) })
@@ -249,6 +261,17 @@ export class WorkspacesService implements IWorkspaces {
if (!result.ok) throw new Error(`workspace delete failed: ${result.error.code}: ${result.error.message}`)
}
/**
* Archive a session into the registry-global set. Clearing an archived
* current selection is the projection sweep's job (one rule for the local
* echo and a remote tab's frame alike).
* @param sessionId - session to archive.
*/
async archiveSession(sessionId: SessionId): Promise<void> {
const result = await this.manager.archiveSession(sessionId)
if (!result.ok) throw new Error(`session archive failed: ${result.error.code}: ${result.error.message}`)
}
/**
* Move a session within its Workspace's manual order (DOM-insertBefore-like).
* @param workspaceId - owning workspace.
@@ -291,8 +314,17 @@ export class WorkspacesService implements IWorkspaces {
const workspace = this.manager.getSnapshot()
const sessions = this.sessions.list.getSnapshot()
const baselinesReady = workspace.phase === 'ready' && sessions.phase === 'ready'
// An archived current selection clears into the New Session view state —
// a hidden row must not stay open behind the list. Sweeping here covers
// every install path with one rule: the local unary echo, another tab's
// changed frame, and a reconnect baseline restoring a persisted
// selection that was archived while this client was away.
if (sessions.current !== undefined && workspace.archivedSessionIds.includes(sessions.current)) {
this.sessions.clear()
}
this.list.set({
items: workspace.items,
archivedSessionIds: workspace.archivedSessionIds,
state: workspace.state,
phase: workspace.phase,
error: workspace.error,