Merge master (slash/input/session architecture) into web-session-model-selector

This commit is contained in:
imccyu
2026-07-27 10:23:51 +08:00
2673 changed files with 101832 additions and 39946 deletions

View File

@@ -10,8 +10,8 @@ The [slot system standard](../../.agents/notes/implemented/architecture/2026-07-
1. **One API**: a plugin composes UI only through `ctx.slots.register({ name, children?, store?, inject? }, Component)`. There is no separate slot-definition call, no whitelist face object, no face-minting helper. The shell alone renders `'root'`.
2. **children = declaration + authorization**: the slots your component renders are exactly the keys of your register call's `children` object (spec values: `kind`/`scope`). Rendering a slot you didn't declare, or declaring one someone else declared, fails at load — do not work around it; the conflict is the design speaking. Slot names mirror the composition path: `<domain>.<entry>.<hole>` (e.g. `'conversation.chat.toolview'`).
3. **Component props are the four shares, all derived**: `PropsRuntime<K>` (SlotMap: owner params + `useSession`/`sessionId` on session scope + `useSessions`) & `PropsRenderSlots<S>` (children keys) & `PropsStore<H>` (store factory) & the inject face. Never hand-write a member a share already derives; never re-type a share locally.
4. **Hooks are framework-made only**: `useSession`, `useSessions`, `useStore`, `renderSlot` are the four seats. Business code never creates a hook or selector as a prop value — pass plain data and callbacks. (Component-internal behavioral hooks that subscribe to nothing external are fine.)
3. **Component props are the four shares, all derived**: `PropsRuntime<K>` (SlotMap: owner params + `useSession`/`sessionId` on session scope + global `useSessions`/`useWorkspaces`) & `PropsRenderSlots<S>` (children keys) & `PropsStore<H>` (store factory) & the inject face. Never hand-write a member a share already derives; never re-type a share locally.
4. **Hooks are framework-made only**: `useSession`, `useSessions`, `useWorkspaces`, `useStore`, `renderSlot` are the five seats. Business code never creates a hook or selector as a prop value — pass plain data and callbacks. (Component-internal behavioral hooks that subscribe to nothing external are fine.)
5. **Live data has exactly three channels**: parent knows it → owner props at the renderSlot site; only the component knows it → local state; shared across entries or survives remounts → a store declared at register. Derived data is a pure function over framework-hook data (`useMemo`), never its own subscription.
6. **Stores: read `props.useStore`, write `props.actions.*`** — the declared actions are the complete mutation surface. Write the store as an exported `createXXXStore()` factory (module-level handles are forbidden — de-facto singletons); share by passing one handle to several registers inside `apply`. Production code never calls the factory or `.create()` outside `apply`; tests do (that is the sanctioned zero-machinery path).
7. **inject returns plain data and callbacks** from the apply closure's own ctx — no hooks, no ReactNode producers, no whole-service objects. Its capability boundary is the plugin's declared `inject` topology; there is no wider ctx to reach for.
@@ -65,11 +65,21 @@ The GUI test structure (three tiers, lane map) is settled in the [GUI testing sy
Run the narrowest rung that covers what you touched; escalate only when the change surface demands it.
1. **Every GUI code change**`pnpm run test:gui` (seconds; no browser, no server): the client suites plus the host-side GUI packages. This is the inner loop; run it as freely as a typecheck.
2. **Changes to the build surface, boot wiring, or static serving** (`apps/web`, vite config, `dsh-host-webserver`) — additionally `pnpm run test:web`: rebuilds the frontend dist, then runs the browser smoke pair (the real-host case self-skips without `DEEPSEEK_API_KEY`).
2. **Changes to the build surface, boot wiring, static serving, or the wire carriage** (`apps/web`, vite config, `dsh-host-webserver`, connection/handler/SSE) — additionally `pnpm run test:web`: rebuilds the frontend dist, then runs the browser smoke pair (the real-host case self-skips without `DEEPSEEK_API_KEY`) plus the keyless replayed e2e scenarios (`DSH_SNAPSHOT=refresh` rewrites their aria goldens after an intentional conversation-UI change; `DSH_SNAPSHOT=record` re-records fixtures with a key).
3. **Before a PR**`pnpm run check:pre-push` (the repo-wide gate ladder). Between PR windows this rung is not expected on every commit.
If `test:gui` is red on code you did not touch, neither silently fix nor ignore it: note it in your handoff so it lands in the next PR window's sweep.
## New plugin package checklist
Bringing up a new `packages/client/<name>` plugin package (ui-workspace is the latest walked example; ui-sidebar/ui-question are good skeletons to copy):
1. **Package skeleton**: `package.json` (`@deepseek-ai/dsh-client-<name>`, exports `.`/`./invariant`/`./client`/`./src/*`/`./package.json`, `dshClient` manifest, `files` list), `tsconfig.json` (extends `tsconfig.base.client.json`, one `references` entry per workspace dependency plus `support/invariants`), `tsdown.config.ts` (`clientBundle(id, ['lib/types/index.js', 'lib/types/invariant.js'])`), `src/index.ts` (empty node-half apply), `src/invariant.ts` (companion with a real reason), `src/css-modules.d.ts` when using CSS Modules, `README.md` with the Model Experience section.
2. **Three registration surfaces, all required** (missing any one fails at a different, later point): the `tsconfig.client.json` aggregate `references` entry; the `CLIENT_PACKAGES` roster in `apps/cli/src/web.ts`; an `apps/cli/package.json` dependency (`mountWebPlugins` resolves roster packages against the composing app's URL — a roster row that is not a dependency of `apps/cli` fails to mount). `pnpm-workspace.yaml` already globs `packages/*/*`.
3. **dshClient manifest semantics**: `platform: 'web'` always; `immediately: true` only for stage-one-prefetch infrastructure rows. `inject` lists package-name dependency edges — they are **informational only** (preflight display, HMR diffing); they do not sequence entry activation or apply order. Activation order is cordis fiber inject waiting on *services*, nothing else.
4. **Registering into another package's slot**: if the declaring host provides no waitable service, your apply's order relative to the host's is unconstrained — a bare `slots.register` into its slot races boot (intermittent `slot "..." is not declared` page failures). Register with declaration-aware deferral: check `ctx.slots.spec(name)`, otherwise `ctx.slots.subscribe(name)` and register on the declaration event (SlotCore supports subscribing ahead of declaration); make the registration idempotent, and unsubscribe + dispose in the effect disposer. Only take a service edge in `inject` when the host actually provides one (ui-question → `'conversation'` is that case).
5. Rebuild the bundle (`pnpm --filter <pkg> bundle`) before probing a live `dsh web` server — the registry serves `lib/client.js`, not sources.
## New component checklist
1. Compose through register: merge the slot contract into `SlotMap`, declare the slot in its parent entry's `children`, register your component — see the [slot system standard](../../.agents/notes/implemented/architecture/2026-07-22-slot-type-chain-implementation.md). No other composition route exists.

View File

@@ -0,0 +1,6 @@
# Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each
# side as of the last confirmed-consistent state. Both languages carry equal authority;
# after editing either side, bring the other along and re-record with:
# pnpm run verify-translation-pairing --write
README.md: 80228a180faba0c556ff720e999b29b5bb1635b6
README.zh.md: f4b857886bfafa891ceb1bd6b79b27e1fb725819

View File

@@ -1,7 +1,13 @@
# @deepseek-ai/dsh-client-connection
English | [中文](README.zh.md)
Wire consumer layer: the client plugin's apply mounts `ctx.connection` (shared api client + single-consumer stream-loop starter); the export face carries the wire contract types, the `AbstractApiClient` seam, and the loop's sink/config types. The platform subclasses (WebApiClient/FixtureApiClient), the ConnectionController loop, and the fixture data source are package-internal — apply selects and drives them; tests reach them via src. Contract: api-contracts v3 §3.
## Keyless fixture
Any `fixture` query parameter selects the in-memory carrier. `fixture=empty` starts with no Workspace or Session; `fixturePrompt=reject` rejects prompts before acceptance; `fixtureAttach=fail` publishes a Session but rejects its Workspace attachment; `fixtureSessionCreate=drop-response` publishes and frames a Session before dropping the create response; and `fixtureFrames=workspace-first` reverses the default session-first create-frame order. Workspace creation by name/path and caller-preallocated SessionIds remain deterministic enough for assembled Web tests to reconcile list and frame arrival.
## Model Experience
None, as the wire consumer layer moves already-composed messages between browser and host; nothing here reaches a model request.

View File

@@ -0,0 +1,22 @@
# @deepseek-ai/dsh-client-connection
[English](README.md) | 中文
协议消费层:客户端插件的 apply 会挂载 `ctx.connection`(共享 API 客户端 + 单消费方流循环启动器);导出表层携带协议契约类型、`AbstractApiClient` seam以及循环的 sink配置类型。平台子类WebApiClient/FixtureApiClient、ConnectionController 循环和 fixture 数据源都属于包内部apply 负责选择并驱动它们,测试则通过 src 访问。契约api-contracts v3 §3。
## 无密钥 fixture
任何 `fixture` 查询参数都会选择内存载体。`fixture=empty` 启动时不含 Workspace 或 Session`fixturePrompt=reject` 在接受前拒绝提示词;`fixtureAttach=fail` 发布 Session 但拒绝将其附加到 Workspace`fixtureSessionCreate=drop-response` 在丢弃创建响应前发布 Session 并为其发出帧;`fixtureFrames=workspace-first` 则反转默认的 Session 优先创建帧顺序。按名称/路径创建 Workspace 以及由调用方预先分配 SessionId均具有足够的确定性组装后的 Web 测试可以据此协调列表与帧的到达。
## 模型体验
无。协议消费层只在浏览器与主机之间搬运已经组合好的消息;这里没有任何内容进入模型请求。
#### KV Cache 影响
无;该包既不组装也不发送提供方请求。
## 已知限制与暂缓事项
- **history 的隐式恢复存在争议**:在未附加的会话上打开 history会在主机侧拉起 agent纯持久化读取的替代方案记录在 rt-core 协调账本中P-I 不作改变。该包的消费方会在首次打开时感受到这段延迟。
- **计划移除 `ToolEventView``ToolCallView``ToolResultView` 的重新导出**:当 toolview 迁移删除主机 `viewFor` 行时它们会一并移除呈现属于客户端在此之前fixture 保留一份局部 `viewFor` 镜像。

View File

@@ -43,10 +43,12 @@
"src"
],
"peerDependencies": {
"@deepseek-ai/dsh-host-webserver": "^0.0.1",
"@deepseek-ai/dsh-invariants": "^0.0.1",
"cordis": "^4.0.0-rc.7"
},
"devDependencies": {
"@deepseek-ai/dsh-host-webserver": "workspace:^",
"@deepseek-ai/dsh-invariants": "workspace:^",
"cordis": "^4.0.0-rc.7"
}

View File

@@ -0,0 +1,8 @@
/**
* The /api URL prefix — single source for both halves of the web transport.
* The node half registers this prefix on the web server; browser-side path
* literals currently live in the apiproxy client layer (out of scope here).
*/
/** Route prefix owning every api request (`/api` and `/api/<anything>`). */
export const API_PATH = '/api'

View File

@@ -7,8 +7,9 @@
export type {
ApiProxy, SessionsApi, SessionSummary, HostApi, EventsApi, MuxFrame, HostFrame,
ApprovalResponsePayload, QuestionResponsePayload, HistoryEntry, ModelCatalogFailure,
ModelCatalogModel, ModelProviderGroup, ModelTarget, SessionModels, ToolEventView,
ApprovalResponsePayload, QuestionResponsePayload, HistoryEntry, ToolEventView,
WorkspaceApi, WorkspaceId, WorkspaceView,
CommandsApi, CommandDescriptor, CommandExecuteResult, SkillsApi, SkillEntry,
} from '@deepseek-ai/dsh-host-apiproxy/api'
export type { ToolCallView, ToolResultView } from '@deepseek-ai/dsh-tools/presentation'
export type {

View File

@@ -9,8 +9,8 @@ import type { ContentBlock } from '@deepseek-ai/dsh-llm/types'
import type { SessionEvent, SessionId } from '@deepseek-ai/dsh-session/types'
import type {
ApiProxy, ClientRequest, ClientResponse, HistoryEntry, HostFrame, MuxFrame, RpcReceipt,
ModelTarget, RpcRequest, RpcResponse, RpcResult, ServerRequest, ServerResponse, SessionSummary,
ToolCallView, ToolEventView, ToolResultView,
RpcRequest, RpcResponse, RpcResult, ServerRequest, ServerResponse, SessionSummary,
ToolCallView, ToolEventView, ToolResultView, WorkspaceId, WorkspaceView,
} from './api.ts'
import type { RequestPayload, ResponseValue, RpcMethodMap } from '@deepseek-ai/dsh-host-apiproxy/api'
import { AbstractApiClient, RpcId } from './api.ts'
@@ -76,7 +76,7 @@ function buildAlphaLog(): SessionEvent[] {
})
}
if (turn % 9 === 4) {
push({ type: 'context/message', surfaceOp: 'append', data: { content: text(`[fixture] 上下文注入turn ${turn}`), source: { kind: 'plugin', plugin: 'fixture' } } })
push({ type: 'user/message', surfaceOp: 'append', data: { content: text(`[fixture] 上下文注入turn ${turn}`), source: { kind: 'plugin', plugin: 'fixture' } } })
}
push({ type: 'step/start', data: { turn, step: 0 } })
const withTool = turn % 5 === 2
@@ -124,6 +124,49 @@ function buildAlphaLog(): SessionEvent[] {
toolTurn(61, 'fx-write', '{"path":"notes/demo.txt","content":"hello fixture\\n"}', 'wrote notes/demo.txt')
toolTurn(62, 'edit', '{"file_path":"notes/demo.txt","old_string":"hello","new_string":"hello fixture"}', '已编辑')
toolTurn(63, 'write', '{"file_path":"notes/new-demo.txt","content":"hello fixture\\n"}', '已写入')
// Turn 64: one run_code turn with three logged sub-dispatches — the Code
// Mode acceptance surface (parent code row + nested native-identical rows,
// including an isError sub-call and a bash sub-call that must hit the same
// keyed registration a top-level bash row uses).
{
const turn = 64
const callId = `fx-call-${turn}`
const program = 'const listing = await tools.bash({ command: "ls notes", description: "List notes" })\n'
+ 'const demo = await tools.read({ path: "notes/demo.txt" })\n'
+ 'await tools.read({ path: "notes/missing.txt" }).catch(() => "tolerated")\n'
+ 'return { listing, demo }'
const args = JSON.stringify({ code: program, description: 'Read the notes files and summarize' })
push({ type: 'turn/start', data: { turn, trigger: { kind: 'message', source: { kind: 'user' } } } })
push({ type: 'user/message', surfaceOp: 'append', data: { content: text(`问题 ${turn}run_code 样本。`), source: { kind: 'user' } } })
push({ type: 'step/start', data: { turn, step: 0 } })
push({
type: 'assistant/message', surfaceOp: 'append',
data: { turn, step: 0, content: [{ type: 'tool-call', id: callId, name: 'run_code', arguments: args } as ContentBlock], provenance: { provider: 'fixture', model: 'fx-1' } },
})
push({ type: 'tool/call', data: { turn, step: 0, callId, name: 'run_code', arguments: args } })
const dispatchPair = (n: number, name: string, dispatchArgs: Record<string, unknown>, resultText: string, isError = false): void => {
push({
type: 'tool/code-dispatch-start',
data: { parentCallId: callId, subCallId: `${callId}:code:${n}`, name, arguments: dispatchArgs },
})
push({
type: 'tool/code-dispatch',
data: {
parentCallId: callId, subCallId: `${callId}:code:${n}`, name,
arguments: dispatchArgs, isError, content: [{ type: 'text', text: resultText }],
},
})
}
dispatchPair(1, 'bash', { command: 'ls notes', description: 'List notes' }, 'demo.txt\nnew-demo.txt')
dispatchPair(2, 'read', { path: 'notes/demo.txt' }, 'hello fixture\n')
dispatchPair(3, 'read', { path: 'notes/missing.txt' }, 'Error: ENOENT: notes/missing.txt not found', true)
push({
type: 'tool/result', surfaceOp: 'append',
data: { turn, step: 0, callId, content: text('{"listing":"demo.txt\\nnew-demo.txt","demo":"hello fixture\\n"}'), isError: false },
})
push({ type: 'step/end', data: { turn, step: 0 } })
push({ type: 'turn/end', data: { turn, reason: { kind: 'completed' } } })
}
return events as unknown as SessionEvent[]
}
@@ -242,6 +285,20 @@ interface StreamConn<F> {
push(envelope: RpcRequest<F>): void
}
/** Deterministic fixture branches used by keyless Web assembly tests. */
export interface FixtureOptions {
/** Start with no real Workspace or Session. */
empty?: boolean
/** Reject every prompt before appending its user event. */
rejectPrompt?: boolean
/** Publish the Session but fail its Workspace account write. */
failWorkspaceAttach?: boolean
/** Publish and frame the Session, then throw instead of returning create. */
dropSessionCreateResponse?: boolean
/** Order of the two successful create frames. */
createFrameOrder?: 'session-first' | 'workspace-first'
}
/** Inbox pump shared by both stream generators (FrameQueue pattern: ONE abort listener hung
* outside the loop — a per-iteration {once:true} listener never fires for non-final rounds and
* piles up for the stream's lifetime, audit C5). breakNow force-ends the stream without the
@@ -286,22 +343,34 @@ class FxInbox<F> implements StreamConn<F> {
/**
* In-memory fake host: fx-alpha carries history and replay scripts; fx-beta is fx-alpha's child session (lineage indent material).
* @param options - fixture branches for empty state and failure timing.
* @returns an ApiProxy backed entirely by in-memory state — no host process, no network.
*/
export function createFixtureApi(): ApiProxy {
const sessions: SessionSummary[] = [
{ sessionId: sid('fx-alpha'), updatedAt: Date.now(), running: true, cwd: '/tmp/fixture' },
{ sessionId: sid('fx-beta'), updatedAt: Date.now() - 60_000, running: false, parentSessionId: sid('fx-alpha'), cwd: '/tmp/fixture' },
{ sessionId: sid('fx-gamma'), updatedAt: Date.now() - 120_000, running: false, cwd: '/tmp/fixture' },
export function createFixtureApi(options: FixtureOptions = {}): ApiProxy {
// The resident fixture sessions all carry history, so none of them is blank.
const sessions: SessionSummary[] = options.empty ? [] : [
{ sessionId: sid('fx-alpha'), updatedAt: Date.now(), running: true, blank: false, cwd: '/tmp/fixture' },
{ sessionId: sid('fx-beta'), updatedAt: Date.now() - 60_000, running: false, blank: false, parentSessionId: sid('fx-alpha'), cwd: '/tmp/fixture' },
{ sessionId: sid('fx-gamma'), updatedAt: Date.now() - 120_000, running: false, blank: false, cwd: '/tmp/fixture' },
]
const logs = new Map<SessionId, SessionEvent[]>([[sid('fx-alpha'), buildAlphaLog()]])
const modelTargets = new Map<SessionId, ModelTarget>(sessions.map(session => [
session.sessionId,
{ provider: 'deepseek', model: 'deepseek-v4-flash' },
]))
const nextTurn = new Map<SessionId, number>([[sid('fx-alpha'), 60]])
let nextSession = 1
let nextRpc = 1
let attachedSessions = options.empty ? 0 : 1
// Workspace entities mirroring the host registry: the fixture sessions all
// live under one workspace, whose account carries them in attach order.
const wid = (raw: string): WorkspaceId => raw as WorkspaceId
const fixtureEpoch = new Date(Date.now() - 300_000).toISOString()
const workspaces: WorkspaceView[] = options.empty ? [] : [{
workspaceId: wid('fx-ws-fixture'),
path: '/tmp/fixture',
title: 'fixture',
sessionIds: [sid('fx-alpha'), sid('fx-beta'), sid('fx-gamma')],
createdAt: fixtureEpoch,
updatedAt: fixtureEpoch,
}]
let nextWorkspace = 1
const mint = (): ReturnType<typeof RpcId> => RpcId(`fx-rpc-${nextRpc++}`)
/** Resident pending approval (stable rpcId: every mux open replays the same id, matching host replay semantics). */
const pendingApprovalRpcId = mint()
@@ -359,6 +428,16 @@ export function createFixtureApi(): ApiProxy {
}
const summaryOf = (id: SessionId): SessionSummary | undefined => sessions.find(s => s.sessionId === id)
/** Shared session guard for sessionId-addressed catalog routes: the error
* response when the session is unknown, undefined when it exists. */
const requireSession = (request: RpcRequest<{ sessionId: SessionId }>): Promise<RpcResponse<never>> | undefined => {
if (summaryOf(request.payload.sessionId) !== undefined) return undefined
return err<{ sessionId: SessionId }, never>(request, {
code: 'session-not-found',
message: `no session ${request.payload.sessionId}`,
details: { sessionId: request.payload.sessionId },
})
}
const setRunning = (id: SessionId, running: boolean): void => {
const summary = summaryOf(id)
if (summary === undefined || summary.running === running) return
@@ -468,13 +547,72 @@ export function createFixtureApi(): ApiProxy {
return {
sessions: {
list: request => ok(request, { items: [...sessions].sort((a, b) => b.updatedAt - a.updatedAt) }),
create: (request) => {
create: async (request) => {
const workspace = request.payload.workspaceId === undefined
? undefined
: workspaces.find(w => w.workspaceId === request.payload.workspaceId)
if (request.payload.workspaceId !== undefined && workspace === undefined) {
return err(request, {
code: 'workspace-not-found',
message: `no workspace ${request.payload.workspaceId}`,
details: { workspaceId: request.payload.workspaceId },
})
}
const cwd = workspace?.path ?? request.payload.cwd ?? '/tmp/fixture'
const requestedId = request.payload.sessionId
const attachWorkspace = (sessionId: SessionId): void => {
/* v8 ignore next -- callers enter only when a target Workspace exists. */
if (workspace === undefined || workspace.sessionIds.includes(sessionId)) return
workspace.sessionIds = [sessionId, ...workspace.sessionIds]
workspace.updatedAt = new Date().toISOString()
emitHost({ type: 'host/workspace-changed', workspace: { ...workspace } })
}
const attachFailure = (
sessionId: SessionId,
workspaceId: WorkspaceId,
): Promise<RpcResponse<{ sessionId: SessionId }>> => err(request, {
code: 'workspace-attach-failed' as const,
message: `fixture rejected Workspace attachment for ${sessionId}`,
details: { sessionId, workspaceId },
})
if (requestedId !== undefined) {
const existing = summaryOf(requestedId)
if (existing !== undefined) {
if (existing.cwd !== cwd) {
return err(request, {
code: 'session-conflict',
message: `session ${requestedId} already uses ${existing.cwd ?? 'no cwd'}`,
details: { sessionId: requestedId, requestedCwd: cwd, ...existing.cwd === undefined ? {} : { existingCwd: existing.cwd } },
})
}
if (workspace !== undefined && !workspace.sessionIds.includes(requestedId)) {
if (options.failWorkspaceAttach) return attachFailure(requestedId, workspace.workspaceId)
attachWorkspace(requestedId)
}
return ok(request, { sessionId: requestedId })
}
}
const created: SessionSummary = {
sessionId: sid(`fx-${nextSession++}`), updatedAt: Date.now(), running: false, cwd: '/tmp/fixture',
sessionId: requestedId ?? sid(`fx-${nextSession++}`), updatedAt: Date.now(), running: false, blank: true, cwd,
}
sessions.push(created)
modelTargets.set(created.sessionId, { provider: 'deepseek', model: 'deepseek-v4-flash' })
emitHost({ type: 'host/session-added', sessionId: created.sessionId })
attachedSessions += 1
const emitSession = (): void => {
// Mirrors the host: the frame fires at creation, so blank is constantly true.
emitHost({ type: 'host/session-added', sessionId: created.sessionId, blank: true, cwd })
}
if (workspace !== undefined && options.failWorkspaceAttach) {
emitSession()
return attachFailure(created.sessionId, workspace.workspaceId)
}
if (workspace !== undefined && options.createFrameOrder === 'workspace-first') {
attachWorkspace(created.sessionId)
emitSession()
} else {
emitSession()
if (workspace !== undefined) attachWorkspace(created.sessionId)
}
if (options.dropSessionCreateResponse) throw new Error('fixture: dropped session.create response after publication')
return ok(request, { sessionId: created.sessionId })
},
history: async (request) => {
@@ -486,36 +624,7 @@ export function createFixtureApi(): ApiProxy {
const delay = historyDelayMs
if (delay > 0) await new Promise(resolve => setTimeout(resolve, delay))
if (doomed) throw new Error('fixture: simulated history transport failure')
return ok(request, {
...page,
modelTarget: modelTargets.get(request.payload.sessionId)
?? { provider: 'deepseek', model: 'deepseek-v4-flash' },
})
},
models: request => ok(request, {
current: modelTargets.get(request.payload.sessionId)
?? { provider: 'deepseek', model: 'deepseek-v4-flash' },
groups: [
{
id: 'deepseek',
name: 'DeepSeek',
models: [
{ id: 'deepseek-v4-flash', name: 'DeepSeek-V4-Flash', description: '快速响应' },
{ id: 'deepseek-v4-pro', name: 'DeepSeek-V4-Pro', description: '复杂任务' },
],
},
{
id: 'openai',
name: 'OpenAI',
models: [{ id: 'gpt-5', name: 'GPT-5' }],
},
],
failures: [],
}),
selectModel: (request) => {
const selected = { provider: request.payload.provider, model: request.payload.model }
modelTargets.set(request.payload.sessionId, selected)
return ok(request, { selected })
return ok(request, page)
},
prompt: (request) => {
const { sessionId: id, mode, content } = request.payload
@@ -523,7 +632,16 @@ export function createFixtureApi(): ApiProxy {
if (summary === undefined) {
return err(request, { code: 'session-not-found', message: `no session ${id}`, details: { sessionId: id } })
}
if (options.rejectPrompt) {
return err(request, {
code: 'agent-busy',
message: 'fixture: prompt rejected before acceptance',
details: { reason: 'fixture-prompt-rejection' },
})
}
summary.updatedAt = Date.now()
// First accepted prompt appends events: the summary stops being blank.
summary.blank = false
const userText = content.map(b => (b.type === 'text' ? b.text : '')).join('')
if (mode === 'steer' && replays.has(id)) {
// Steering: insert a steering message into the current turn; the replay continues.
@@ -542,9 +660,7 @@ export function createFixtureApi(): ApiProxy {
turn,
userText === 'render markdown'
? MARKDOWN_FIXTURE
: userText === 'report model'
? `当前模型:${modelTargets.get(id)?.provider ?? 'unknown'}/${modelTargets.get(id)?.model ?? 'unknown'}`
: `回声:${userText}。这是 fixture 的流式回复,用于验证打字机增长与定稿切换。`,
: `回声:${userText}。这是 fixture 的流式回复,用于验证打字机增长与定稿切换。`,
)
return ok(request, { accepted: true as const })
},
@@ -560,7 +676,127 @@ export function createFixtureApi(): ApiProxy {
},
},
host: {
describe: request => ok(request, { version: '0.0.0-fixture', cwd: '/tmp/fixture', attachedSessions: 1 }),
describe: request => ok(request, { version: '0.0.0-fixture', cwd: '/tmp/fixture', attachedSessions }),
},
workspace: {
list: request => ok(request, { items: workspaces.map(w => ({ ...w })) }),
create: (request) => {
const { path, name } = request.payload
const target = path ?? `/tmp/fixture-workspaces/${name ?? ''}`
const existing = workspaces.find(w => w.path === target)
if (existing !== undefined) return ok(request, { workspace: { ...existing }, created: false })
const now = new Date().toISOString()
const created: WorkspaceView = {
workspaceId: wid(`fx-ws-${nextWorkspace++}`),
path: target,
title: name ?? target.split('/').filter(Boolean).at(-1) ?? target,
sessionIds: [],
createdAt: now,
updatedAt: now,
}
workspaces.unshift(created)
emitHost({ type: 'host/workspace-changed', workspace: { ...created } })
return ok(request, { workspace: { ...created }, created: true })
},
rename: (request) => {
const { workspaceId, title } = request.payload
const workspace = workspaces.find(w => w.workspaceId === workspaceId)
if (workspace === undefined) {
return err(request, {
code: 'workspace-not-found',
message: `no workspace ${workspaceId}`,
details: { workspaceId },
})
}
const trimmed = title.trim()
if (trimmed !== workspace.title) {
if (workspaces.some(w => w.workspaceId !== workspaceId && w.title === trimmed)) {
return err(request, {
code: 'workspace-name-conflict',
message: `workspace name '${trimmed}' is already in use`,
details: { name: trimmed },
})
}
workspace.title = trimmed
workspace.updatedAt = new Date().toISOString()
emitHost({ type: 'host/workspace-changed', workspace: { ...workspace } })
}
return ok(request, { workspace: { ...workspace } })
},
insertSessionBefore: (request) => {
const { workspaceId, sessionId, beforeSessionId } = request.payload
const workspace = workspaces.find(w => w.workspaceId === workspaceId)
if (workspace === undefined) {
return err(request, {
code: 'workspace-not-found',
message: `no workspace ${workspaceId}`,
details: { workspaceId },
})
}
if (!workspace.sessionIds.includes(sessionId)
|| (beforeSessionId !== undefined && !workspace.sessionIds.includes(beforeSessionId))) {
return err(request, {
code: 'workspace-move-invalid',
message: `session or anchor is not accounted by workspace ${workspaceId}`,
details: { workspaceId, sessionId, ...beforeSessionId === undefined ? {} : { beforeSessionId } },
})
}
const without = workspace.sessionIds.filter(id => id !== sessionId)
const at = beforeSessionId === undefined ? without.length : without.indexOf(beforeSessionId)
const sessionIds = [...without.slice(0, at), sessionId, ...without.slice(at)]
if (!sessionIds.every((id, index) => id === workspace.sessionIds[index])) {
workspace.sessionIds = sessionIds
workspace.updatedAt = new Date().toISOString()
emitHost({ type: 'host/workspace-changed', workspace: { ...workspace } })
}
return ok(request, { workspace: { ...workspace } })
},
},
commands: {
// The catalog mirrors one session's effective view (every fixture
// session has an agent, like the real host).
list: (request) => {
const missing = requireSession(request)
if (missing !== undefined) return missing
return ok(request, {
commands: [
{ name: 'compact', description: 'fixture压缩当前会话上下文' },
{ name: 'echo', description: 'fixture回显参数', input: { hint: 'text to echo' } },
{ name: 'goal-fixture', description: 'fixture目标样本命令', input: { hint: '<objective>' } },
],
})
},
execute: (request) => {
const missing = requireSession(request)
if (missing !== undefined) return missing
const line = request.payload.line.trim()
const match = /^\/(\S+)(?:\s+(.*))?$/.exec(line)
const name = match?.[1]
if (name === 'compact' || name === 'echo') {
return ok(request, {
matched: true as const,
result: { kind: 'success' as const, text: name === 'echo' ? (match?.[2] ?? '') : 'fixture已压缩假动作' },
})
}
if (name === 'goal-fixture') {
return ok(request, {
matched: true as const,
result: { kind: 'success' as const, text: `fixturegoal 已设置(${request.payload.sessionId}` },
})
}
return ok(request, { matched: false as const })
},
},
skills: {
list: (request) => {
const missing = requireSession(request)
if (missing !== undefined) return missing
return ok(request, {
skills: [
{ name: 'fixture-demo', description: 'fixture 技能样本', whenToUse: '仅供 UI 目录渲染验收' },
],
})
},
},
events: {
async *mux(_request, signal) {
@@ -642,7 +878,12 @@ export function createFixtureApi(): ApiProxy {
* to the isomorphic pipeline (InProcessApiClient over toFetchHandler(fixtureImpl)).
*/
export class FixtureApiClient extends AbstractApiClient {
private readonly api = createFixtureApi()
private readonly api: ApiProxy
constructor() {
super()
this.api = createFixtureApi(fixtureOptionsFromLocation())
}
protected doFetch(): Promise<Response> {
throw new Error('FixtureApiClient overrides all protocol paths; doFetch must be unreachable')
@@ -667,11 +908,17 @@ export class FixtureApiClient extends AbstractApiClient {
case 'session.list': return this.api.sessions.list(request)
case 'session.create': return this.api.sessions.create(request)
case 'session.history': return this.api.sessions.history(request)
case 'session.models': return this.api.sessions.models(request)
case 'session.selectModel': return this.api.sessions.selectModel(request)
case 'session.prompt': return this.api.sessions.prompt(request)
case 'session.cancel': return this.api.sessions.cancel(request)
case 'host.describe': return this.api.host.describe(request)
case 'workspace.list': return this.api.workspace.list(request)
case 'workspace.create': return this.api.workspace.create(request)
case 'workspace.rename': return this.api.workspace.rename(request)
case 'workspace.insertSessionBefore': return this.api.workspace.insertSessionBefore(request)
case 'command.list': return this.api.commands.list(request)
// The in-memory execute never blocks, so a never-aborting signal is faithful here.
case 'command.execute': return this.api.commands.execute(request, new AbortController().signal)
case 'skill.list': return this.api.skills.list(request)
}
}
@@ -716,3 +963,16 @@ export class FixtureApiClient extends AbstractApiClient {
return this.api.respond(message)
}
}
/** Browser query mapping; direct unit callers pass FixtureOptions explicitly. */
function fixtureOptionsFromLocation(): FixtureOptions {
if (typeof location === 'undefined') return {}
const query = new URLSearchParams(location.search)
return {
empty: query.get('fixture') === 'empty',
rejectPrompt: query.get('fixturePrompt') === 'reject',
failWorkspaceAttach: query.get('fixtureAttach') === 'fail',
dropSessionCreateResponse: query.get('fixtureSessionCreate') === 'drop-response',
createFrameOrder: query.get('fixtureFrames') === 'workspace-first' ? 'workspace-first' : 'session-first',
}
}

View File

@@ -1,10 +1,7 @@
/**
* Browser half of the wire consumer layer (contract: api-contracts v3
* section 3; export inventory = v3 §3.2). The wire is this package's client
* half in its entirety — apply mounts ctx.connection: the shared api client
* plus the connection controller handle. Mode selection (?fixture) happens
* here so the rest of the client tree is mode-blind; the controller's sinks
* are wired by the runtime plugin (object layer), which injects this service.
* Browser wire client. The plugin selects fixture or HTTP transport, provides
* the shared API client, and lets the runtime object layer start the stream
* controller with its sinks.
*/
import type { Context } from 'cordis'
import type { IApiClient } from './api.ts'
@@ -15,18 +12,17 @@ import { WebApiClient } from './web-api-client.ts'
// ---- Contract re-exports (browser-safe apiproxy channels + core types) ----
export type {
ApiProxy, SessionsApi, SessionSummary, HostApi, EventsApi, MuxFrame, HostFrame,
ApprovalResponsePayload, QuestionResponsePayload, HistoryEntry, ModelCatalogFailure,
ModelCatalogModel, ModelProviderGroup, ModelTarget, SessionModels, ToolEventView,
ToolCallView, ToolResultView,
ApprovalResponsePayload, QuestionResponsePayload, HistoryEntry, ToolEventView,
ToolCallView, ToolResultView, WorkspaceApi, WorkspaceId, WorkspaceView,
CommandsApi, CommandDescriptor, CommandExecuteResult, SkillsApi, SkillEntry,
RpcRequest, RpcResponse, RpcResult, RpcError, RpcErrorCode,
ClientRequest, ServerResponse, ServerRequest, ClientResponse, RpcMessage, RpcReceipt,
IApiClient, SessionId, SessionEvent, ContentBlock, StreamChunk,
} from './api.ts'
export { RpcId, AbstractApiClient, transportError } from './api.ts'
// ---- Connection loop types (part of the ConnectionHandle.start contract;
// the controller class itself stays package-internal — apply owns the loop,
// tests reach it via src) ----
// Connection loop types are public through ConnectionHandle.start; the
// controller remains package-internal.
export type { ConnectionConfig, ConnectionSinks, ConnectionState }

View File

@@ -0,0 +1,59 @@
/**
* node:http ↔ WHATWG fetch bridge for the /api transport (host side of the
* web carrier; the fetch-shaped handler itself is transport-agnostic).
*/
import type { IncomingMessage, ServerResponse } from 'node:http'
/**
* Bridge one node:http request to the fetch-shaped handler (client close
* aborts; SSE bodies stream out chunk by chunk).
* @param req - incoming node:http request (fully read before dispatch).
* @param res - node:http response the bridge writes and owns to completion.
* @param apiHandler - fetch-shaped API carrier the request is dispatched to.
*/
export async function bridge(req: IncomingMessage, res: ServerResponse, apiHandler: { fetch: typeof fetch }): Promise<void> {
const abort = new AbortController()
// Client-disconnect detection MUST hang off the response, not the request:
// since Node 16, IncomingMessage 'close' fires as soon as the request body is
// fully consumed (immediately for a bodyless GET), which would abort every SSE
// stream right after open. ServerResponse 'close' fires on connection teardown;
// writableEnded distinguishes a normal end() from the client going away.
res.on('close', () => {
if (!res.writableEnded) abort.abort()
})
const chunks: Buffer[] = []
for await (const chunk of req) chunks.push(chunk as Buffer)
/* v8 ignore next 3 -- `??` arms: node:http always sets url/method on server
requests; the fields are only optional on the client-side IncomingMessage type */
const request = new Request(new URL(req.url ?? '/', 'http://dsh.internal'), {
method: req.method ?? 'GET',
headers: Object.fromEntries(Object.entries(req.headers).filter(([, v]) => typeof v === 'string') as [string, string][]),
...chunks.length > 0 ? { body: Buffer.concat(chunks) } : {},
signal: abort.signal,
})
const response = await apiHandler.fetch(request)
res.writeHead(response.status, Object.fromEntries(response.headers.entries()))
if (response.body === null) {
res.end()
return
}
for await (const chunk of response.body) {
// Backpressure: a false return means the socket buffer is full — wait for drain
// instead of buffering unboundedly (slow/suspended SSE consumers). 'close' also
// resolves so a mid-wait disconnect can't park this loop forever; the close
// handler above aborts the handler stream, which then ends the iteration.
if (!res.write(chunk)) {
await new Promise<void>((resolve) => {
const done = (): void => {
res.off('drain', done)
res.off('close', done)
resolve()
}
res.once('drain', done)
res.once('close', done)
})
}
}
res.end()
}

View File

@@ -1,10 +1,29 @@
/**
* Connection plugin, node half. The package IS a dshClient plugin: the wire
* consumer layer lives in its client half in full (src/client/ — contract:
* api-contracts v3 section 3, inventory §3.2); consumers import the /client
* subpath. The empty apply exists so the plugin appears in the host Loader
* (lifecycle governance + dshClient discovery).
*/
/** Host HTTP bridge for browser-client RPC. */
import type { Context } from 'cordis'
// Activates the httpServer Context merge used below.
import type { WebRoute } from '@deepseek-ai/dsh-host-webserver'
import { toFetchHandler } from '@deepseek-ai/dsh-host-apiproxy'
import { API_PATH } from './api-path.ts'
import { bridge } from './http-bridge.ts'
/** Host plugin body — no host-side behavior for the connection plugin. */
export function apply(_ctx: unknown): void {}
export { API_PATH } from './api-path.ts'
/** Stable Cordis plugin name. */
export const name = 'client-connection'
/** Services required before mounting the route. */
export const inject = ['httpServer', 'apiProxy']
/**
* Mounts the API gateway under the browser transport prefix.
* @param ctx - Host plugin context.
*/
export function apply(ctx: Context): void {
const apiHandler = toFetchHandler(ctx.apiProxy)
const route: WebRoute = {
kind: 'prefix',
path: API_PATH,
handler: (req, res) => bridge(req, res, apiHandler),
}
ctx.effect(() => ctx.httpServer.register(route), 'client-connection: /api route')
}

View File

@@ -15,10 +15,11 @@ export const name = 'client-connection-invariant'
export const inject = ['invariants']
/**
* No runtime invariant: the pure wire layer emits no cordis events and owns no
* No runtime invariant: the wire layer emits no cordis events and owns no
* mutable cross-plugin relation — stream/reconnect sequencing is exercised
* directly by its behavior specs, and rpcId round-trip discipline is owned by
* the apiproxy contract layer.
* directly by its behavior specs, rpcId round-trip discipline is owned by the
* apiproxy contract layer, and the node half's single route registration's
* register/dispose symmetry is audited by the webserver package's invariant.
*/
const install: InvariantInstaller = () => {}

View File

@@ -2,7 +2,8 @@
// data source on a real clock; behavior tests need per-case responses and
// deferred-controlled timing). Streams are hand pumps: pushMux/pushHost.
import type {
HostFrame, IApiClient, ModelTarget, MuxFrame, RpcRequest, RpcResponse, SessionId, SessionModels,
CommandDescriptor, CommandExecuteResult, HostFrame, IApiClient, MuxFrame,
RpcRequest, RpcResponse, SessionId, SkillEntry,
} from '../src/client/api.ts'
import { RpcId } from '../src/client/api.ts'
@@ -44,21 +45,9 @@ export class FakeApiClient implements IApiClient {
onList: (payload: unknown) => Promise<RpcResponse<{ items: never[] }>> = () => Promise.resolve(ok({ items: [] }))
onCreate: (payload: unknown) => Promise<RpcResponse<{ sessionId: SessionId }>> = () => Promise.resolve(ok({ sessionId: 'fk-new' as SessionId }))
onHistory: (payload: { sessionId: SessionId; beforeSeq?: number; maxMessages?: number })
=> Promise<RpcResponse<{ events: never[]; hasMore: boolean; modelTarget: ModelTarget }>> =
() => Promise.resolve(ok({
events: [],
hasMore: false,
modelTarget: { provider: 'deepseek', model: 'deepseek-chat' },
}))
=> Promise<RpcResponse<{ events: never[]; hasMore: boolean }>> =
() => Promise.resolve(ok({ events: [], hasMore: false }))
onModels: (payload: unknown) => Promise<RpcResponse<SessionModels>> = () => Promise.resolve(ok({
current: { provider: 'deepseek', model: 'deepseek-chat' },
groups: [],
failures: [],
}))
onSelectModel: (payload: ModelTarget & { sessionId: SessionId })
=> Promise<RpcResponse<{ selected: ModelTarget }>> =
payload => Promise.resolve(ok({ selected: { provider: payload.provider, model: payload.model } }))
onPrompt: (payload: unknown) => Promise<RpcResponse<{ accepted: true }>> = () => Promise.resolve(ok({ accepted: true as const }))
onCancel: (payload: unknown) => Promise<RpcResponse<{ accepted: true }>> = () => Promise.resolve(ok({ accepted: true as const }))
onDescribe: (payload: unknown) => Promise<RpcResponse<{ version: string; cwd: string; attachedSessions: number }>> =
@@ -75,9 +64,6 @@ export class FakeApiClient implements IApiClient {
create: (payload: unknown) => this.record('session.create', payload, this.onCreate(payload)),
history: (payload: { sessionId: SessionId; beforeSeq?: number; maxMessages?: number }) =>
this.record('session.history', payload, this.onHistory(payload)),
models: (payload: unknown) => this.record('session.models', payload, this.onModels(payload)),
selectModel: (payload: ModelTarget & { sessionId: SessionId }) =>
this.record('session.selectModel', payload, this.onSelectModel(payload)),
prompt: (payload: unknown) => this.record('session.prompt', payload, this.onPrompt(payload)),
cancel: (payload: unknown) => this.record('session.cancel', payload, this.onCancel(payload)),
}
@@ -86,6 +72,38 @@ export class FakeApiClient implements IApiClient {
describe: payload => this.record('host.describe', payload, this.onDescribe(payload)),
}
readonly workspace: IApiClient['workspace'] = {
list: (payload: unknown) => this.record('workspace.list', payload, Promise.resolve(ok({ items: [] }))),
create: (payload: unknown) => this.record('workspace.create', payload, Promise.resolve(ok({
workspace: { workspaceId: 'fk-ws' as never, path: '/f/ws', title: 'ws', sessionIds: [], createdAt: '0', updatedAt: '0' },
created: true,
}))),
rename: (payload: unknown) => this.record('workspace.rename', payload, Promise.resolve(ok({
workspace: { workspaceId: 'fk-ws' as never, path: '/f/ws', title: 'ws', sessionIds: [], createdAt: '0', updatedAt: '0' },
}))),
insertSessionBefore: (payload: unknown) => this.record('workspace.insertSessionBefore', payload, Promise.resolve(ok({
workspace: { workspaceId: 'fk-ws' as never, path: '/f/ws', title: 'ws', sessionIds: [], createdAt: '0', updatedAt: '0' },
}))),
}
// Payloads stay `unknown` (lint-lane note above); response rows are the real
// wire shapes so cases can program catalogs and skill lists without casts.
onCommandList: (payload: unknown) => Promise<RpcResponse<{ commands: CommandDescriptor[] }>>
= () => Promise.resolve(ok({ commands: [] }))
onCommandExecute: (payload: unknown) => Promise<RpcResponse<{ matched: boolean; result?: CommandExecuteResult }>>
= () => Promise.resolve(ok({ matched: false }))
onSkillList: (payload: unknown) => Promise<RpcResponse<{ skills: SkillEntry[] }>>
= () => Promise.resolve(ok({ skills: [] }))
readonly commands: IApiClient['commands'] = {
list: (payload: unknown) => this.record('command.list', payload, this.onCommandList(payload)),
execute: (payload: unknown) => this.record('command.execute', payload, this.onCommandExecute(payload)),
}
readonly skills: IApiClient['skills'] = {
list: (payload: unknown) => this.record('skill.list', payload, this.onSkillList(payload)),
}
/** When true, streams never fire onOpen (misbehaving-carrier material for the handshake timeout guard). */
suppressStreamOpen = false

View File

@@ -0,0 +1,92 @@
/**
* Fixture commands/skills domains: contract-shape conformance for the two
* domains added to ApiProxy — rpcId echo, session-addressed catalogs, execute
* parse/dispatch, skill.list session resolution, and the FixtureApiClient
* dispatch rows.
*/
import { describe, expect, it } from 'vitest'
import type { SessionId } from '../src/client/api.ts'
import { RpcId } from '../src/client/api.ts'
import type { RpcRequest } from '../src/client/api.ts'
import { FixtureApiClient, createFixtureApi } from '../src/client/fixture.ts'
const sid = (id: string): SessionId => id as SessionId
let reqCount = 0
const req = <P>(payload: P): RpcRequest<P> => ({ rpcId: RpcId(`t-${reqCount++}`), payload })
const signal = new AbortController().signal
describe('createFixtureApi commands/skills', () => {
it('serves the addressed session catalog with rpcId echo', async () => {
const api = createFixtureApi()
const request = req({ sessionId: sid('fx-alpha') })
const response = await api.commands.list(request)
expect(response.rpcId).toBe(request.rpcId)
if (!response.result.ok) throw new Error('list failed')
const commands = response.result.value.commands
expect(commands.map(c => c.name)).toEqual(['compact', 'echo', 'goal-fixture'])
// input hint rides only the commands declaring it.
const echo = commands.find(c => c.name === 'echo')
expect(echo?.input?.hint).toBeTruthy()
expect(commands.find(c => c.name === 'compact')?.input).toBeUndefined()
})
it('rejects a catalog request for an unknown session', async () => {
const api = createFixtureApi()
const response = await api.commands.list(req({ sessionId: sid('fx-nope') }))
expect(response.result).toMatchObject({ ok: false, error: { code: 'session-not-found' } })
})
it('executes a known command line and reports matched with a result', async () => {
const api = createFixtureApi()
const response = await api.commands.execute(req({ sessionId: sid('fx-alpha'), line: '/echo hello world' }), signal)
if (!response.result.ok) throw new Error('execute failed')
expect(response.result.value.matched).toBe(true)
expect(response.result.value.result).toEqual({ kind: 'success', text: 'hello world' })
})
it('addresses execute to the session (result text carries the id)', async () => {
const api = createFixtureApi()
const hit = await api.commands.execute(req({ sessionId: sid('fx-alpha'), line: '/goal-fixture ship' }), signal)
if (!hit.result.ok) throw new Error('execute failed')
expect(hit.result.value.matched).toBe(true)
expect(hit.result.value.result?.text).toContain('fx-alpha')
const missing = await api.commands.execute(req({ sessionId: sid('fx-nope'), line: '/goal-fixture ship' }), signal)
expect(missing.result).toMatchObject({ ok: false, error: { code: 'session-not-found' } })
})
it('falls to matched:false on unknown names and non-command lines', async () => {
const api = createFixtureApi()
for (const line of ['/nope', 'plain text', '/']) {
const response = await api.commands.execute(req({ sessionId: sid('fx-alpha'), line }), signal)
if (!response.result.ok) throw new Error('execute failed')
expect(response.result.value.matched).toBe(false)
expect(response.result.value.result).toBeUndefined()
}
})
it('serves the skill catalog for the addressed session and rejects unknown sessions', async () => {
const api = createFixtureApi()
const response = await api.skills.list(req({ sessionId: sid('fx-alpha') }))
if (!response.result.ok) throw new Error('skill list failed')
expect(response.result.value.skills[0]?.name).toBe('fixture-demo')
const missingSession = await api.skills.list(req({ sessionId: sid('fx-nope') }))
expect(missingSession.result).toMatchObject({ ok: false, error: { code: 'session-not-found' } })
})
})
describe('FixtureApiClient command/skill dispatch', () => {
it('routes the three method keys through the in-memory dispatch table', async () => {
const client = new FixtureApiClient()
const list = await client.commands.list({ sessionId: sid('fx-alpha') })
if (!list.result.ok) throw new Error('command.list failed')
expect(list.result.value.commands.length).toBeGreaterThan(0)
const executed = await client.commands.execute({ sessionId: sid('fx-alpha'), line: '/compact' })
if (!executed.result.ok) throw new Error('command.execute failed')
expect(executed.result.value.matched).toBe(true)
const skills = await client.skills.list({ sessionId: sid('fx-alpha') })
if (!skills.result.ok) throw new Error('skill.list failed')
expect(skills.result.value.skills.length).toBeGreaterThan(0)
})
})

View File

@@ -5,7 +5,7 @@
* the hand-written fixture/host parallel implementations.
*/
import { afterEach, describe, expect, it, vi } from 'vitest'
import type { SessionId } from '../src/client/api.ts'
import type { SessionId, WorkspaceId } from '../src/client/api.ts'
import { RpcId } from '../src/client/api.ts'
import type { HostFrame, MuxFrame, RpcMessage, RpcRequest } from '../src/client/api.ts'
import { FixtureApiClient, createFixtureApi } from '../src/client/fixture.ts'
@@ -123,7 +123,7 @@ describe('createFixtureApi', () => {
await consuming
if (!created.result.ok) throw new Error('create failed')
const createdId = created.result.value.sessionId
expect(seen).toEqual([{ type: 'host/session-added', sessionId: createdId }])
expect(seen).toEqual([{ type: 'host/session-added', sessionId: createdId, blank: true, cwd: '/tmp/fixture' }])
const list = await api.sessions.list(req({}))
if (!list.result.ok) throw new Error('list failed')
expect(list.result.value.items.some(s => s.sessionId === createdId)).toBe(true)
@@ -295,6 +295,267 @@ describe('createFixtureApi', () => {
const api = createFixtureApi()
const response = await api.host.describe(req({}))
expect(response.result).toMatchObject({ ok: true, value: { version: '0.0.0-fixture', attachedSessions: 1 } })
const empty = await createFixtureApi({ empty: true }).host.describe(req({}))
expect(empty.result).toMatchObject({ ok: true, value: { attachedSessions: 0 } })
})
it('workspace.list serves the resident account and create reuses on path collision', async () => {
const api = createFixtureApi()
const listed = await api.workspace.list(req({}))
if (!listed.result.ok) throw new Error('list failed')
expect(listed.result.value.items).toEqual([expect.objectContaining({
workspaceId: 'fx-ws-fixture', path: '/tmp/fixture', title: 'fixture',
sessionIds: ['fx-alpha', 'fx-beta', 'fx-gamma'],
})])
// path collision → the existing entity comes back, created:false, no frame.
const reused = await api.workspace.create(req({ path: '/tmp/fixture' }))
if (!reused.result.ok) throw new Error('reuse failed')
expect(reused.result.value).toMatchObject({ created: false, workspace: { workspaceId: 'fx-ws-fixture' } })
})
it('workspace.create by name mints a new entity and pushes host/workspace-changed', async () => {
const api = createFixtureApi()
const abort = new AbortController()
const seen: HostFrame[] = []
const consuming = (async () => {
for await (const envelope of api.events.host(req({}), abort.signal)) {
seen.push(envelope.payload)
abort.abort()
}
})()
await new Promise(resolve => setTimeout(resolve, 10))
const created = await api.workspace.create(req({ name: 'nova' }))
if (!created.result.ok) throw new Error('create failed')
expect(created.result.value.created).toBe(true)
expect(created.result.value.workspace).toMatchObject({
path: '/tmp/fixture-workspaces/nova', title: 'nova', sessionIds: [],
})
await consuming
expect(seen).toEqual([{ type: 'host/workspace-changed', workspace: created.result.value.workspace }])
// path spelling falls back to the basename when no title/name rides along.
const pathOnly = await api.workspace.create(req({ path: '/tmp/fixture-elsewhere/base' }))
if (!pathOnly.result.ok) throw new Error('pathOnly failed')
expect(pathOnly.result.value.workspace.title).toBe('base')
// Degenerate spellings reach the impl unfiltered (the fixture carrier has
// no schema gate): both-absent falls back to the bucket dir, and a
// basename-less path serves as its own title.
const bare = await api.workspace.create(req({}))
if (!bare.result.ok) throw new Error('bare failed')
expect(bare.result.value.workspace).toMatchObject({ path: '/tmp/fixture-workspaces/', title: 'fixture-workspaces' })
const rootPath = await api.workspace.create(req({ path: '/' }))
if (!rootPath.result.ok) throw new Error('rootPath failed')
expect(rootPath.result.value.workspace.title).toBe('/')
})
it('workspace.rename covers not-found, conflict, no-op, and the changed frame', async () => {
const api = createFixtureApi()
const abort = new AbortController()
const seen: HostFrame[] = []
const consuming = (async () => {
for await (const envelope of api.events.host(req({}), abort.signal)) {
seen.push(envelope.payload)
if (seen.length >= 2) abort.abort()
}
})()
await new Promise(resolve => setTimeout(resolve, 10))
const wsid = 'fx-ws-fixture' as WorkspaceId
const missing = await api.workspace.rename(req({ workspaceId: 'fx-ws-void' as WorkspaceId, title: 'x' }))
expect(missing.result).toMatchObject({ ok: false, error: { code: 'workspace-not-found', details: { workspaceId: 'fx-ws-void' } } })
await api.workspace.create(req({ name: 'occupied' }))
const conflict = await api.workspace.rename(req({ workspaceId: wsid, title: ' occupied ' }))
expect(conflict.result).toMatchObject({ ok: false, error: { code: 'workspace-name-conflict', details: { name: 'occupied' } } })
const noop = await api.workspace.rename(req({ workspaceId: wsid, title: ' fixture ' }))
if (!noop.result.ok) throw new Error('no-op rename failed')
expect(noop.result.value.workspace.title).toBe('fixture')
const renamed = await api.workspace.rename(req({ workspaceId: wsid, title: 'renamed' }))
if (!renamed.result.ok) throw new Error('rename failed')
expect(renamed.result.value.workspace.title).toBe('renamed')
await consuming
// Only the create and the effective rename emit frames; the no-op stays silent.
expect(seen.map(f => f.type)).toEqual(['host/workspace-changed', 'host/workspace-changed'])
})
it('workspace.insertSessionBefore moves, appends, no-ops, and rejects invalid ids', async () => {
const api = createFixtureApi()
const wsid = 'fx-ws-fixture' as WorkspaceId
const missing = await api.workspace.insertSessionBefore(req({ workspaceId: 'fx-ws-void' as WorkspaceId, sessionId: sid('fx-alpha') }))
expect(missing.result).toMatchObject({ ok: false, error: { code: 'workspace-not-found' } })
const ghost = await api.workspace.insertSessionBefore(req({ workspaceId: wsid, sessionId: sid('fx-ghost') }))
expect(ghost.result).toMatchObject({ ok: false, error: { code: 'workspace-move-invalid', details: { sessionId: 'fx-ghost' } } })
const badAnchor = await api.workspace.insertSessionBefore(req({ workspaceId: wsid, sessionId: sid('fx-alpha'), beforeSessionId: sid('fx-ghost') }))
expect(badAnchor.result).toMatchObject({ ok: false, error: { code: 'workspace-move-invalid', details: { beforeSessionId: 'fx-ghost' } } })
const moved = await api.workspace.insertSessionBefore(req({ workspaceId: wsid, sessionId: sid('fx-gamma'), beforeSessionId: sid('fx-beta') }))
if (!moved.result.ok) throw new Error('move failed')
expect(moved.result.value.workspace.sessionIds).toEqual(['fx-alpha', 'fx-gamma', 'fx-beta'])
const appended = await api.workspace.insertSessionBefore(req({ workspaceId: wsid, sessionId: sid('fx-alpha') }))
if (!appended.result.ok) throw new Error('append failed')
expect(appended.result.value.workspace.sessionIds).toEqual(['fx-gamma', 'fx-beta', 'fx-alpha'])
const before = appended.result.value.workspace.updatedAt
const noop = await api.workspace.insertSessionBefore(req({ workspaceId: wsid, sessionId: sid('fx-alpha') }))
if (!noop.result.ok) throw new Error('no-op move failed')
expect(noop.result.value.workspace.sessionIds).toEqual(['fx-gamma', 'fx-beta', 'fx-alpha'])
expect(noop.result.value.workspace.updatedAt).toBe(before)
})
it('session.create({workspaceId}) lands on the account and unknown ids error', async () => {
const api = createFixtureApi()
const abort = new AbortController()
const seen: HostFrame[] = []
const consuming = (async () => {
for await (const envelope of api.events.host(req({}), abort.signal)) {
seen.push(envelope.payload)
if (seen.length >= 2) abort.abort()
}
})()
await new Promise(resolve => setTimeout(resolve, 10))
const missing = await api.sessions.create(req({ workspaceId: 'fx-ws-void' as WorkspaceId }))
expect(missing.result).toMatchObject({ ok: false, error: { code: 'workspace-not-found', details: { workspaceId: 'fx-ws-void' } } })
const created = await api.sessions.create(req({ workspaceId: 'fx-ws-fixture' as WorkspaceId }))
if (!created.result.ok) throw new Error('create failed')
const id = created.result.value.sessionId
await consuming
// The session lands with the workspace's path as cwd, and the account
// write pushes the fresh workspace snapshot after session-added.
expect(seen[0]).toEqual({ type: 'host/session-added', sessionId: id, blank: true, cwd: '/tmp/fixture' })
expect(seen[1]).toMatchObject({
type: 'host/workspace-changed',
workspace: { workspaceId: 'fx-ws-fixture', sessionIds: [id, 'fx-alpha', 'fx-beta', 'fx-gamma'] },
})
})
it('supports an empty baseline, preallocated ids, workspace-first frames, and idempotent retry', async () => {
const api = createFixtureApi({ empty: true, createFrameOrder: 'workspace-first' })
const initialSessions = await api.sessions.list(req({}))
const initialWorkspaces = await api.workspace.list(req({}))
expect(initialSessions.result).toMatchObject({ ok: true, value: { items: [] } })
expect(initialWorkspaces.result).toMatchObject({ ok: true, value: { items: [] } })
const made = await api.workspace.create(req({ name: 'nova' }))
if (!made.result.ok) throw new Error('workspace create failed')
const abort = new AbortController()
const framesPromise = collect(api.events.host(req({}), abort.signal), abort, frames => frames.length === 2)
await new Promise(resolve => setTimeout(resolve, 10))
const preallocated = sid('fx-preallocated')
const created = await api.sessions.create(req({
workspaceId: made.result.value.workspace.workspaceId,
sessionId: preallocated,
}))
expect(created.result).toEqual({ ok: true, value: { sessionId: preallocated } })
const frames = await framesPromise
expect(frames[0]).toMatchObject({
type: 'host/workspace-changed', workspace: { sessionIds: [preallocated] },
})
expect(frames[1]).toEqual({ type: 'host/session-added', sessionId: preallocated, blank: true, cwd: made.result.value.workspace.path })
const retried = await api.sessions.create(req({
workspaceId: made.result.value.workspace.workspaceId,
sessionId: preallocated,
}))
expect(retried.result).toEqual({ ok: true, value: { sessionId: preallocated } })
const listed = await api.sessions.list(req({}))
if (!listed.result.ok) throw new Error('session list failed')
expect(listed.result.value.items.filter(item => item.sessionId === preallocated)).toHaveLength(1)
const conflict = await api.sessions.create(req({ sessionId: preallocated, cwd: '/elsewhere' }))
expect(conflict.result).toMatchObject({
ok: false,
error: { code: 'session-conflict', details: { sessionId: preallocated, requestedCwd: '/elsewhere' } },
})
})
it('attaches an existing ungrouped Session to a matching Workspace', async () => {
const api = createFixtureApi()
const sessionId = sid('fx-existing-ungrouped')
await expect(api.sessions.create(req({ sessionId, cwd: '/tmp/fixture' }))).resolves.toMatchObject({
result: { ok: true, value: { sessionId } },
})
await expect(api.sessions.create(req({
sessionId,
workspaceId: 'fx-ws-fixture' as WorkspaceId,
}))).resolves.toMatchObject({ result: { ok: true, value: { sessionId } } })
const workspaces = await api.workspace.list(req({}))
if (!workspaces.result.ok) throw new Error('workspace list failed')
expect(workspaces.result.value.items[0]?.sessionIds).toContain(sessionId)
})
it('reports a conflict without an existing cwd detail for an unrecorded cwd', async () => {
const api = createFixtureApi()
const listed = await api.sessions.list(req({}))
if (!listed.result.ok) throw new Error('session list failed')
const existing = listed.result.value.items.find(item => item.sessionId === sid('fx-alpha'))
if (existing === undefined) throw new Error('fixture Session missing')
delete existing.cwd
const conflict = await api.sessions.create(req({ sessionId: existing.sessionId }))
expect(conflict.result).toEqual({
ok: false,
error: {
code: 'session-conflict',
message: `session ${existing.sessionId} already uses no cwd`,
details: { sessionId: existing.sessionId, requestedCwd: '/tmp/fixture' },
},
})
})
it('publishes an ungrouped Session when Workspace attachment fails', async () => {
const api = createFixtureApi({ failWorkspaceAttach: true })
const sessionId = sid('fx-partial')
const created = await api.sessions.create(req({
workspaceId: 'fx-ws-fixture' as WorkspaceId,
sessionId,
}))
expect(created.result).toMatchObject({
ok: false,
error: { code: 'workspace-attach-failed', details: { sessionId, workspaceId: 'fx-ws-fixture' } },
})
const listed = await api.sessions.list(req({}))
const workspaces = await api.workspace.list(req({}))
if (!listed.result.ok || !workspaces.result.ok) throw new Error('list failed')
expect(listed.result.value.items.filter(item => item.sessionId === sessionId)).toHaveLength(1)
expect(workspaces.result.value.items[0]?.sessionIds).not.toContain(sessionId)
const retried = await api.sessions.create(req({
workspaceId: 'fx-ws-fixture' as WorkspaceId,
sessionId,
}))
expect(retried.result).toMatchObject({ ok: false, error: { code: 'workspace-attach-failed' } })
const afterRetry = await api.sessions.list(req({}))
if (!afterRetry.result.ok) throw new Error('list failed')
expect(afterRetry.result.value.items.filter(item => item.sessionId === sessionId)).toHaveLength(1)
})
it('reconciles a dropped create response and can reject a prompt before acceptance', async () => {
const sessionId = sid('fx-lost-response')
const dropped = createFixtureApi({ dropSessionCreateResponse: true })
await expect(Promise.resolve().then(() => dropped.sessions.create(req({
workspaceId: 'fx-ws-fixture' as WorkspaceId,
sessionId,
})))).rejects.toThrow(/dropped session\.create response/)
const listed = await dropped.sessions.list(req({}))
const workspaces = await dropped.workspace.list(req({}))
if (!listed.result.ok || !workspaces.result.ok) throw new Error('list failed')
expect(listed.result.value.items.some(item => item.sessionId === sessionId)).toBe(true)
expect(workspaces.result.value.items[0]?.sessionIds).toContain(sessionId)
await expect(dropped.sessions.create(req({
workspaceId: 'fx-ws-fixture' as WorkspaceId,
sessionId,
}))).resolves.toMatchObject({ result: { ok: true, value: { sessionId } } })
const rejecting = createFixtureApi({ empty: true, rejectPrompt: true })
const real = await rejecting.sessions.create(req({ sessionId: sid('fx-rejected') }))
if (!real.result.ok) throw new Error('session create failed')
const prompt = await rejecting.sessions.prompt(req({
sessionId: real.result.value.sessionId,
mode: 'queue' as const,
content: [{ type: 'text' as const, text: 'keep me' }],
}))
expect(prompt.result).toMatchObject({ ok: false, error: { code: 'agent-busy' } })
})
it('timing hooks: history delay + one-shot failure, silent append, and breakStreams end open generators', async () => {
@@ -347,6 +608,7 @@ describe('createFixtureApi', () => {
describe('FixtureApiClient (protocol-level fake carrier)', () => {
afterEach(() => {
vi.restoreAllMocks()
vi.unstubAllGlobals()
})
it('doFetch is an unreachable tripwire (all protocol paths overridden)', () => {
@@ -382,6 +644,66 @@ describe('FixtureApiClient (protocol-level fake carrier)', () => {
expect((await client.sessions.prompt({ sessionId: id, mode: 'queue', content: [{ type: 'text', text: '嗨' }] })).result.ok).toBe(true)
expect((await client.sessions.cancel({ sessionId: id })).result.ok).toBe(true)
expect((await client.host.describe({})).result.ok).toBe(true)
expect((await client.workspace.list({})).result.ok).toBe(true)
const workspace = await client.workspace.create({ name: 'via-client' })
if (!workspace.result.ok) throw new Error('workspace create failed')
expect(workspace.result.value.workspace.title).toBe('via-client')
const wsid = workspace.result.value.workspace.workspaceId
const renamed = await client.workspace.rename({ workspaceId: wsid, title: 'via-client-2' })
if (!renamed.result.ok) throw new Error('workspace rename failed')
expect(renamed.result.value.workspace.title).toBe('via-client-2')
const attached = await client.sessions.create({ workspaceId: wsid })
if (!attached.result.ok) throw new Error('attached create failed')
const moved = await client.workspace.insertSessionBefore({ workspaceId: wsid, sessionId: attached.result.value.sessionId })
if (!moved.result.ok) throw new Error('workspace move failed')
expect(moved.result.value.workspace.sessionIds).toEqual([attached.result.value.sessionId])
})
it('maps empty, prompt-reject, and workspace-first query scenarios', async () => {
vi.stubGlobal('location', {
search: '?fixture=empty&fixturePrompt=reject&fixtureFrames=workspace-first',
})
const client = new FixtureApiClient()
await expect(client.sessions.list({})).resolves.toMatchObject({ result: { ok: true, value: { items: [] } } })
const made = await client.workspace.create({ name: 'query-workspace' })
if (!made.result.ok) throw new Error('workspace create failed')
const abort = new AbortController()
const framesPromise = collect(client.events.host({}, abort.signal), abort, frames => frames.length === 2)
await new Promise(resolve => setTimeout(resolve, 10))
const sessionId = sid('fx-query-session')
const created = await client.sessions.create({
workspaceId: made.result.value.workspace.workspaceId,
sessionId,
})
expect(created.result).toMatchObject({ ok: true, value: { sessionId } })
const frames = await framesPromise
expect(frames.map(frame => frame.type)).toEqual(['host/workspace-changed', 'host/session-added'])
const rejected = await client.sessions.prompt({
sessionId,
mode: 'queue',
content: [{ type: 'text', text: 'retain' }],
})
expect(rejected.result).toMatchObject({ ok: false, error: { code: 'agent-busy' } })
})
it('maps attach-failure and dropped-response query scenarios', async () => {
vi.stubGlobal('location', { search: '?fixture&fixtureAttach=fail' })
const partial = new FixtureApiClient()
const partialResult = await partial.sessions.create({
workspaceId: 'fx-ws-fixture' as WorkspaceId,
sessionId: sid('fx-query-partial'),
})
expect(partialResult.result).toMatchObject({
ok: false,
error: { code: 'workspace-attach-failed', details: { sessionId: 'fx-query-partial' } },
})
vi.stubGlobal('location', { search: '?fixture&fixtureSessionCreate=drop-response' })
const dropped = new FixtureApiClient()
await expect(dropped.sessions.create({
workspaceId: 'fx-ws-fixture' as WorkspaceId,
sessionId: sid('fx-query-dropped'),
})).rejects.toThrow(/dropped session\.create response/)
})
it('fires onOpen at stream-iteration start and taps server-request full forms', async () => {

View File

@@ -1,10 +1,33 @@
/** Node half: the empty host apply (Loader governance + dshClient discovery placeholder). */
/** Node half: registers the /api prefix route bridging to the api gateway. */
import { Context } from 'cordis'
import { describe, expect, it } from 'vitest'
import { apply } from '../src/index.ts'
import type { ApiProxy } from '@deepseek-ai/dsh-host-apiproxy/api'
import type { HttpServerService, WebRoute } from '@deepseek-ai/dsh-host-webserver'
import { API_PATH, apply, inject } from '../src/index.ts'
describe('node half', () => {
it('apply is a no-op host placeholder', () => {
apply(undefined)
expect(true).toBe(true) // reaching here without throw is the contract
describe('connection node half', () => {
it('registers the /api prefix route and removes it with the fiber', async () => {
const ctx = new Context()
const routes: WebRoute[] = []
// Structural fake: the plugin only touches register(); the service class
// carries private state a literal cannot (and need not) reproduce.
const httpServer: Pick<HttpServerService, 'register' | 'tapIndex' | 'port'> = {
register(route) {
routes.push(route)
return () => { routes.splice(routes.indexOf(route), 1) }
},
tapIndex: () => () => {},
port: 0,
}
ctx.provide('httpServer', httpServer as HttpServerService)
ctx.provide('apiProxy', {} as unknown as ApiProxy)
const fiber = ctx.plugin({ inject: [...inject], apply })
await fiber.await()
expect(routes).toHaveLength(1)
expect(routes[0]).toMatchObject({ kind: 'prefix', path: API_PATH })
await fiber.dispose()
expect(routes).toHaveLength(0)
})
})

View File

@@ -2,7 +2,8 @@
"extends": "../../../tsconfig.base.client.json",
"compilerOptions": {
"rootDir": "src",
"outDir": "lib/types"
"outDir": "lib/types",
"types": ["node"]
},
"include": [
"src"
@@ -20,6 +21,9 @@
{
"path": "../../host/apiproxy"
},
{
"path": "../../host/webserver"
},
{
"path": "../../ui/user-approval"
},

View File

@@ -0,0 +1,6 @@
# Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each
# side as of the last confirmed-consistent state. Both languages carry equal authority;
# after editing either side, bring the other along and re-record with:
# pnpm run verify-translation-pairing --write
README.md: 2b2f63c25cbf3a46babef78a4dfb52f859156887
README.zh.md: 6d94ca4a5e91f390e58575aa4ddf64fc18a509de

View File

@@ -1,8 +1,10 @@
# @deepseek-ai/dsh-client-hmr
English | [中文](README.zh.md)
Hot reload for fetch-arrival client plugins. A static-arrival entry composed only into `--dev` graphs (`dsh web --dev`); production graphs omit the row, so the shell-bundled code stays inert.
The plugin subscribes to the webserver's system SSE channel (`GET /plugins/events`) and reloads one plugin per `rebuilt` frame, serialized through a queue (the bundle handoff slot is single). The sequence per frame — `prefetch` (fetch the new bundle before touching anything), `invalidate`, `registry.delete` (before the fiber: a bare fiber dispose trips the vendored Loader's self-dispose branch, which would mark the entry disabled), drain the old fiber, delete `entry.fiber`, remove owned `<style data-plugin>` tags, `entry.refresh()` re-imports and remounts, `fiber.await()` rethrows startup failures loud. Dependents reload through cordis itself: a fiber's activation epoch strings its service providers' uids, so replacing a provider's fiber cascades every dependent with zero client-side graph analysis. Rebuild detection lives on the webserver: in dev mode it stat-polls each plugin's built `lib/client.js` (`fs.watchFile`) and broadcasts the `rebuilt` frame when the bundle's rev changes, so any tsdown watch process producing the bundle triggers HMR with no builder→host channel.
The browser half subscribes to the system SSE channel (`GET /plugins/events`) and reloads one plugin per `rebuilt` frame, serialized through a queue (the bundle handoff slot is single). The sequence per frame — `prefetch` (fetch the new bundle before touching anything), `invalidate`, `registry.delete` (before the fiber: a bare fiber dispose trips the vendored Loader's self-dispose branch, which would mark the entry disabled), drain the old fiber, delete `entry.fiber`, remove owned `<style data-plugin>` tags, `entry.refresh()` re-imports and remounts, `fiber.await()` rethrows startup failures loud. Dependents reload through cordis itself: a fiber's activation epoch strings its service providers' uids, so replacing a provider's fiber cascades every dependent with zero client-side graph analysis. The node half detects rebuilds with one interval that stat-polls each graph bundle from a synchronous baseline, immediately re-hashes after adding a row, retains missing rows as dirty, and broadcasts only real rev changes; any tsdown watch process producing the bundle therefore triggers HMR with no builder→host channel.
## Model Experience

View File

@@ -0,0 +1,21 @@
# @deepseek-ai/dsh-client-hmr
[English](README.md) | 中文
为通过 fetch 到达的客户端插件提供热重载。该静态到达配置项只组合进 `--dev` 图(`dsh web --dev`);生产图省略此行,因此外壳打包的代码保持不活动。
浏览器侧订阅系统 SSE 通道(`GET /plugins/events`),每个 `rebuilt` 帧重载一个插件,并通过队列串行执行(组合包交接 slot 只能容纳一个)。每帧的顺序是:`prefetch`(在触碰任何内容前抓取新组合包)、`invalidate``registry.delete`(在 fiber 之前执行:只释放 fiber 会触发 vendored Loader 的 self-dispose 分支,把配置项标为禁用)、排空旧 fiber、删除 `entry.fiber`、移除自身拥有的 `<style data-plugin>` 标签、通过 `entry.refresh()` 重新导入并挂载、以 `fiber.await()` 将启动失败高声重新抛出。依赖方由 cordis 自身重载fiber 的激活 epoch 会串联其服务提供方的 uid因此替换提供方 fiber 会级联所有依赖方无需客户端图分析。node 侧使用一个 interval 检测重建:从同步基线开始 stat-poll 每个图组合包;新增一行后立即重新计算 hash缺失行保持 dirty只广播真实 rev 变更。因此,任何生成组合包的 tsdown watch 进程都能触发 HMR无需 builder→host 通道。
## 模型体验
无。重载驱动器属于浏览器侧机制;这里没有任何内容进入模型请求。
#### KV Cache 影响
无;该包既不组装也不发送提供方请求。
## 已知限制与暂缓事项
- **重载有意保持粗粒度**:会创建全新的 fiber 和组件;重载插件中的 React 状态会丢失数据层connection/runtime fiber、Session 对象不受影响。react-refresh 级状态保留与「重新执行组合包会重新运行 factory」冲突因此有意排除。
- **失败时不回滚**:失败的重载会使配置项处于 FAILED 状态,并在 loader 状态投影中高声报告;自动恢复先前组合包会等到实际需要出现后再实现。
- **重建帧不会刷新图 rev**:陈旧 rev 无害(组合包端点以 no-cache 提供内容rev 刷新会随重新连接握手机制落地。

View File

@@ -28,15 +28,20 @@
"immediately": true
},
"license": "BSD-3-Clause",
"dependencies": {
"schemastery": "^3.18.0"
},
"peerDependencies": {
"@cordisjs/plugin-loader": "^1.0.0-rc.5",
"@deepseek-ai/dsh-client-modules": "^0.0.1",
"@deepseek-ai/dsh-host-webserver": "^0.0.1",
"@deepseek-ai/dsh-invariants": "^0.0.1",
"cordis": "^4.0.0-rc.7"
},
"devDependencies": {
"@cordisjs/plugin-loader": "workspace:^",
"@deepseek-ai/dsh-client-modules": "workspace:^",
"@deepseek-ai/dsh-host-webserver": "workspace:^",
"@deepseek-ai/dsh-invariants": "workspace:^",
"cordis": "^4.0.0-rc.7"
},

View File

@@ -64,20 +64,11 @@
*/
import type { Context } from 'cordis'
import type { Entry, Loader } from '@cordisjs/plugin-loader'
import type { WebBootGraph } from '@deepseek-ai/dsh-client-modules'
import type { PluginsEventFrame } from '../events.ts'
import { EVENTS_ENDPOINT } from '../events.ts'
/**
* Frames on the `GET /plugins/events` system SSE channel (owned host-side by
* dsh-host-webserver's PluginEventFrame). Mirrored here because this is a
* wire boundary: frames arrive as JSON text and are validated at the parse
* point, not shared as a same-process typed seam.
*/
export type PluginsEventFrame =
| { type: 'graph'; graph: WebBootGraph }
| { type: 'rebuilt'; id: string; rev: string }
/** System SSE endpoint pushing graph/rebuilt frames (wire protocol constant). */
export const EVENTS_ENDPOINT = '/plugins/events'
export type { PluginsEventFrame } from '../events.ts'
export { EVENTS_ENDPOINT } from '../events.ts'
/** Cordis plugin name. */
export const name = 'client-hmr'

View File

@@ -0,0 +1,16 @@
/**
* Wire protocol of the `/plugins/events` dev SSE channel — single source for
* both halves of this package. Frames still cross a wire boundary: the
* browser half validates them at its JSON parse point; sharing the type keeps
* the two ends from drifting, not from parsing.
*/
import type { WebBootGraph } from '@deepseek-ai/dsh-client-modules'
/** One SSE frame: the full graph on connect, or one rebuilt bundle notice. */
export type PluginsEventFrame =
| { type: 'graph'; graph: WebBootGraph }
| { type: 'rebuilt'; id: string; rev: string }
/** System SSE endpoint pushing graph/rebuilt frames (wire protocol constant). */
export const EVENTS_ENDPOINT = '/plugins/events'

View File

@@ -1,9 +1,189 @@
/**
* HMR plugin, node half. The package IS a dshClient plugin (dev-only row in
* the host graph): the reload driver lives in its client half in full
* (src/client/); the empty apply exists so the plugin appears in the host
* Loader (lifecycle governance + dshClient discovery).
* HMR plugin, node half: the host end of the dev reload chain. One interval
* stat-polls every graph row's client bundle (polling by design: network
* mounts deliver no inotify events), reports content changes through
* `clientModuleHost.rebuilt(id)`, and serves the `/plugins/events` SSE channel
* broadcasting graph/rebuilt frames to the browser half (src/client/).
* Dev-only row: prod compositions never mount this plugin.
*/
import { statSync } from 'node:fs'
import type { ServerResponse } from 'node:http'
import type { Context } from 'cordis'
import z from 'schemastery'
// Empty type imports carry the clientModuleHost/httpServer Context merges.
import type {} from '@deepseek-ai/dsh-client-modules'
import type {} from '@deepseek-ai/dsh-host-webserver'
import type { PluginsEventFrame } from './events.ts'
import { EVENTS_ENDPOINT } from './events.ts'
/** Host plugin body — no host-side behavior for the HMR plugin. */
export function apply(): void {}
export type { PluginsEventFrame } from './events.ts'
export { EVENTS_ENDPOINT } from './events.ts'
/** Cordis plugin name. */
export const name = 'client-hmr'
/** Required services: the web plugin table and the route registry. */
export const inject = ['clientModuleHost', 'httpServer']
/** Plugin config, validated by the same-named schemastery schema. */
export interface Config {
/** Bundle stat-poll interval in milliseconds (default 500, the build-side watcher's polling default). */
pollIntervalMs?: number
}
export const Config: z<Config> = z.object({
pollIntervalMs: z.number().step(1).min(1).default(500),
})
/** Serialize one frame as an SSE data line. */
function sseData(frame: PluginsEventFrame): string {
return `data: ${JSON.stringify(frame)}\n\n`
}
interface WatchedBundle {
path: string
mtimeMs: number
size: number
dirty: boolean
}
/**
* Mount the dev chain: bundle watches, rebuilt reporting, and the SSE channel.
* @param ctx - host plugin context carrying clientModuleHost and httpServer.
* @param config - validated {@link Config}.
*/
export function apply(ctx: Context, config: Config): void {
// schemastery's .default() guarantees the field is set after validation.
const pollIntervalMs = config.pollIntervalMs as number
// --- bundle watch: one HMR-owned stat poll ------------------------------
const watched = new Map<string, WatchedBundle>()
const rehash = (id: string, watch: WatchedBundle, current: { mtimeMs: number; size: number }): void => {
try {
// rebuilt() re-hashes; an unchanged hash stays silent (clientModuleHost
// fires onRebuilt only on a real rev change).
ctx.clientModuleHost.rebuilt(id)
} catch (error) {
const code = (error as NodeJS.ErrnoException).code
if (code === 'ENOENT') {
watch.dirty = true
return
}
ctx.logger.warn(error)
}
watch.mtimeMs = current.mtimeMs
watch.size = current.size
watch.dirty = false
}
const watchRow = (id: string, path: string): void => {
let baseline: { mtimeMs: number; size: number }
try {
baseline = statSync(path)
} catch (error) {
watched.set(id, { path, mtimeMs: 0, size: 0, dirty: true })
if ((error as NodeJS.ErrnoException).code !== 'ENOENT') ctx.logger.warn(error)
return
}
const watch = { path, mtimeMs: baseline.mtimeMs, size: baseline.size, dirty: false }
watched.set(id, watch)
// The module host hashed before publishing the graph. Re-hash immediately
// after capturing this baseline so a write in between cannot become an
// already-current baseline paired with a stale graph rev.
rehash(id, watch, baseline)
}
const pollWatches = (): void => {
for (const [id, watch] of watched) {
let current: { mtimeMs: number; size: number }
try {
current = statSync(watch.path)
} catch (error) {
watch.dirty = true
if ((error as NodeJS.ErrnoException).code !== 'ENOENT') ctx.logger.warn(error)
continue
}
if (!watch.dirty && current.mtimeMs === watch.mtimeMs && current.size === watch.size) continue
// Stat-before-hash preserves a detectable older baseline for writes that
// land during hashing. Repeated stat changes heal a torn read.
rehash(id, watch, current)
}
}
// Diff the watch set against the current graph: drop watches for removed
// rows (or rows whose bundle path moved), add watches for new rows.
const syncWatches = (): void => {
const rows = new Map<string, string>()
for (const row of ctx.clientModuleHost.graph().entries) {
const path = ctx.clientModuleHost.clientPath(row.id)
if (path !== undefined) rows.set(row.id, path)
}
for (const [id, watch] of watched) {
if (rows.get(id) === watch.path) continue
watched.delete(id)
}
for (const [id, path] of rows) {
if (!watched.has(id)) watchRow(id, path)
}
}
ctx.effect(() => {
// Initial sync covers rows already in the graph; the subscription covers
// rows arriving later (boot-window activations, including this plugin's
// own row — no self-exemption, a modules/hmr rebuild rides the same chain).
syncWatches()
const unsubscribe = ctx.clientModuleHost.onGraphChanged(syncWatches)
const timer = setInterval(pollWatches, pollIntervalMs)
timer.unref()
return () => {
unsubscribe()
clearInterval(timer)
watched.clear()
}
}, 'client-hmr: bundle watches')
// --- /plugins/events SSE channel ----------------------------------------
const connections = new Set<ServerResponse>()
const connect = (res: ServerResponse): void => {
res.writeHead(200, {
'content-type': 'text/event-stream',
'cache-control': 'no-cache',
'connection': 'keep-alive',
})
// Comment line on open so clients/proxies see a live channel even when
// no rebuild ever happens; EventSource frame parsing skips it naturally.
res.write(': connected\n\n')
res.write(sseData({ type: 'graph', graph: ctx.clientModuleHost.graph() }))
connections.add(res)
res.on('close', () => { connections.delete(res) })
}
ctx.effect(() => {
const disposeRoute = ctx.httpServer.register({
kind: 'exact',
path: EVENTS_ENDPOINT,
handler: (req, res) => {
// Named routes match ahead of the carrier's method gate; keep the old
// global 405 semantics for non-GET hits on this endpoint.
if (req.method !== 'GET' && req.method !== 'HEAD') {
res.writeHead(405)
res.end()
return
}
connect(res)
},
})
const unsubscribe = ctx.clientModuleHost.onRebuilt((id, rev) => {
const line = sseData({ type: 'rebuilt', id, rev })
for (const res of connections) res.write(line)
})
return () => {
unsubscribe()
disposeRoute()
for (const res of connections) res.destroy()
connections.clear()
}
}, 'client-hmr: /plugins/events channel')
}

View File

@@ -3,8 +3,7 @@
* @module @deepseek-ai/dsh-client-hmr/invariant
*/
/* jscpd:ignore-start */
import type { Context } from 'cordis'
import type { Context, Fiber } from 'cordis'
import type { InvariantInstaller } from '@deepseek-ai/dsh-invariants'
const PACKAGE_NAME = '@deepseek-ai/dsh-client-hmr'
@@ -14,14 +13,42 @@ export const name = 'client-hmr-invariant'
/** Service required before the companion can reserve package ownership. */
export const inject = ['invariants']
/** Live fs.watchFile pollers (this package is the composition's only stat-poll user). */
function statWatchers(): number {
return process.getActiveResourcesInfo().filter(kind => kind === 'StatWatcher').length
}
/**
* No runtime invariant: a dev-only reload driver — it consumes the loader
* entry tree and module cache but owns no events and no cross-plugin mutable
* state; reload correctness (dispose → style removal → re-execute ordering)
* is observable only through the assembled browser runtime, not a host-side
* event relation.
* Owned relation: every bundle stat watcher the node half starts must die
* with its fiber — a surviving poller would keep re-hashing bundles for a
* torn-down dev chain forever. Checked as a baseline delta: the StatWatcher
* count observed at fiber creation must be restored once disposal has drained
* the fiber's effects (`internal/plugin` fires at dispose start; the microtask
* hop lets the disposer queue its unload before `fiber.await()` joins it).
* SSE-connection and listener teardown live inside the same ctx.effect
* disposers, so the watcher count is the relation's observable proxy.
*/
const install: InvariantInstaller = () => {}
const install: InvariantInstaller = (ctx, fail) => {
const baselines = new WeakMap<Fiber, number>()
// Async listener by design: emitPluginDisposed awaits-and-logs returned
// promises, so a violation surfaces loudly instead of unhandled.
// eslint-disable-next-line @typescript-eslint/no-misused-promises
ctx.on('internal/plugin', async (fiber) => {
if (fiber.name !== 'client-hmr') return
if (fiber.uid !== null) {
baselines.set(fiber, statWatchers())
return
}
const baseline = baselines.get(fiber)
if (baseline === undefined) return
await Promise.resolve()
await fiber.await()
const remaining = statWatchers()
if (remaining > baseline) {
fail(`client-hmr fiber disposed but ${remaining - baseline} bundle stat watcher(s) survived teardown`)
}
}, { global: true })
}
/**
* Register this package's invariant companion.
@@ -30,4 +57,3 @@ const install: InvariantInstaller = () => {}
*/
export const apply = (ctx: Context): Promise<() => void> =>
Promise.resolve(ctx.invariants.register(PACKAGE_NAME, install))
/* jscpd:ignore-end */

View File

@@ -1,14 +1,204 @@
/**
* Node half of the HMR plugin: an empty apply placeholder (the reload driver
* lives in the client half) whose only contract is mounting and disposing
* cleanly in the host Loader.
* Node half of the HMR plugin: bundle watches follow the graph, stat changes
* report through clientModuleHost.rebuilt, and everything dies with the fiber.
*/
import { describe, expect, it } from 'vitest'
import { apply } from '@deepseek-ai/dsh-client-hmr'
import { mkdtempSync, rmSync, statSync, unlinkSync, utimesSync, writeFileSync } from 'node:fs'
import { tmpdir } from 'node:os'
import { join } from 'node:path'
import { Context } from 'cordis'
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import type { WebBootGraph, ClientModuleHostService } from '@deepseek-ai/dsh-client-modules'
import type { WebRoute, HttpServerService } from '@deepseek-ai/dsh-host-webserver'
import { apply, Config, EVENTS_ENDPOINT, inject } from '../src/index.ts'
const POLL_MS = 20
let dir: string
beforeEach(() => { dir = mkdtempSync(join(tmpdir(), 'dsh-hmr-')) })
afterEach(() => { rmSync(dir, { recursive: true, force: true }) })
/**
* Controllable clientModuleHost fake over a mutable id → bundle-path table.
* Structural (Pick+cast): the plugin only touches the read/notify surface;
* the service class carries private scan state a literal need not reproduce.
*/
type FakeHost = ClientModuleHostService & { rebuiltCalls: string[]; fireGraphChanged(): void }
interface FakeHostOptions {
beforeGraphRead?: () => void
rebuilt?: (id: string) => string | undefined
}
function fakeClientModuleHost(rows: Map<string, string>, options: FakeHostOptions = {}): FakeHost {
const graphListeners = new Set<() => void>()
const rebuiltCalls: string[] = []
const fake: Pick<FakeHost, 'graph' | 'clientPath' | 'rebuilt' | 'onRebuilt' | 'onGraphChanged' | 'rebuiltCalls' | 'fireGraphChanged'> = {
rebuiltCalls,
fireGraphChanged: () => { for (const l of graphListeners) l() },
graph: (): WebBootGraph => {
options.beforeGraphRead?.()
return {
rev: 'r',
entries: [...rows.keys()].map(id => ({ id, url: `/plugins/${id}/client.js?rev=r`, rev: 'r' })),
}
},
clientPath: id => rows.get(id),
rebuilt: (id) => {
rebuiltCalls.push(id)
return options.rebuilt?.(id) ?? 'r2'
},
onRebuilt: () => () => {},
onGraphChanged: (listener) => {
graphListeners.add(listener)
return () => { graphListeners.delete(listener) }
},
}
return fake as FakeHost
}
// Structural fake: the plugin only touches register(); the service class
// carries private state a literal cannot (and need not) reproduce.
function fakeHttpServer(routes: WebRoute[]): HttpServerService {
const fake: Pick<HttpServerService, 'register' | 'tapIndex' | 'port'> = {
register(route) {
routes.push(route)
return () => { routes.splice(routes.indexOf(route), 1) }
},
tapIndex: () => () => {},
port: 0,
}
return fake as HttpServerService
}
async function mount(clientModuleHost: FakeHost, httpServer: HttpServerService) {
const ctx = new Context()
ctx.provide('clientModuleHost', clientModuleHost)
ctx.provide('httpServer', httpServer)
const fiber = ctx.plugin(
{ inject: [...inject], Config, apply },
{ pollIntervalMs: POLL_MS },
)
await fiber.await()
return fiber
}
describe('hmr node half', () => {
it('apply is a no-op host placeholder', () => {
apply()
expect(true).toBe(true) // reaching here without throw is the contract
it('watches graph bundles, reports stat changes, and unwatches on dispose', async () => {
const bundle = join(dir, 'a.js')
writeFileSync(bundle, 'v1')
const clientModuleHost = fakeClientModuleHost(new Map([['pkg-a', bundle]]))
const routes: WebRoute[] = []
const fiber = await mount(clientModuleHost, fakeHttpServer(routes))
expect(routes).toHaveLength(1)
expect(routes[0]).toMatchObject({ kind: 'exact', path: EVENTS_ENDPOINT })
expect(clientModuleHost.rebuiltCalls).toEqual(['pkg-a'])
clientModuleHost.rebuiltCalls.length = 0
// Nudge mtime past stat granularity so the poller sees a content signal.
await new Promise(resolve => setTimeout(resolve, POLL_MS * 2))
writeFileSync(bundle, 'v2-longer')
await vi.waitFor(() => { expect(clientModuleHost.rebuiltCalls).toContain('pkg-a') }, { timeout: 3_000 })
await fiber.dispose()
expect(routes).toHaveLength(0)
// Watcher gone: further file changes report nothing.
clientModuleHost.rebuiltCalls.length = 0
writeFileSync(bundle, 'v3-even-longer')
await new Promise(resolve => setTimeout(resolve, POLL_MS * 4))
expect(clientModuleHost.rebuiltCalls).toHaveLength(0)
})
it('follows graph changes: rows added after activation get watched', async () => {
const early = join(dir, 'early.js')
const late = join(dir, 'late.js')
writeFileSync(early, 'v1')
const rows = new Map([['pkg-early', early]])
const clientModuleHost = fakeClientModuleHost(rows)
const fiber = await mount(clientModuleHost, fakeHttpServer([]))
clientModuleHost.rebuiltCalls.length = 0
writeFileSync(late, 'v1')
rows.set('pkg-late', late)
clientModuleHost.fireGraphChanged()
expect(clientModuleHost.rebuiltCalls).toEqual(['pkg-late'])
clientModuleHost.rebuiltCalls.length = 0
await new Promise(resolve => setTimeout(resolve, POLL_MS * 2))
writeFileSync(late, 'v2-longer')
await vi.waitFor(() => { expect(clientModuleHost.rebuiltCalls).toContain('pkg-late') }, { timeout: 3_000 })
rows.delete('pkg-late')
clientModuleHost.fireGraphChanged()
clientModuleHost.rebuiltCalls.length = 0
writeFileSync(late, 'v3-even-longer')
await new Promise(resolve => setTimeout(resolve, POLL_MS * 3))
expect(clientModuleHost.rebuiltCalls).toHaveLength(0)
await fiber.dispose()
})
it('rehashes after baseline capture so a construction-window write cannot become the baseline', async () => {
const bundle = join(dir, 'construction.js')
writeFileSync(bundle, 'v1')
let rewrite = true
const clientModuleHost = fakeClientModuleHost(new Map([['pkg-a', bundle]]), {
beforeGraphRead: () => {
if (!rewrite) return
rewrite = false
// The graph carries the hash from before this write. The old
// fs.watchFile registration asynchronously captured the new file as
// its first baseline and never requested a re-hash.
writeFileSync(bundle, 'v2-written-during-watch-construction')
},
})
const fiber = await mount(clientModuleHost, fakeHttpServer([]))
expect(clientModuleHost.rebuiltCalls).toEqual(['pkg-a'])
clientModuleHost.rebuiltCalls.length = 0
await new Promise(resolve => setTimeout(resolve, POLL_MS * 3))
expect(clientModuleHost.rebuiltCalls).toHaveLength(0)
await fiber.dispose()
})
it('marks a vanished bundle dirty so identical metadata still re-hashes after it reappears', async () => {
const bundle = join(dir, 'replace.js')
writeFileSync(bundle, 'seed')
const fixedTime = new Date(1_600_000_000_000)
utimesSync(bundle, fixedTime, fixedTime)
const baseline = statSync(bundle)
const clientModuleHost = fakeClientModuleHost(new Map([['pkg-a', bundle]]))
const fiber = await mount(clientModuleHost, fakeHttpServer([]))
clientModuleHost.rebuiltCalls.length = 0
unlinkSync(bundle)
await new Promise(resolve => setTimeout(resolve, POLL_MS * 2))
writeFileSync(bundle, 'x'.repeat(baseline.size))
utimesSync(bundle, fixedTime, fixedTime)
const restored = statSync(bundle)
expect({ mtimeMs: restored.mtimeMs, size: restored.size }).toEqual({
mtimeMs: baseline.mtimeMs,
size: baseline.size,
})
await vi.waitFor(() => { expect(clientModuleHost.rebuiltCalls).toEqual(['pkg-a']) }, { timeout: 3_000 })
await fiber.dispose()
})
it('retains a dirty baseline when the immediate re-hash races a rename', async () => {
const bundle = join(dir, 'rename.js')
writeFileSync(bundle, 'v1')
let first = true
const clientModuleHost = fakeClientModuleHost(new Map([['pkg-a', bundle]]), {
rebuilt: () => {
if (!first) return 'r2'
first = false
throw Object.assign(new Error('bundle renamed'), { code: 'ENOENT' })
},
})
const fiber = await mount(clientModuleHost, fakeHttpServer([]))
await vi.waitFor(() => { expect(clientModuleHost.rebuiltCalls).toEqual(['pkg-a', 'pkg-a']) }, { timeout: 3_000 })
await fiber.dispose()
})
})

View File

@@ -8,7 +8,7 @@
"DOM",
"DOM.Iterable"
],
"types": []
"types": ["node"]
},
"include": [
"src"
@@ -23,6 +23,12 @@
{
"path": "../modules"
},
{
"path": "../../host/webserver"
},
{
"path": "../../../vendor/schemastery"
},
{
"path": "../../support/invariants"
}

View File

@@ -1,16 +0,0 @@
# @deepseek-ai/dsh-client-i18n
i18n plugin: I18nService (ns×locale dictionaries, bind(ns)→t with a stable function identity, locale store). Contract: api-contracts v3 §8.
## Model Experience
None, as the i18n registry serves browser UI copy; nothing here reaches a model request.
#### KV Cache effect
None; this package neither assembles nor sends a provider request.
## Known Limitations and Deferred Work
- **zh/en ship as empty structures** — the existing UI copy is inline Chinese; extraction into dictionaries is deferred repo-wide work, so `bind(ns)` consumers today mostly receive key-echo fallbacks.
- **Locale switching re-renders the whole tree** — accepted as a low-frequency operation; no per-namespace subscription granularity.

View File

@@ -1,113 +0,0 @@
/**
* i18n plugin, browser half: namespace x locale dictionary registry with a
* bound translate function whose reference is stable (safe for inject
* surfaces). Mounts ctx.i18n and seeds the zh/en base dictionaries.
* Contract: api-contracts v3 section 8.
*/
import type { Context } from 'cordis'
// The snapshot-store engine lives in runtime (store relocation): framework
// data stores like this locale cell use it directly. The store carries no
// hook — a React consumer binds a selector hook via web-react's
// bindSnapshotSelector at its own seam (none exists today; the current
// consumers are translate() reads and test-side subscribe/set).
import type { SnapshotStore } from '@deepseek-ai/dsh-client-runtime/client'
import { createSnapshotStore } from '@deepseek-ai/dsh-client-runtime/client'
import { en } from '../locales/en.ts'
import { zh } from '../locales/zh.ts'
/** Translate a key with optional params. */
export type Translate = (key: string, params?: Record<string, unknown>) => string
/** Locale dictionary: flat key to template string ({name} placeholders). */
export type LocaleDict = Record<string, string>
declare module 'cordis' {
interface Context {
i18n: I18nService
}
}
/** Fallback locale consulted after the active locale misses. */
export const FALLBACK_LOCALE = 'zh'
/** Shared namespace for shell-level texts. */
export const COMMON_NS = 'common'
/**
* Dictionary registry plus locale switch. Lookup chain per key: active locale
* -> zh fallback -> the key itself (missing text stays visible, fail loud in
* the UI rather than blank).
*/
export class I18nService {
private dicts = new Map<string, Map<string, LocaleDict>>()
private bound = new Map<string, Translate>()
private localeStore = createSnapshotStore<string>(FALLBACK_LOCALE)
/**
* Register a dictionary for a namespace and locale. Duplicate (ns, locale)
* throws (single occupant; a namespace's texts have one owner).
* @param ns - namespace.
* @param locale - locale tag (zh/en to start).
* @param dict - dictionary.
* @returns disposer (idempotent).
*/
register(ns: string, locale: string, dict: LocaleDict): () => void {
let locales = this.dicts.get(ns)
if (!locales) {
locales = new Map()
this.dicts.set(ns, locales)
}
if (locales.has(locale)) throw new Error(`i18n namespace "${ns}" already has locale "${locale}"`)
locales.set(locale, dict)
return () => {
const owner = this.dicts.get(ns)
if (owner?.get(locale) === dict) owner.delete(locale)
}
}
/**
* Bind a namespace to a translate function. The returned reference is
* stable per namespace (repeat binds return the same function), so it can
* ride inject surfaces without breaking memoization.
* @param ns - namespace.
* @returns the translate function (reads the locale store at call time).
*/
bind(ns: string): Translate {
let t = this.bound.get(ns)
if (!t) {
t = (key, params) => this.translate(ns, key, params)
this.bound.set(ns, t)
return t
}
return t
}
/** Active locale store (switching re-renders the tree; low frequency). */
get locale(): SnapshotStore<string> {
return this.localeStore
}
private translate(ns: string, key: string, params?: Record<string, unknown>): string {
const locales = this.dicts.get(ns)
const template = locales?.get(this.localeStore.getSnapshot())?.[key]
?? locales?.get(FALLBACK_LOCALE)?.[key]
?? key
if (!params) return template
return template.replace(/\{(\w+)\}/g, (match, name: string) =>
name in params ? String(params[name]) : match)
}
}
/** Required services (none; the loader passes the export surface as an object plugin). */
export const inject: string[] = []
/**
* Client plugin body: provide the i18n service with base dictionaries.
* @param ctx - client cordis context.
*/
export function apply(ctx: Context): void {
const i18n = new I18nService()
i18n.register(COMMON_NS, 'zh', zh)
i18n.register(COMMON_NS, 'en', en)
ctx.provide('i18n', i18n)
}

View File

@@ -1,11 +0,0 @@
/**
* i18n plugin, node half. Pure UI plugin: the empty apply exists so the
* plugin appears in the host cordis.yml / Loader (load and lifecycle follow
* the host; the browser half ships via exports["./client"], discovered
* through the package.json dshClient declaration). Everything else —
* I18nService, Translate, LocaleDict — lives in the client half; consumers
* import the /client subpath. Contract: api-contracts v3 section 8.
*/
/** Host plugin body — no host-side behavior for the i18n plugin. */
export function apply(): void {}

View File

@@ -1,53 +0,0 @@
import { describe, expect, it } from 'vitest'
import { I18nService } from '@deepseek-ai/dsh-client-i18n/client'
describe('I18nService', () => {
it('translates from the active locale with zh fallback then key passthrough', () => {
const i18n = new I18nService()
i18n.register('ns', 'zh', { hello: '你好', onlyZh: '仅中文' })
i18n.register('ns', 'en', { hello: 'Hello' })
const t = i18n.bind('ns')
expect(i18n.locale.getSnapshot()).toBe('zh')
expect(t('hello')).toBe('你好')
i18n.locale.set('en')
expect(t('hello')).toBe('Hello')
expect(t('onlyZh')).toBe('仅中文')
expect(t('missing.key')).toBe('missing.key')
})
it('interpolates {name} params and leaves unknown placeholders intact', () => {
const i18n = new I18nService()
i18n.register('ns', 'zh', { greet: '你好,{name}!第 {n} 次', partial: '{known} 与 {unknown}' })
const t = i18n.bind('ns')
expect(t('greet', { name: '世界', n: 2 })).toBe('你好,世界!第 2 次')
expect(t('partial', { known: 'A' })).toBe('A 与 {unknown}')
expect(t('greet')).toBe('你好,{name}!第 {n} 次')
})
it('bind returns a stable reference per namespace', () => {
const i18n = new I18nService()
expect(i18n.bind('a')).toBe(i18n.bind('a'))
expect(i18n.bind('a')).not.toBe(i18n.bind('b'))
})
it('duplicate (ns, locale) throws; disposer unregisters and is idempotent', () => {
const i18n = new I18nService()
const dispose = i18n.register('ns', 'zh', { k: 'v1' })
expect(() => i18n.register('ns', 'zh', { k: 'v2' })).toThrow('already has locale')
dispose()
dispose()
const t = i18n.bind('ns')
expect(t('k')).toBe('k')
i18n.register('ns', 'zh', { k: 'v2' })
expect(t('k')).toBe('v2')
})
it('locale store is subscribable (snapshot store contract)', () => {
const i18n = new I18nService()
let notified = 0
i18n.locale.subscribe(() => { notified += 1 })
i18n.locale.set('en')
expect(i18n.locale.getSnapshot()).toBe('en')
expect(notified).toBe(1)
})
})

View File

@@ -1,30 +0,0 @@
import { describe, expect, it } from 'vitest'
import { Context } from 'cordis'
import { apply as nodeApply } from '@deepseek-ai/dsh-client-i18n'
import { apply as clientApply, COMMON_NS, I18nService, inject } from '@deepseek-ai/dsh-client-i18n/client'
import * as I18nInvariant from '@deepseek-ai/dsh-client-i18n/invariant'
import InvariantService from '@deepseek-ai/dsh-invariants'
describe('invariant companion', () => {
it('registers under the package name with an empty installer', async () => {
const ctx = new Context()
await ctx.plugin(InvariantService, { enabled: true })
await expect(ctx.plugin(I18nInvariant).await()).resolves.toBeDefined()
})
it('node-half apply is a no-op host placeholder', () => {
nodeApply()
expect(true).toBe(true) // reaching here without throw is the contract
})
it('client apply provides ctx.i18n seeded with the zh/en common namespace', async () => {
expect(inject).toEqual([])
const ctx = new Context()
await ctx.plugin({ inject, apply: clientApply }).await()
const i18n = ctx.get('i18n')
expect(i18n).toBeInstanceOf(I18nService)
// Seeded dictionaries occupy the (ns, locale) seats even while empty.
expect(() => (i18n as I18nService).register(COMMON_NS, 'zh', {})).toThrow('already has locale')
expect(() => (i18n as I18nService).register(COMMON_NS, 'en', {})).toThrow('already has locale')
})
})

View File

@@ -1,3 +0,0 @@
import { clientBundle } from '../tsdown.client.ts'
export default clientBundle('@deepseek-ai/dsh-client-i18n', ['lib/types/index.js', 'lib/types/invariant.js'])

View File

@@ -0,0 +1,6 @@
# Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each
# side as of the last confirmed-consistent state. Both languages carry equal authority;
# after editing either side, bring the other along and re-record with:
# pnpm run verify-translation-pairing --write
README.md: 9015af2b44a33771b06863ace139fe97695df616
README.zh.md: 6b129bcabbef5b5a00c5073ebc9142a0e406ddba

View File

@@ -0,0 +1,18 @@
# @deepseek-ai/dsh-client-locale
English | [中文](README.zh.md)
Locale plugin: LocaleService — the browser locale preference (`zh`/`en`, persisted under `dsh.locale`, getter/setter with `locale/change` snapshots) plus the ns×locale dictionary registry (`bind(ns)`→t with a stable function identity; lookup chain active → zh → key).
## Model Experience
None, as the locale registry serves browser UI copy; nothing here reaches a model request.
#### KV Cache effect
None; this package neither assembles nor sends a provider request.
## Known Limitations and Deferred Work
- **Only the Settings surface is translated** — other pages keep inline copy; repo-wide extraction into dictionaries is deferred.
- **Locale switching re-renders subscribed consumers only** — sections not wired to `locale/change` keep their rendered text until remount.

View File

@@ -0,0 +1,18 @@
# @deepseek-ai/dsh-client-locale
[English](README.md) | 中文
locale 插件LocaleService 包含浏览器 locale 偏好(`zh``en`,以 `dsh.locale` 为键持久化;提供 gettersetter并生成 `locale/change` 快照),以及 ns×locale 字典注册表(`bind(ns)`→t 的函数标识稳定;查找链为 active → zh → key
## 模型体验
无。locale 注册表为浏览器 UI 文案提供服务;这里没有任何内容进入模型请求。
#### KV Cache 影响
无;该包既不组装也不发送提供方请求。
## 已知限制与暂缓事项
- **只有设置界面完成翻译**:其他页面仍保留内联文案;将全仓文案提取到字典的工作暂缓。
- **切换 locale 只重新渲染已订阅的消费方**:未接入 `locale/change` 的分区会保留已渲染文本,直到重新挂载。

View File

@@ -0,0 +1,62 @@
{
"name": "@deepseek-ai/dsh-client-locale",
"description": "Locale plugin: LocaleService (zh/en preference with getter/setter/change event + persistence; ns x locale dictionaries, bind(ns) -> t); registers the Language settings row",
"version": "0.0.1",
"private": true,
"type": "module",
"main": "lib/index.js",
"types": "lib/types/index.d.ts",
"exports": {
".": {
"types": "./lib/types/index.d.ts",
"default": "./lib/index.js"
},
"./invariant": {
"types": "./lib/types/invariant.d.ts",
"default": "./lib/invariant.js"
},
"./client": {
"types": "./lib/types/client/index.d.ts",
"default": "./lib/client.js"
},
"./src/*": "./src/*",
"./package.json": "./package.json"
},
"dshClient": {
"inject": [
"@deepseek-ai/dsh-client-runtime"
],
"platform": "web",
"immediately": true
},
"license": "BSD-3-Clause",
"peerDependencies": {
"@deepseek-ai/dsh-client-runtime": "^0.0.1",
"@deepseek-ai/dsh-client-ui-primitives": "^0.0.1",
"@deepseek-ai/dsh-client-ui-slots": "^0.0.1",
"@deepseek-ai/dsh-invariants": "^0.0.1",
"cordis": "^4.0.0-rc.7",
"react": "^18.2.0"
},
"devDependencies": {
"@deepseek-ai/dsh-client-runtime": "workspace:^",
"@deepseek-ai/dsh-client-ui-primitives": "workspace:^",
"@deepseek-ai/dsh-client-ui-slots": "workspace:^",
"@deepseek-ai/dsh-invariants": "workspace:^",
"@types/react": "~18.3.1",
"cordis": "^4.0.0-rc.7",
"react": "^18.2.0"
},
"files": [
"lib/index.js",
"lib/invariant.js",
"lib/client.js",
"lib/types/**/*.d.ts",
"lib/types/**/*.d.ts.map",
"src"
],
"scripts": {
"bundle": "tsdown",
"watch": "tsdown --watch"
}
}

View File

@@ -0,0 +1,47 @@
/* Language row (figma 'Setting-Cell': gap 8, pad 16/0, hairline separator;
* the section column removes the separator on its last child). */
.row {
display: flex;
align-items: center;
gap: 8px;
padding: 16px 0;
border-bottom: 1px solid var(--dsw-alias-border-l2);
}
.rowText {
flex: 1;
min-width: 0;
display: flex;
flex-direction: column;
gap: 4px;
padding-right: 48px;
}
.title {
font-size: 14px;
font-weight: 400;
line-height: 22px;
color: var(--dsw-alias-label-primary);
}
/* Selector pill (figma 'Selector': h36 r18, fill #F5F6F7, pad 0/14, gap 12). */
.selector {
display: inline-flex;
align-items: center;
gap: 12px;
height: 36px;
padding: 0 14px;
border: none;
border-radius: 18px;
background: var(--dsw-alias-bg-module-platform);
font: inherit;
font-size: 14px;
line-height: 22px;
color: var(--dsw-alias-label-primary);
cursor: pointer;
}
.chevron {
flex: none;
}

View File

@@ -0,0 +1,68 @@
/**
* Language preference row registered into the General section item slot
* (figma 501:30011 'Setting-Cell'): title + selector pill opening the locale
* menu. Registered by this package — the locale feature owns its own
* settings surface.
*/
import { useState } from 'react'
import type { PropsRuntime, PropsStore } from '@deepseek-ai/dsh-client-ui-slots'
import { IconChevronDownOutline14, Menu } from '@deepseek-ai/dsh-client-ui-primitives'
import type {} from './settings-contract.ts'
import type { createLanguageRowStore } from './settings-store.ts'
import css from './LanguageRow.module.css'
/** Injected business face: namespace-bound translate + the preference write. */
export interface LanguageRowInjected {
/** Translate a `settings.locale` dictionary key to the active-locale text. */
t: (key: string) => string
/** Switch the active locale (a registered locale id). */
setLocale: (id: string) => void
}
/** Full component props: runtime share + store share + injected face. */
export type LanguageRowComponentProps =
PropsRuntime<'settings.general.item'> & PropsStore<ReturnType<typeof createLanguageRowStore>> & LanguageRowInjected
/**
* Render the Language row.
* @param props - composed slot props.
* @returns the row element tree.
*/
export function LanguageRow({ t, setLocale, useStore }: LanguageRowComponentProps) {
const active = useStore(s => s.active)
const options = useStore(s => s.options)
const [open, setOpen] = useState(false)
const activeLabel = options.find(o => o.id === active)?.label ?? active
return (
<div className={css.row}>
<div className={css.rowText}>
<div className={css.title}>{t('language.title')}</div>
</div>
<Menu
open={open}
onClose={() => { setOpen(false) }}
items={options.map(o => ({ id: o.id, label: o.label }))}
selectedId={active}
onSelect={(id) => {
setLocale(id)
setOpen(false)
}}
align="end"
portal
anchor={(
<button
type="button"
className={css.selector}
aria-haspopup="menu"
aria-expanded={open}
onClick={() => { setOpen(v => !v) }}
>
{activeLabel}
<IconChevronDownOutline14 className={css.chevron} />
</button>
)}
/>
</div>
)
}

View File

@@ -0,0 +1,248 @@
/**
* Browser-side locale registry. Bound translation functions retain stable
* identity for injected consumers. The plugin also registers the Language
* preference row into the settings General section — the locale feature owns
* its own settings surface.
*/
import type { Context } from 'cordis'
import { deferRegistration, type BoundActions } from '@deepseek-ai/dsh-client-ui-slots'
import type { ClientContext } from '@deepseek-ai/dsh-client-runtime/client'
import { en } from '../locales/en.ts'
import { zh } from '../locales/zh.ts'
import type { LanguageRowInjected } from './LanguageRow.tsx'
import { LanguageRow } from './LanguageRow.tsx'
import { createLanguageRowStore } from './settings-store.ts'
export type { LanguageRowComponentProps, LanguageRowInjected } from './LanguageRow.tsx'
export type { LanguageOptionRow, LanguageRowState } from './settings-store.ts'
export type { SettingsGeneralItemOwnerProps } from './settings-contract.ts'
/** Translate a key with optional params. */
export type Translate = (key: string, params?: Record<string, unknown>) => string
/** Locale dictionary: flat key to template string ({name} placeholders). */
export type LocaleDict = Record<string, string>
/** Locale identifier: the two shipped locales. */
export type LocaleId = 'zh' | 'en'
/** One selectable locale: id plus its self-described display name. */
export interface LocaleDefinition {
/** Locale id (persisted; the setLocale argument). */
id: LocaleId
/** Display name in its own language (中文 / English). */
label: string
}
/** Immutable locale state published on every change. */
export interface LocaleSnapshot {
/** Active locale id. */
active: LocaleId
/** Selectable locales in display order. */
locales: readonly LocaleDefinition[]
/** Monotonic change counter (registry or active changes). */
revision: number
}
declare module 'cordis' {
interface Context {
locale: LocaleService
}
interface Events {
/**
* Locale state changed (active locale switched or registry updated).
* @param snapshot - Current immutable locale snapshot.
* @mode emit
*/
'locale/change'(snapshot: LocaleSnapshot): void
}
}
/** Fallback locale consulted after the active locale misses (also the default). */
export const FALLBACK_LOCALE: LocaleId = 'zh'
/** Shared namespace for shell-level texts. */
export const COMMON_NS = 'common'
/** Namespace owning this feature's settings-row copy. */
export const SETTINGS_NS = 'settings.locale'
/** localStorage key holding the persisted locale id. */
export const STORAGE_KEY = 'dsh.locale'
/** The two shipped locales. */
const LOCALES: readonly LocaleDefinition[] = Object.freeze([
{ id: 'zh', label: '中文' },
{ id: 'en', label: 'English' },
])
/**
* Dictionary registry plus locale preference. Lookup chain per key: active
* locale -> zh fallback -> the key itself (missing text stays visible, fail
* loud in the UI rather than blank). Reads go through {@link getLocale};
* writes only through {@link setLocale}; continuous sync only through the
* `locale/change` event.
*/
export class LocaleService {
private dicts = new Map<string, Map<string, LocaleDict>>()
private bound = new Map<string, Translate>()
private snapshot: LocaleSnapshot
private readonly ctx: Context
/**
* @param ctx - owning context (change events are emitted on it).
*/
constructor(ctx: Context) {
this.ctx = ctx
this.snapshot = Object.freeze({ active: restorePreference(), locales: LOCALES, revision: 0 })
}
/**
* Read the current immutable locale snapshot.
* @returns the current snapshot (stable reference until the next change).
*/
getLocale(): LocaleSnapshot {
return this.snapshot
}
/**
* Switch the active locale — the only preference write entry. Persists the
* id and emits `locale/change`.
* @param id - a registered locale id; unknown ids throw.
*/
setLocale(id: string): void {
const match = this.snapshot.locales.find(l => l.id === id)
if (match === undefined) throw new Error(`locale "${id}" is not registered`)
if (this.snapshot.active === match.id) return
this.snapshot = Object.freeze({
active: match.id,
locales: this.snapshot.locales,
revision: this.snapshot.revision + 1,
})
persistPreference(match.id)
this.ctx.emit('locale/change', this.snapshot)
}
/**
* Register a dictionary for a namespace and locale. Duplicate (ns, locale)
* throws (single occupant; a namespace's texts have one owner).
* @param ns - namespace.
* @param locale - locale tag (zh/en to start).
* @param dict - dictionary.
* @returns disposer (idempotent).
*/
register(ns: string, locale: string, dict: LocaleDict): () => void {
let locales = this.dicts.get(ns)
if (!locales) {
locales = new Map()
this.dicts.set(ns, locales)
}
if (locales.has(locale)) throw new Error(`locale namespace "${ns}" already has locale "${locale}"`)
locales.set(locale, dict)
return () => {
const owner = this.dicts.get(ns)
if (owner?.get(locale) === dict) owner.delete(locale)
}
}
/**
* Bind a namespace to a translate function. The returned reference is
* stable per namespace (repeat binds return the same function), so it can
* ride inject surfaces without breaking memoization.
* @param ns - namespace.
* @returns the translate function (reads the active locale at call time).
*/
bind(ns: string): Translate {
let t = this.bound.get(ns)
if (!t) {
t = (key, params) => this.translate(ns, key, params)
this.bound.set(ns, t)
return t
}
return t
}
private translate(ns: string, key: string, params?: Record<string, unknown>): string {
const locales = this.dicts.get(ns)
const template = locales?.get(this.snapshot.active)?.[key]
?? locales?.get(FALLBACK_LOCALE)?.[key]
?? key
if (!params) return template
return template.replace(/\{(\w+)\}/g, (match, name: string) =>
name in params ? String(params[name]) : match)
}
}
/** Read the persisted locale id; unknown or unreadable values fall back to zh. */
function restorePreference(): LocaleId {
// Non-browser runs (node e2e booting the client tree) have no localStorage.
if (typeof localStorage === 'undefined') return FALLBACK_LOCALE
try {
const stored = localStorage.getItem(STORAGE_KEY)
if (stored === 'zh' || stored === 'en') return stored
} catch {
// Storage access can throw (privacy mode); the default below covers it.
}
return FALLBACK_LOCALE
}
/** Persist the locale id; storage failures are non-fatal (preference resets next boot). */
function persistPreference(id: LocaleId): void {
if (typeof localStorage === 'undefined') return
try {
localStorage.setItem(STORAGE_KEY, id)
} catch {
// Storage access can throw (privacy mode / quota); the preference simply
// does not survive the session.
}
}
/** Required services: the slot registry (the feature registers its own settings row). */
export const inject = ['slots']
/**
* Client plugin body: provide the locale service with base dictionaries and
* register the feature-owned Language preference row into the General
* section's item slot (a feature owns its settings surface).
* @param ctx - client cordis context.
*/
export function apply(ctx: ClientContext): void {
const locale = new LocaleService(ctx)
locale.register(COMMON_NS, 'zh', zh)
locale.register(COMMON_NS, 'en', en)
locale.register(SETTINGS_NS, 'zh', { 'language.title': '语言' })
locale.register(SETTINGS_NS, 'en', { 'language.title': 'Language' })
ctx.provide('locale', locale)
const store = createLanguageRowStore()
let bound: BoundActions<typeof store> | undefined
const sync = (snapshot: LocaleSnapshot): void => {
bound?.sync(
snapshot.active,
snapshot.locales.map(l => ({ id: l.id, label: l.label })),
snapshot.revision,
)
}
ctx.on('locale/change', sync)
const injected = (actions: BoundActions<typeof store>): LanguageRowInjected => {
bound = actions
// Re-sync from the getter so no event is lost between registration and
// first render (the store's revision guard drops stale duplicates).
sync(locale.getLocale())
return {
t: locale.bind(SETTINGS_NS),
setLocale: (id) => { locale.setLocale(id) },
}
}
ctx.effect(() => {
const deferred = deferRegistration(ctx.slots, 'settings.general.item', LanguageRow, () =>
ctx.slots.register({
name: 'settings.general.item',
id: 'language',
order: 0,
store,
inject: injected,
}, LanguageRow))
return () => { deferred.dispose() }
}, 'locale: language settings row registration')
}

View File

@@ -0,0 +1,26 @@
/**
* The `settings.general.item` slot type — one preference row inside the
* settings General section, contributed by the feature plugin that owns the
* preference (locale → Language, ui-theme → Appearance). Options: `id` (row
* key), `order` (row position). Rows draw their own internals (row layout,
* separators via CSS); the section column only stacks them.
*
* TYPE HOME RATIONALE: the slot is declared at runtime by
* ui-settings-general's General entry, but its type lives here — this
* package is the common dependency of every item registrant (any settings
* row carries copy, so every registrant already depends on locale), whereas
* the declarer's own contract is unreachable for locale/ui-theme without a
* reference cycle.
*/
declare module '@deepseek-ai/dsh-client-ui-slots' {
interface SlotMap {
/** One preference row inside the settings General section (see module JSDoc). */
'settings.general.item': { kind: 'list'; scope: 'root'; owner: SettingsGeneralItemOwnerProps }
}
}
/** Owner share of a General preference row (the section supplies nothing). */
export interface SettingsGeneralItemOwnerProps {
/** Marker field: item owner props are intentionally empty. */
children?: never
}

View File

@@ -0,0 +1,47 @@
/**
* Language row slot store: a mirror of the locale service snapshot. The
* plugin's apply-world change listener is the only writer; the row component
* reads via props.useStore.
*/
import { defineStore, type EngineStoreHandle } from '@deepseek-ai/dsh-client-runtime/client'
/** One selectable locale row (id + self-described label). */
export interface LanguageOptionRow {
/** Locale id (the setLocale argument). */
id: string
/** Display name in its own language (中文 / English). */
label: string
}
/** Store state mirrored from the locale snapshot. */
export interface LanguageRowState {
/** Active locale id. */
active: string
/** Selectable locales in display order. */
options: LanguageOptionRow[]
/** Service revision; -1 until first sync so revision 0 lands as a change. */
revision: number
}
/** Declared action shape giving the exported factory a stable return type. */
type LanguageRowActions = {
sync: (draft: LanguageRowState, active: string, options: LanguageOptionRow[], revision: number) => void
}
/**
* Declares the Language row state and write surface.
* @returns the store handle.
*/
export function createLanguageRowStore(): EngineStoreHandle<LanguageRowState, LanguageRowActions> {
return defineStore({
init: (): LanguageRowState => ({ active: '', options: [], revision: -1 }),
actions: {
sync: (d, active: string, options: LanguageOptionRow[], revision: number) => {
if (revision <= d.revision) return
d.active = active
d.options = options
d.revision = revision
},
},
})
}

View File

@@ -2,3 +2,5 @@ declare module '*.module.css' {
const classes: Record<string, string>
export default classes
}
declare module '*.css'

View File

@@ -0,0 +1,4 @@
/** Host loader entry for the browser implementation exported from `./client`. */
/** Host plugin body — no host-side behavior for the locale plugin. */
export function apply(): void {}

View File

@@ -1,16 +1,16 @@
/**
* Package-owned invariant companion for `@deepseek-ai/dsh-client-i18n`.
* @module @deepseek-ai/dsh-client-i18n/invariant
* Package-owned invariant companion for `@deepseek-ai/dsh-client-locale`.
* @module @deepseek-ai/dsh-client-locale/invariant
*/
/* jscpd:ignore-start */
import type { Context } from 'cordis'
import type { InvariantInstaller } from '@deepseek-ai/dsh-invariants'
const PACKAGE_NAME = '@deepseek-ai/dsh-client-i18n'
const PACKAGE_NAME = '@deepseek-ai/dsh-client-locale'
/** Cordis companion plugin name. */
export const name = 'client-i18n-invariant'
export const name = 'client-locale-invariant'
/** Service required before the companion can reserve package ownership. */
export const inject = ['invariants']

View File

@@ -0,0 +1,116 @@
/** locale apply wiring: service + dictionaries provision, declaration-aware
* Language row registration, snapshot projection into the row store, and
* recovery after an HMR collapse of the declaring entry. */
import { Context } from 'cordis'
import { describe, expect, it } from 'vitest'
import { SlotsService } from '@deepseek-ai/dsh-client-runtime/client'
import { apply, inject, SETTINGS_NS } from '@deepseek-ai/dsh-client-locale/client'
import type { LanguageRowInjected, LocaleService } from '@deepseek-ai/dsh-client-locale/client'
import { LanguageRow } from '../src/client/LanguageRow.tsx'
import type { createLanguageRowStore } from '../src/client/settings-store.ts'
const SLOT = 'settings.general.item'
async function bench() {
const ctx = new Context()
await ctx.plugin(SlotsService).await()
return { ctx, slots: ctx.get('slots') as SlotsService }
}
/** Stand in for the settings shell: declare the General item slot from root. */
function declareItems(slots: SlotsService): () => void {
return slots.register(
{ name: 'root', children: { [SLOT]: { kind: 'list', scope: 'root' } } } as never,
() => null,
)
}
/** Mirror the framework's inject choreography: bake a real instance from the
* declared handle and hand its actions to the entry's inject factory. */
function faceOf(slots: SlotsService) {
const entry = slots.entries(SLOT).find(e => e.component === LanguageRow)!
const handle = entry.store as ReturnType<typeof createLanguageRowStore>
const instance = handle.create()
const face = (entry.inject as unknown as (a: typeof instance.actions) => LanguageRowInjected)(instance.actions)
return { entry, instance, face }
}
describe('locale apply', () => {
it('declares the slot service', () => {
expect(inject).toEqual(['slots'])
})
it('provides the service with base + settings dictionaries and registers the row (declaration before or after apply)', async () => {
const before = await bench()
declareItems(before.slots)
await before.ctx.plugin({ inject: [...inject], apply }).await()
const locale = before.ctx.get('locale') as LocaleService
// Base dictionaries are registered: the (ns, locale) seats are occupied.
expect(() => locale.register('common', 'zh', {})).toThrow('already has locale')
expect(() => locale.register('common', 'en', {})).toThrow('already has locale')
expect(locale.bind(SETTINGS_NS)('language.title')).toBe('语言')
const entry = before.slots.entries(SLOT).find(e => e.component === LanguageRow)!
expect(entry.options).toMatchObject({ id: 'language', order: 0 })
const after = await bench()
const fiber = after.ctx.plugin({ inject: [...inject], apply })
await fiber.await()
expect(after.slots.entries(SLOT)).toHaveLength(0)
declareItems(after.slots)
await Promise.resolve()
expect(after.slots.entries(SLOT).some(e => e.component === LanguageRow)).toBe(true)
})
it('projects service snapshots into the row store and routes face writes back', async () => {
const b = await bench()
declareItems(b.slots)
await b.ctx.plugin({ inject: [...inject], apply }).await()
const locale = b.ctx.get('locale') as LocaleService
// An event ahead of any inject hits the unbound-actions arm.
locale.setLocale('en')
const { instance, face } = faceOf(b.slots)
// The inject-time re-sync sealed the init window: the mirror is current.
expect(instance.getSnapshot().active).toBe('en')
expect(instance.getSnapshot().options.map(o => o.id)).toEqual(['zh', 'en'])
expect(face.t('language.title')).toBe('Language')
face.setLocale('zh')
expect(locale.getLocale().active).toBe('zh')
expect(instance.getSnapshot().active).toBe('zh')
expect(face.t('language.title')).toBe('语言')
})
it('recovers after an HMR collapse of the declaring entry (stale disposer must not block)', async () => {
const b = await bench()
const host = declareItems(b.slots)
await b.ctx.plugin({ inject: [...inject], apply }).await()
expect(b.slots.entries(SLOT)).toHaveLength(1)
// Collapse: the declarer dies, the cascade removes our entry while the
// apply closure still holds its (now stale) disposer.
host()
expect(b.slots.entries(SLOT)).toHaveLength(0)
declareItems(b.slots)
await Promise.resolve()
expect(b.slots.entries(SLOT).some(e => e.component === LanguageRow)).toBe(true)
})
it('teardown removes the row; teardown without a declaration is quiet', async () => {
const b = await bench()
declareItems(b.slots)
const fiber = b.ctx.plugin({ inject: [...inject], apply })
await fiber.await()
expect(b.slots.entries(SLOT)).toHaveLength(1)
await fiber.dispose()
expect(b.slots.entries(SLOT)).toHaveLength(0)
// Never-declared bench: the effect disposer's dispose arm stays undefined.
const quiet = await bench()
const f2 = quiet.ctx.plugin({ inject: [...inject], apply })
await f2.await()
await f2.dispose()
expect(quiet.slots.entries(SLOT)).toHaveLength(0)
})
})

View File

@@ -0,0 +1,34 @@
// @vitest-environment jsdom
import { describe, expect, it } from 'vitest'
import { Context } from 'cordis'
import { apply as nodeApply } from '@deepseek-ai/dsh-client-locale'
import { apply as clientApply, COMMON_NS, LocaleService, inject } from '@deepseek-ai/dsh-client-locale/client'
import * as LocaleInvariant from '@deepseek-ai/dsh-client-locale/invariant'
import { SlotsService } from '@deepseek-ai/dsh-client-runtime/client'
import InvariantService from '@deepseek-ai/dsh-invariants'
describe('invariant companion', () => {
it('registers under the package name with an empty installer', async () => {
const ctx = new Context()
await ctx.plugin(InvariantService, { enabled: true })
await expect(ctx.plugin(LocaleInvariant).await()).resolves.toBeDefined()
})
it('node-half apply is a no-op host placeholder', () => {
nodeApply()
expect(true).toBe(true) // reaching here without throw is the contract
})
it('client apply provides ctx.locale seeded with the zh/en common namespace', async () => {
// The feature registers its own Language settings row, hence the slots edge.
expect(inject).toEqual(['slots'])
const ctx = new Context()
new SlotsService(ctx)
await ctx.plugin({ inject, apply: clientApply }).await()
const locale = ctx.get('locale')
expect(locale).toBeInstanceOf(LocaleService)
// Seeded dictionaries occupy the (ns, locale) seats even while empty.
expect(() => (locale as LocaleService).register(COMMON_NS, 'zh', {})).toThrow('already has locale')
expect(() => (locale as LocaleService).register(COMMON_NS, 'en', {})).toThrow('already has locale')
})
})

View File

@@ -0,0 +1,82 @@
// @vitest-environment jsdom
/** LanguageRow behavior: selector pill shows the active locale, the menu
* opens/closes, and selection drives setLocale. */
import { afterEach, describe, expect, it, vi } from 'vitest'
import { act, cleanup, fireEvent, render, screen } from '@testing-library/react'
import { createSnapshotStore, type SessionListState, type WorkspaceListState } from '@deepseek-ai/dsh-client-runtime/client'
import { bindSnapshotSelector } from '@deepseek-ai/dsh-client-web-react'
import { LanguageRow } from '../src/client/LanguageRow.tsx'
import type { LanguageRowComponentProps } from '../src/client/LanguageRow.tsx'
import { createLanguageRowStore } from '../src/client/settings-store.ts'
afterEach(cleanup)
const OPTIONS = [{ id: 'zh', label: '中文' }, { id: 'en', label: 'English' }]
/** Empty global standard-kit hooks (the row reads neither). */
function emptySessions() {
const store = createSnapshotStore<SessionListState>(
{ ids: [], byId: {}, current: undefined, phase: 'ready' })
return bindSnapshotSelector(store)
}
function emptyWorkspaces() {
const store = createSnapshotStore<WorkspaceListState>({
items: [], state: 'idle', phase: 'ready', error: null,
baselinesReady: true, recentWorkspaceId: undefined,
})
return bindSnapshotSelector(store)
}
function mount(active = 'en') {
// Real store instance — the sanctioned zero-machinery path for tests.
const store = createLanguageRowStore().create()
store.actions.sync(active, OPTIONS, 0)
const setLocale = vi.fn()
const props: LanguageRowComponentProps = {
useSessions: emptySessions(),
useWorkspaces: emptyWorkspaces(),
useStore: bindSnapshotSelector(store),
actions: store.actions,
t: (key: string) => key === 'language.title' ? 'Language' : key,
setLocale,
}
render(<LanguageRow {...props} />)
return { store, setLocale }
}
describe('LanguageRow', () => {
it('shows the title and the active locale label on the selector pill', () => {
mount('en')
expect(screen.getByText('Language')).toBeDefined()
const trigger = screen.getByRole('button', { name: /English/ })
expect(trigger.getAttribute('aria-expanded')).toBe('false')
})
it('opens the menu, selects a locale, and closes', () => {
const b = mount('en')
const trigger = screen.getByRole('button', { name: /English/ })
fireEvent.click(trigger)
expect(trigger.getAttribute('aria-expanded')).toBe('true')
fireEvent.click(screen.getByRole('menuitem', { name: '中文' }))
expect(b.setLocale).toHaveBeenCalledWith('zh')
expect(trigger.getAttribute('aria-expanded')).toBe('false')
expect(screen.queryByRole('menuitem', { name: '中文' })).toBeNull()
})
it('closes on outside pointerdown without selecting', () => {
const b = mount('en')
fireEvent.click(screen.getByRole('button', { name: /English/ }))
expect(screen.getByRole('menuitem', { name: '中文' })).toBeDefined()
fireEvent.pointerDown(document.body)
expect(screen.queryByRole('menuitem', { name: '中文' })).toBeNull()
expect(b.setLocale).not.toHaveBeenCalled()
})
it('follows store changes; an unknown active id falls back to the id itself', () => {
const b = mount('en')
act(() => { b.store.actions.sync('zh', OPTIONS, 1) })
expect(screen.getByRole('button', { name: /中文/ })).toBeDefined()
act(() => { b.store.actions.sync('fr', OPTIONS, 2) })
expect(screen.getByRole('button', { name: /fr/ })).toBeDefined()
})
})

View File

@@ -0,0 +1,102 @@
// @vitest-environment jsdom
import { beforeEach, describe, expect, it, vi } from 'vitest'
import { Context } from 'cordis'
import type { LocaleSnapshot } from '@deepseek-ai/dsh-client-locale/client'
import { LocaleService, STORAGE_KEY } from '@deepseek-ai/dsh-client-locale/client'
const make = (): { ctx: Context; svc: LocaleService; events: LocaleSnapshot[] } => {
const ctx = new Context()
const events: LocaleSnapshot[] = []
ctx.on('locale/change', (snapshot) => { events.push(snapshot) })
return { ctx, svc: new LocaleService(ctx), events }
}
describe('LocaleService', () => {
beforeEach(() => {
localStorage.clear()
})
it('translates through the active-locale -> zh -> key chain', () => {
const { svc } = make()
svc.register('ns', 'zh', { hello: '你好', onlyZh: '仅中文' })
svc.register('ns', 'en', { hello: 'Hello' })
const t = svc.bind('ns')
expect(svc.getLocale().active).toBe('zh')
expect(t('hello')).toBe('你好')
svc.setLocale('en')
expect(t('hello')).toBe('Hello')
expect(t('onlyZh')).toBe('仅中文')
expect(t('missing.key')).toBe('missing.key')
})
it('interpolates {name} params and leaves unknown placeholders intact', () => {
const { svc } = make()
svc.register('ns', 'zh', { greet: '你好,{name}!第 {n} 次', partial: '{known} 与 {unknown}' })
const t = svc.bind('ns')
expect(t('greet', { name: '世界', n: 2 })).toBe('你好,世界!第 2 次')
expect(t('partial', { known: 'A' })).toBe('A 与 {unknown}')
})
it('bind returns a stable per-namespace function identity', () => {
const { svc } = make()
expect(svc.bind('a')).toBe(svc.bind('a'))
expect(svc.bind('a')).not.toBe(svc.bind('b'))
})
it('rejects duplicate (ns, locale) and disposer only removes its own dict', () => {
const { svc } = make()
const dispose = svc.register('ns', 'zh', { k: 'v1' })
expect(() => svc.register('ns', 'zh', { k: 'v2' })).toThrow('already has locale')
dispose()
const t = svc.bind('ns')
expect(t('k')).toBe('k')
svc.register('ns', 'zh', { k: 'v2' })
expect(t('k')).toBe('v2')
dispose()
expect(t('k')).toBe('v2')
})
it('setLocale persists, republishes an immutable snapshot, and no-ops on same value', () => {
const { svc, events } = make()
svc.setLocale('en')
expect(svc.getLocale().active).toBe('en')
expect(localStorage.getItem(STORAGE_KEY)).toBe('en')
expect(events).toHaveLength(1)
expect(events[0]).toBe(svc.getLocale())
expect(events[0]!.revision).toBe(1)
svc.setLocale('en')
expect(events).toHaveLength(1)
})
it('throws on unknown locale ids', () => {
const { svc } = make()
expect(() => { svc.setLocale('fr') }).toThrow('not registered')
})
it('restores a persisted locale and falls back to zh on garbage', () => {
localStorage.setItem(STORAGE_KEY, 'en')
expect(make().svc.getLocale().active).toBe('en')
localStorage.setItem(STORAGE_KEY, 'fr')
expect(make().svc.getLocale().active).toBe('zh')
})
it('runs without localStorage (node boots): defaults on read, no-op on write', () => {
vi.stubGlobal('localStorage', undefined)
try {
const { svc } = make()
expect(svc.getLocale().active).toBe('zh')
svc.setLocale('en')
expect(svc.getLocale().active).toBe('en')
} finally {
vi.unstubAllGlobals()
}
})
it('exposes the two shipped locales with self-described labels', () => {
const { svc } = make()
expect(svc.getLocale().locales).toEqual([
{ id: 'zh', label: '中文' },
{ id: 'en', label: 'English' },
])
})
})

View File

@@ -0,0 +1,30 @@
/** Language row store: snapshot-mirror action and the revision guard. */
import { describe, expect, it } from 'vitest'
import { createLanguageRowStore } from '../src/client/settings-store.ts'
const OPTIONS = [{ id: 'zh', label: '中文' }, { id: 'en', label: 'English' }]
describe('createLanguageRowStore', () => {
it('init shape: empty mirror with revision at -1', () => {
const store = createLanguageRowStore().create()
expect(store.getSnapshot()).toEqual({ active: '', options: [], revision: -1 })
})
it('sync mirrors the snapshot and advances the revision', () => {
const store = createLanguageRowStore().create()
store.actions.sync('zh', OPTIONS, 0)
expect(store.getSnapshot()).toEqual({ active: 'zh', options: OPTIONS, revision: 0 })
store.actions.sync('en', OPTIONS, 1)
expect(store.getSnapshot().active).toBe('en')
expect(store.getSnapshot().revision).toBe(1)
})
it('revision guard drops stale and duplicate writes', () => {
const store = createLanguageRowStore().create()
store.actions.sync('en', OPTIONS, 5)
store.actions.sync('zh', OPTIONS, 4)
store.actions.sync('zh', OPTIONS, 5)
expect(store.getSnapshot().active).toBe('en')
expect(store.getSnapshot().revision).toBe(5)
})
})

View File

@@ -0,0 +1,27 @@
{
"extends": "../../../tsconfig.base.client.json",
"compilerOptions": {
"rootDir": "src",
"outDir": "lib/types"
},
"include": [
"src"
],
"references": [
{
"path": "../runtime"
},
{
"path": "../ui-primitives"
},
{
"path": "../ui-slots"
},
{
"path": "../../../vendor/cordis"
},
{
"path": "../../support/invariants"
}
]
}

View File

@@ -0,0 +1,3 @@
import { clientBundle } from '../tsdown.client.ts'
export default clientBundle('@deepseek-ai/dsh-client-locale', ['lib/types/index.js', 'lib/types/invariant.js'])

View File

@@ -0,0 +1,6 @@
# Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each
# side as of the last confirmed-consistent state. Both languages carry equal authority;
# after editing either side, bring the other along and re-record with:
# pnpm run verify-translation-pairing --write
README.md: efba9e2eb0b148677fc7ac18bfad6333fb6f80da
README.zh.md: 7d1aa8af08256c47c1ae65343e46c30e910128d0

View File

@@ -1,5 +1,7 @@
# @deepseek-ai/dsh-client-modules
English | [中文](README.zh.md)
Client module system: the browser peer of Node's internal ESM loader, built as a lazy CJS table. The web shell mounts the vendored cordis Loader for entry governance (fiber lifecycle, inject waiting, update/refresh) and injects this package's `ClientModuleLoader` as its `internal` seam — the vendored side's only consumption point is `EntryTree.import`, so replacing `internal` replaces exactly "how plugin code arrives" and nothing else.
Lazy CJS model (web2): executing a plugin bundle only REGISTERS its factory (`window.__ModuleLoader__.load({id, factory})`); every module body side effect — CSS injection included — lives in the factory closure and runs at materialization (`factory(require)` → export surface, memoized in `loadCache`), not at script execution. A factory that requires another registered-but-unmaterialized module materializes it recursively, so load order needs no external sequencing; require cycles throw (factory-form CJS cannot deliver partial exports). `<id>/client` and the bare id name the same surface (a plugin bundle IS its package's client half).

View File

@@ -0,0 +1,22 @@
# @deepseek-ai/dsh-client-modules
[English](README.md) | 中文
客户端模块系统Node 内部 ESM loader 的浏览器端对等实现,以惰性 CJS 表构建。web 外壳挂载 vendored cordis Loader 来治理配置项fiber 生命周期、inject 等待、update/refresh并把该包的 `ClientModuleLoader` 作为其 `internal` seam 注入vendored 一侧唯一的消费点是 `EntryTree.import`,因此替换 `internal` 恰好只会替换「插件代码如何到达」,不会改变其他内容。
惰性 CJS 模型web2执行插件组合包只会注册其 factory`window.__ModuleLoader__.load({id, factory})`);每个模块主体的副作用(包括 CSS 注入)都位于 factory 闭包中,在物化时运行(`factory(require)` → 导出表层,并在 `loadCache` 中记忆化),不会在脚本执行时运行。如果 factory 请求另一个已注册但尚未物化的模块系统会递归物化它因此加载顺序无需外部编排require 循环会抛出异常factory 形式的 CJS 无法交付部分导出)。`<id>/client` 与裸 id 指向同一表层(一个插件组合包就是其包的客户端侧)。
解析分支顺序(`import(specifier)`):平台种子词 → 外壳实例;记忆化记录 → 表层;外壳自身的静态注册表(`registerStatic`app-shell→ 模块;已注册 factory → 物化;图行(`window.__DSH_BOOT__`)→ 抓取 + 执行 + 物化;其他情况一律抛出异常。这是构建时组合包纯度门禁的运行时镜像。交给 factory 的同步 `require` 采用相同顺序,但不含抓取分支,并把观察到的边记录到模块记录中。`prefetch` 是第一阶段到达 hook抓取 + 执行,只注册;并发调用共享一个进行中的 task`invalidate` 会丢弃 factory 与物化记录,使下一次 prefetch/import 重新抓取HMR hook
## 模型体验
无。模块 loader 属于浏览器侧内核机制;这里没有任何内容进入模型请求。
#### KV Cache 影响
无;该包既不组装也不发送提供方请求。
## 已知限制与暂缓事项
- **有意采用扁平模块图**每个组合包是一个模块节点其边只指向表叶接口loadCache/edges/invalidate按通用模块图塑形因此可以改变 externalization 粒度而不更改接口。
- **自身不记录卸载账目**:样式移除与 fiber 拆卸顺序属于 HMR 驱动器(`@deepseek-ai/dsh-client-hmr`loader 只逐记录清点自身拥有的样式标签 id。

View File

@@ -1,6 +1,6 @@
{
"name": "@deepseek-ai/dsh-client-modules",
"description": "Client module loader: the browser peer of Node's internal ESM loader, consumed by the vendored cordis Loader as its internal seam (resolve/import/loadCache/invalidate over seed table, static registry and fetch bundles)",
"description": "Client module system, dual-face: node half composes the __DSH_BOOT__ entry graph (incremental dshClient scan, bundle route, index tap, webPlugins service); browser half is the lazy-CJS module table the vendored cordis Loader consumes as its internal seam",
"version": "0.0.1",
"private": true,
"type": "module",
@@ -11,6 +11,10 @@
"types": "./lib/types/index.d.ts",
"default": "./lib/index.js"
},
"./client": {
"types": "./lib/types/client/index.d.ts",
"default": "./lib/client.js"
},
"./invariant": {
"types": "./lib/types/invariant.d.ts",
"default": "./lib/invariant.js"
@@ -18,14 +22,26 @@
"./src/*": "./src/*",
"./package.json": "./package.json"
},
"dshClient": {
"platform": "web",
"inject": [],
"immediately": true
},
"scripts": {
"bundle": "tsdown",
"watch": "tsdown --watch"
},
"license": "BSD-3-Clause",
"devDependencies": {
"@cordisjs/plugin-loader": "workspace:^",
"@deepseek-ai/dsh-host-webserver": "workspace:^",
"@deepseek-ai/dsh-invariants": "workspace:^",
"cordis": "^4.0.0-rc.7"
},
"files": [
"lib/index.js",
"lib/invariant.js",
"lib/client.js",
"lib/types/**/*.d.ts",
"lib/types/**/*.d.ts.map",
"src"

View File

@@ -0,0 +1,34 @@
/**
* Browser half (the standard `./client` export): the module-system class and
* wire contract, plus the enrollment plugin face. The module system itself is
* built by the shell kernel BEFORE cordis exists (the bootstrap exception,
* design §4.7 — the mechanism that loads plugins cannot arrive through
* itself); the plugin face only enrolls that pre-existing instance by
* providing it as `ctx.modules`. The kernel statically registers this module,
* so the graph row for this package never triggers a real fetch — arrival is
* a no-op against the already-registered entry.
* @module @deepseek-ai/dsh-client-modules/client
*/
import type { Context } from 'cordis'
import type { DshWindow } from './manifest.ts'
export { ClientModuleSystem } from './system.ts'
export { parseBootManifest } from './manifest.ts'
export type {
BootManifest, BootModuleRow, BootPluginRow, ClientModuleLoader, ClientModuleRecord,
ClientModuleSystemOptions, ClientPluginHandoff, DshWindow, WebBootEntry, WebBootGraph,
} from './manifest.ts'
/**
* Enroll the kernel-built module system as `ctx.modules`.
* @param ctx - client root context.
*/
export function apply(ctx: Context): void {
const modules = (globalThis as DshWindow).__DSH_MODULES__
// The kernel writes the slot right after constructing the instance, before
// any cordis entry exists — a missing slot means the kernel sequencing broke.
if (modules === undefined) {
throw new Error('client-modules: window.__DSH_MODULES__ missing — the shell kernel must construct the module system before plugin boot')
}
ctx.reflect.provide('modules', modules)
}

View File

@@ -0,0 +1,243 @@
/**
* Client module system: the browser peer of Node's internal ESM loader, built
* as a lazy CJS table. The vendored cordis Loader consumes this object
* through its `internal` seam (the only call site is `EntryTree.import` →
* `internal.import`), which keeps entry governance (fiber lifecycle, inject
* waiting, update/refresh) entirely on the vendored side while this package
* owns code arrival.
*
* Lazy CJS model (web2 §0): executing a plugin bundle only REGISTERS its
* factory (`window.__ModuleLoader__.load({id, factory})`); every module body
* side effect — including CSS injection — lives inside the factory closure
* and runs at materialization, not at script execution. Materialization
* (factory(require) → export surface) happens on first import/require and is
* memoized in {@link ClientModuleLoader.loadCache}; a factory that requires
* another registered-but-unmaterialized module materializes it recursively,
* so load order needs no external sequencing.
*
* Resolution branch order (import): seed word → shell instance; memoized
* record → surface; static registry (shell-own modules, e.g. app-shell) →
* module; registered factory → materialize; graph row → fetch + execute +
* materialize; anything else → throw (loud — the runtime mirror of the
* build-time bundle purity gate). The synchronous `require` handed to
* factories walks the same order minus the fetch branch: fetching is async,
* so only already-executed bundles can be required — and cross-plugin value
* imports are a build error anyway.
*
* This file is the browser-safe contract face (zero node imports): the
* `__DSH_BOOT__` wire types, the boot-manifest parser, and the seams around
* {@link ClientModuleSystem}. The package root is the host-side service that
* composes the wire.
*/
import type {} from 'cordis'
import type { ClientModuleSystem } from './system.ts'
declare module 'cordis' {
interface Context {
/** The client module system the web shell builds at boot (contract C5; provided by the `./client` wrapper plugin). */
modules: ClientModuleLoader
}
}
/**
* One composed client entry pushed by the host (web2 §0 graph row). Wire
* single source: the host node half (package root) produces this same shape.
* `immediately` marks stage-one prefetch; `inject` is informational graph
* metadata (the authoritative edges live in each package's dshClient
* declaration and reach fibers through entry creation).
*/
export interface WebBootEntry {
/** Entry name == package name. */
id: string
/** Bundle endpoint, '/plugins/<id>/client.js?rev=<rev>'. */
url: string
/** Bundle content hash (cache-busting consistency anchor). */
rev: string
/** Package-name dependency edges, informational (preflight display / HMR diffing). */
inject?: string[]
/** Stage-one prefetch mark: fetch + execute (factory registration) during module-face boot. */
immediately?: boolean
}
/** The composed client entry graph the host injects as `window.__DSH_BOOT__`. */
export interface WebBootGraph {
/** Consistency anchor over the whole graph (content + bundle hashes). */
rev: string
/** Composed entries; order carries no semantics (activation order is fiber inject waiting). */
entries: WebBootEntry[]
}
/** The npm-package view of one boot row: what the module table needs to fetch the bundle. */
export interface BootModuleRow {
/** Entry name == package name (module-table key). */
id: string
/** Bundle endpoint, '/plugins/<id>/client.js?rev=<rev>'. */
url: string
/** Bundle content hash. */
rev: string
}
/** The cordis-plugin view of one boot row: what entry composition needs (optional wire fields normalized). */
export interface BootPluginRow {
/** Entry name == package name. */
id: string
/** Package-name dependency edges ([] when the wire omits them). */
inject: string[]
/** Stage-one prefetch tier (false when the wire omits it). */
immediately: boolean
}
/** The parsed boot manifest: one wire, two consumer views. */
export interface BootManifest {
/** Consistency anchor over the whole graph. */
rev: string
/** Rows as the module table consumes them. */
modules: BootModuleRow[]
/** Rows as entry composition consumes them. */
plugins: BootPluginRow[]
}
/**
* Parse `window.__DSH_BOOT__` into the two consumer views. Wire boundary:
* a missing or malformed graph throws (the shell shows the loud failure —
* a page without a valid manifest cannot boot anything).
* @param wire - the raw `window.__DSH_BOOT__` value.
* @returns the manifest with optional plugin-view fields normalized.
*/
export function parseBootManifest(wire: unknown): BootManifest {
if (typeof wire !== 'object' || wire === null) {
throw new Error('client-modules: window.__DSH_BOOT__ is missing or not an object')
}
const graph = wire as Record<string, unknown>
if (typeof graph.rev !== 'string') {
throw new Error('client-modules: boot manifest rev must be a string')
}
if (!Array.isArray(graph.entries)) {
throw new Error('client-modules: boot manifest entries must be an array')
}
const modules: BootModuleRow[] = []
const plugins: BootPluginRow[] = []
for (const value of graph.entries as unknown[]) {
if (typeof value !== 'object' || value === null) {
throw new Error('client-modules: boot manifest entry is not an object')
}
const row = value as Record<string, unknown>
const where = typeof row.id === 'string' ? `"${row.id}"` : JSON.stringify(row)
if (typeof row.id !== 'string' || typeof row.url !== 'string' || typeof row.rev !== 'string') {
throw new Error(`client-modules: boot manifest entry ${where} must carry string id/url/rev`)
}
if (row.inject !== undefined && (!Array.isArray(row.inject) || row.inject.some(i => typeof i !== 'string'))) {
throw new Error(`client-modules: boot manifest entry ${where} inject must be a string array`)
}
if (row.immediately !== undefined && typeof row.immediately !== 'boolean') {
throw new Error(`client-modules: boot manifest entry ${where} immediately must be a boolean`)
}
modules.push({ id: row.id, url: row.url, rev: row.rev })
plugins.push({
id: row.id,
inject: row.inject === undefined ? [] : [...row.inject as string[]],
immediately: row.immediately === true,
})
}
return { rev: graph.rev, modules, plugins }
}
/** The shape a client bundle hands to `window.__ModuleLoader__.load` (registration handoff, contract C6). */
export interface ClientPluginHandoff {
/** Plugin id (package name) — the registration key; must match the graph row being executed. */
id: string
/**
* Closure factory holding the whole bundle body: receives the synchronous
* require bound to the module table and returns the bundle's export
* surface. Runs once, at materialization.
*/
factory: (require: (spec: string) => unknown) => Record<string, unknown>
}
/** Window surface of the web boot protocol: the host-injected graph, the registration sink, and the kernel handoff slot. */
export interface DshWindow {
/** Host-composed entry graph, injected before the shell bundle runs; wire-boundary raw until {@link parseBootManifest}. */
__DSH_BOOT__?: unknown
/** Bundle registration sink; installed once per page by the {@link ClientModuleSystem} constructor (contract C6). */
__ModuleLoader__?: { load(handoff: ClientPluginHandoff): void }
/**
* Kernel handoff slot: the shell kernel stores the instance here right
* after construction (before cordis exists) so the `./client` wrapper
* plugin can provide it as `ctx.modules`. Missing slot at wrapper apply
* time = kernel sequencing bug, thrown loud.
*/
__DSH_MODULES__?: ClientModuleSystem
}
/** Per-module bookkeeping in {@link ClientModuleLoader.loadCache} (module-graph seam, flat today). */
export interface ClientModuleRecord {
/** Module id (entry name / package name). */
id: string
/** The materialized export surface (factory `module.exports`, or the shell module for static registrations). */
surface: unknown
/** Owned `<style data-plugin>` tag ids (`data-plugin-css` values) injected during materialization. */
styles: string[]
/** Observed `require()` edges (module-graph seam; only table words can appear today). */
edges: Set<string>
}
/**
* The internal-seam subset the vendored Loader and the client HMR plugin
* consume. Mounted on `ctx.loader.internal` by the shell boot and provided
* as `ctx.modules` (contract C5).
*/
export interface ClientModuleLoader {
/** Discriminant against Node's internal loader shapes ('v1'/'v2'). */
version: 'client'
/** Materialized-module registry: id → record. The governance-side read face for entry export surfaces. */
loadCache: Map<string, ClientModuleRecord>
/**
* Internal seam consumed by the vendored Loader's `tree.import`. Resolves
* `specifier` through the branch order documented on the module, fetching
* and executing a bundle when needed.
* @param specifier - module specifier (entry name or table word).
* @param parentURL - importer URL (unused — the client module graph is flat).
* @param attrs - import attributes (unused; interface parity with Node's seam).
* @returns the module's export surface.
*/
import(specifier: string, parentURL: string, attrs: Record<string, unknown>): Promise<unknown>
/**
* Register a shell-own module (app-shell — code that ships inside the shell
* bundle and never arrives as a plugin bundle).
* @param id - entry name (shell-owned pseudo id).
* @param module - the statically imported module namespace.
*/
registerStatic(id: string, module: unknown): void
/**
* Stage-one arrival: fetch the entry's bundle and execute it, registering
* its factory (no materialization — module side effects wait for import).
* No-op for static-registered ids and ids whose factory is already
* registered; concurrent calls share one in-flight task. To force a fresh
* fetch (HMR), {@link invalidate} first.
* @param id - graph entry name.
*/
prefetch(id: string): Promise<void>
/**
* Full reset of one module: drop its registered factory, its materialized
* record, and any consumed bundle text, so the next prefetch/import
* refetches and re-executes (the HMR invalidation hook).
* @param id - entry name to invalidate.
*/
invalidate(id: string): void
}
/** Options for {@link ClientModuleSystem} (assembled by the web shell kernel at boot). */
export interface ClientModuleSystemOptions {
/** Boot rows in the module-table view (from {@link parseBootManifest}). */
modules: BootModuleRow[]
/** Module-table seed: platform-singleton specifier → shell instance. */
staticModules: Record<string, unknown>
/** Bundle fetch seam (parallelizable half). Defaults to same-origin fetch().text(). */
fetchBundle?: (url: string) => Promise<string>
/**
* Bundle execution seam (synchronously performs the load() registration).
* Defaults to a <script> element carrying the code.
*/
executeBundle?: (code: string, url: string) => void
}

View File

@@ -1,13 +1,13 @@
/**
* ClientModuleLoaderImpl the implementation behind the {@link ClientModuleLoader}
* ClientModuleSystem the implementation behind the {@link ClientModuleLoader}
* seam. The conceptual contract (lazy CJS model, resolution branch order) is
* documented on the package module and the public interfaces in `./index.ts`;
* this file owns the state tables and the fetch/execute/materialize machinery.
* documented on the public interfaces in `./manifest.ts`; this file owns the
* state tables and the fetch/execute/materialize machinery.
*/
import type {
ClientModuleLoader, ClientModuleLoaderOptions, ClientModuleRecord,
ClientPluginHandoff, DshWindow, WebBootEntry,
} from './index.ts'
BootModuleRow, ClientModuleLoader, ClientModuleRecord,
ClientModuleSystemOptions, ClientPluginHandoff, DshWindow,
} from './manifest.ts'
/** A registered-but-unmaterialized bundle: the factory plus its source URL (diagnostics). */
interface RegisteredFactory {
@@ -35,13 +35,6 @@ const defaultExecuteBundle = (code: string, url: string): void => {
el.remove()
}
const urlOf = (row: WebBootEntry): string => {
// url is conditional on the wire (shell-own pseudo rows omit it); those
// ids resolve through the static registry and never reach a fetch.
if (row.url === undefined) throw new Error(`client-modules: entry "${row.id}" has no bundle url and no static registration`)
return row.url
}
/**
* A plugin bundle IS its package's client half: `<id>/client` (the exports
* subpath external bundles emit) and the bare graph id name the same
@@ -70,10 +63,10 @@ const claimStyles = (id: string): string[] => {
/**
* The client module system: state tables plus the arrival/materialization
* machinery implementing {@link ClientModuleLoader} (whose members carry the
* seam contract docs). Construction indexes the boot graph and installs the
* seam contract docs). Construction indexes the boot rows and installs the
* `window.__ModuleLoader__` registration sink (contract C6) once per page.
*/
export class ClientModuleLoaderImpl implements ClientModuleLoader {
export class ClientModuleSystem implements ClientModuleLoader {
readonly version = 'client'
readonly loadCache = new Map<string, ClientModuleRecord>()
@@ -84,7 +77,7 @@ export class ClientModuleLoaderImpl implements ClientModuleLoader {
private readonly pendingArrival = new Map<string, Promise<void>>()
/** Materialization re-entrancy guard: factory-form CJS cannot deliver partial exports, so a cycle is fatal. */
private readonly materializing = new Set<string>()
private readonly graphRows = new Map<string, WebBootEntry>()
private readonly graphRows = new Map<string, BootModuleRow>()
// Execution URL of the bundle currently being executed (bound into the
// factory registration so diagnostics can name the source).
private executingUrl = ''
@@ -97,17 +90,17 @@ export class ClientModuleLoaderImpl implements ClientModuleLoader {
private readonly executeBundle: (code: string, url: string) => void
/**
* Build the module system over the host graph.
* @param options - entry graph, module-table staticModules, fetch/execute seams.
* Build the module system over the parsed boot rows.
* @param options - module rows, module-table staticModules, fetch/execute seams.
*/
constructor(options: ClientModuleLoaderOptions) {
constructor(options: ClientModuleSystemOptions) {
this.seed = new Map(Object.entries(options.staticModules))
this.fetchBundle = options.fetchBundle ?? defaultFetchBundle
this.executeBundle = options.executeBundle ?? defaultExecuteBundle
for (const entry of options.graph.entries) {
if (this.graphRows.has(entry.id)) throw new Error(`client-modules: duplicate graph entry "${entry.id}"`)
this.graphRows.set(entry.id, entry)
for (const row of options.modules) {
if (this.graphRows.has(row.id)) throw new Error(`client-modules: duplicate graph entry "${row.id}"`)
this.graphRows.set(row.id, row)
}
const win = globalThis as DshWindow
@@ -129,13 +122,12 @@ export class ClientModuleLoaderImpl implements ClientModuleLoader {
}
/** Fetch + execute one graph row so its factory is registered (idempotent per in-flight arrival). */
private arrive(row: WebBootEntry): Promise<void> {
const { id } = row
private arrive(row: BootModuleRow): Promise<void> {
const { id, url } = row
const pending = this.pendingArrival.get(id)
if (pending !== undefined) return pending
if (this.factories.has(id)) return Promise.resolve()
const task = (async (): Promise<void> => {
const url = urlOf(row)
const code = await this.fetchBundle(url)
this.executingUrl = url
this.executingId = id

View File

@@ -1,175 +1,393 @@
/**
* Client module system: the browser peer of Node's internal ESM loader, built
* as a lazy CJS table. The vendored cordis Loader consumes this object
* through its `internal` seam (the only call site is `EntryTree.import` →
* `internal.import`), which keeps entry governance (fiber lifecycle, inject
* waiting, update/refresh) entirely on the vendored side while this package
* owns code arrival.
* Node half of the client module system (dshClient dual-face package): scans
* the host Loader's entries for `dshClient` packages, composes the
* `window.__DSH_BOOT__` entry graph (wire single source: {@link WebBootEntry}
* in `./client/manifest.ts`), serves `/plugins/<id>/client.js`, taps the
* index render to inject the boot manifest, and provides the
* `clientModuleHost` service (the HMR node half's registration/notification
* face).
*
* Lazy CJS model (web2 §0): executing a plugin bundle only REGISTERS its
* factory (`window.__ModuleLoader__.load({id, factory})`); every module body
* side effect — including CSS injection — lives inside the factory closure
* and runs at materialization, not at script execution. Materialization
* (factory(require) → export surface) happens on first import/require and is
* memoized in {@link ClientModuleLoader.loadCache}; a factory that requires
* another registered-but-unmaterialized module materializes it recursively,
* so load order needs no external sequencing.
*
* Resolution branch order (import): seed word → shell instance; memoized
* record → surface; static registry (shell-own modules, e.g. app-shell) →
* module; registered factory → materialize; graph row → fetch + execute +
* materialize; anything else → throw (loud — the runtime mirror of the
* build-time bundle purity gate). The synchronous `require` handed to
* factories walks the same order minus the fetch branch: fetching is async,
* so only already-executed bundles can be required — and cross-plugin value
* imports are a build error anyway.
* Scanning is incremental per package — there is no full-rescan code path.
* Every cordis `internal/plugin` emission (fiber construction/disposal) marks
* the fiber's entry name dirty; a microtask flush reconciles each dirty name
* against the live loader entries. The activation pass seeds the same dirty
* set with all current entries and flushes synchronously, so first scan and
* steady state share one implementation. Package metadata (including the
* negative "not a client package" verdict) is cached per name and never
* expires — plugin-set changes take effect on restart per the config-source
* ruling; bundle content changes reach the graph only through
* {@link ClientModuleHostService.rebuilt}.
* @module @deepseek-ai/dsh-client-modules
*/
import { ClientModuleLoaderImpl } from './loader.ts'
import { createHash } from 'node:crypto'
import { readFileSync } from 'node:fs'
import { readFile } from 'node:fs/promises'
import type { IncomingMessage, ServerResponse } from 'node:http'
import { createRequire } from 'node:module'
import { dirname, join } from 'node:path'
import { Service } from 'cordis'
import type { Context } from 'cordis'
import type {} from '@cordisjs/plugin-loader'
import type {} from '@deepseek-ai/dsh-host-webserver'
import type { WebBootEntry, WebBootGraph } from './client/manifest.ts'
export { ClientModuleLoaderImpl }
export type {
BootManifest, BootModuleRow, BootPluginRow, WebBootEntry, WebBootGraph,
} from './client/manifest.ts'
declare module 'cordis' {
interface Context {
/** The client module system the web shell provides at boot (contract C5). */
modules: ClientModuleLoader
/** The web plugin table (provided by the client-modules node half). */
clientModuleHost: ClientModuleHostService
}
}
/** package.json `dshClient` declaration shape (file boundary — validated field by field). */
interface DshClientDeclaration {
inject?: string[]
platform: string
/** Boot phase-one prefetch mark; absent means lazy (fetched on demand). */
immediately?: boolean
}
/** Resolved package metadata for one dshClient package (cached per name, never expires). */
interface PkgMeta {
clientPath: string
inject?: string[]
immediately: boolean
}
/** One composed table row: the wire entry plus its bundle path. */
interface WebPluginRecord {
entry: WebBootEntry
clientPath: string
}
/** Narrow an unknown parsed JSON value to the dshClient declaration, throwing on malformed fields. */
function parseDshClient(pkgName: string, value: unknown): DshClientDeclaration | undefined {
if (value === undefined) return undefined
if (typeof value !== 'object' || value === null) {
throw new Error(`client-modules: ${pkgName} has a non-object dshClient declaration`)
}
const decl = value as Record<string, unknown>
if (typeof decl.platform !== 'string') {
throw new Error(`client-modules: ${pkgName} dshClient.platform must be a string`)
}
if (decl.inject !== undefined && (!Array.isArray(decl.inject) || decl.inject.some(i => typeof i !== 'string'))) {
throw new Error(`client-modules: ${pkgName} dshClient.inject must be a string array`)
}
if (decl.immediately !== undefined && typeof decl.immediately !== 'boolean') {
throw new Error(`client-modules: ${pkgName} dshClient.immediately must be a boolean`)
}
return {
platform: decl.platform,
...(decl.inject !== undefined ? { inject: decl.inject as string[] } : {}),
...(decl.immediately !== undefined ? { immediately: decl.immediately } : {}),
}
}
/** Resolve `exports["./client"]` to a relative path, accepting the string and one-level conditional forms. */
function clientExportOf(pkgName: string, exportsField: unknown): string | undefined {
if (typeof exportsField !== 'object' || exportsField === null) return undefined
const client = (exportsField as Record<string, unknown>)['./client']
if (client === undefined) return undefined
if (typeof client === 'string') return client
if (typeof client === 'object' && client !== null) {
const fallback = (client as Record<string, unknown>).default
if (typeof fallback === 'string') return fallback
}
throw new Error(`client-modules: ${pkgName} exports["./client"] has an unsupported shape`)
}
/** sha1 content hash shortened to 12 hex chars (bundle rev / graph rev). */
function shortHash(input: string | Buffer): string {
return createHash('sha1').update(input).digest('hex').slice(0, 12)
}
/** Graph row for one bundle rev (url carries the rev as its cache-busting query). */
function graphRow(id: string, rev: string, injectEdges: string[] | undefined, immediately: boolean): WebBootEntry {
return {
id,
url: `/plugins/${id}/client.js?rev=${rev}`,
rev,
...(injectEdges !== undefined ? { inject: injectEdges } : {}),
...(immediately ? { immediately: true } : {}),
}
}
/**
* One composed client entry pushed by the host (web2 §0 graph row).
* `immediately` marks stage-one prefetch; `inject` is informational graph
* metadata (the authoritative edges live in each package's dshClient
* declaration and reach fibers through entry creation).
*
* Wire contract, held on both sides: the producing peer lives in
* `@deepseek-ai/dsh-host-webserver` (host packages keep zero workspace
* dependencies, so neither side imports the other's shape — drift between
* the two declarations is a bug against the web2 contract).
* Inject the boot entry graph into index.html: `window.__DSH_BOOT__` as the
* first script in <head> (before the shell bundle reads it). `<` is escaped in
* the JSON so plugin-controlled strings cannot break out of the script element.
* @param html - the index.html source.
* @param graph - the composed entry graph.
* @returns the html with the graph script injected.
*/
export interface WebBootEntry {
/** Entry name == package name (or a shell-owned pseudo id, e.g. app-shell). */
id: string
/**
* Bundle endpoint, '/plugins/<id>/client.js?rev=<rev>'. Absent only on
* shell-owned pseudo rows (app-shell) whose module is statically registered
* — a row that is neither fetchable nor static-registered fails loud.
*/
url?: string
/** Bundle content hash (cache-busting consistency anchor); absent with url. */
rev?: string
/** Package-name dependency edges, informational (preflight display / HMR diffing). */
inject?: string[]
/** Stage-one prefetch mark: fetch + execute (factory registration) during module-face boot. */
immediately?: boolean
}
/** The composed client entry graph the host injects as `window.__DSH_BOOT__` (dual-held wire contract — see {@link WebBootEntry}). */
export interface WebBootGraph {
/** Consistency anchor over the whole graph (content + bundle hashes). */
rev: string
/** Composed entries; order carries no semantics (activation order is fiber inject waiting). */
entries: WebBootEntry[]
}
/** The shape a client bundle hands to `window.__ModuleLoader__.load` (registration handoff, contract C6). */
export interface ClientPluginHandoff {
/** Plugin id (package name) — the registration key; must match the graph row being executed. */
id: string
/**
* Closure factory holding the whole bundle body: receives the synchronous
* require bound to the module table and returns the bundle's export
* surface. Runs once, at materialization.
*/
factory: (require: (spec: string) => unknown) => Record<string, unknown>
}
/** Window surface this loader owns (bundle side of the handoff protocol) plus the host-injected graph. */
export interface DshWindow {
/** Host-composed entry graph, injected before the shell bundle runs. */
__DSH_BOOT__?: WebBootGraph
/** Bundle registration sink; installed once per page by {@link createClientModuleLoader} (contract C6). */
__ModuleLoader__?: { load(handoff: ClientPluginHandoff): void }
}
/** Per-module bookkeeping in {@link ClientModuleLoader.loadCache} (module-graph seam, flat today). */
export interface ClientModuleRecord {
/** Module id (entry name / package name). */
id: string
/** The materialized export surface (factory `module.exports`, or the shell module for static registrations). */
surface: unknown
/** Owned `<style data-plugin>` tag ids (`data-plugin-css` values) injected during materialization. */
styles: string[]
/** Observed `require()` edges (module-graph seam; only table words can appear today). */
edges: Set<string>
export function injectBootManifest(html: string, graph: WebBootGraph): string {
const json = JSON.stringify(graph).replaceAll('<', '\\u003c')
const script = `<script>window.__DSH_BOOT__ = ${json}</script>`
const head = html.indexOf('<head>')
if (head !== -1) return `${html.slice(0, head + 6)}${script}${html.slice(head + 6)}`
// Headless fixture pages may lack <head>; prepending keeps the read-before-shell ordering.
return `${script}${html}`
}
/**
* The internal-seam subset the vendored Loader and the client HMR plugin
* consume. Mounted on `ctx.loader.internal` by the shell boot and provided
* as `ctx.modules` (contract C5).
* The web plugin table service: incremental dshClient scan + wire composition
* + bundle route + index tap. Construction runs the activation scan
* synchronously — a malformed declaration or missing bundle among the
* already-loaded entries aggregates into one loud throw (FAILED fiber; the
* boot sweep reports it).
*/
export interface ClientModuleLoader {
/** Discriminant against Node's internal loader shapes ('v1'/'v2'). */
version: 'client'
/** Materialized-module registry: id → record. The governance-side read face for entry export surfaces. */
loadCache: Map<string, ClientModuleRecord>
export class ClientModuleHostService extends Service {
static inject = ['httpServer', 'loader']
private readonly table = new Map<string, WebPluginRecord>()
// Negative verdicts (unresolvable specifier — builtins like cordis:include,
// subpath rows — or a package without a web dshClient declaration) are
// cached as null and never expire: plugin-set changes take effect on restart.
private readonly pkgMeta = new Map<string, PkgMeta | null>()
private readonly rebuildListeners = new Set<(id: string, rev: string) => void>()
private readonly graphListeners = new Set<() => void>()
private readonly dirty = new Set<string>()
private readonly resolvePkgJson: (spec: string) => string
private flushQueued = false
private composed: WebBootGraph
/**
* Internal seam consumed by the vendored Loader's `tree.import`. Resolves
* `specifier` through the branch order documented on the module, fetching
* and executing a bundle when needed.
* @param specifier - module specifier (entry name or table word).
* @param parentURL - importer URL (unused — the client module graph is flat).
* @param attrs - import attributes (unused; interface parity with Node's seam).
* @returns the module's export surface.
* Build the service: subscribe, seed, and run the activation flush.
* @param ctx - plugin context carrying httpServer and loader.
*/
import(specifier: string, parentURL: string, attrs: Record<string, unknown>): Promise<unknown>
constructor(ctx: Context) {
super(ctx, 'clientModuleHost')
// Resolution anchor: the config tree's baseUrl (the cordis.yml directory,
// whose package declares every composed plugin as a dependency). The
// modules package's own URL would miss sibling packages under pnpm's
// isolated node_modules.
if (ctx.baseUrl === undefined) {
throw new Error('client-modules: ctx.baseUrl is unset — the node half needs the config-tree anchor to resolve plugin packages')
}
const require = createRequire(ctx.baseUrl)
this.resolvePkgJson = spec => require.resolve(`${spec}/package.json`)
// Subscribe before seeding so a fiber arriving mid-activation lands in the
// same dirty set (Set idempotence makes the overlap harmless). An entry-less
// fiber is a child plugin or a manual mount — never a loader row; O(1) drop.
ctx.on('internal/plugin', (fiber) => {
const entryName = fiber.entry?.options.name
if (entryName === undefined) return
this.dirty.add(entryName)
if (this.flushQueued) return
this.flushQueued = true
queueMicrotask(() => {
this.flushQueued = false
this.flush((err) => { ctx.logger.warn(err) })
})
})
// Activation pass: the initial scan IS the incremental path over the
// current entries, flushed synchronously (nothing async between subscribe,
// seed, and flush).
for (const entry of ctx.loader.entries()) this.dirty.add(entry.options.name)
this.composed = this.compose()
const failures: Error[] = []
this.flush(err => failures.push(err))
if (failures.length > 0) {
throw new AggregateError(
failures,
`client-modules: ${String(failures.length)} client package(s) failed to compose:\n${failures.map(e => ` - ${e.message}`).join('\n')}`,
)
}
ctx.effect(
() => ctx.httpServer.register({ kind: 'prefix', path: '/plugins', handler: this.serveBundle }),
'client-modules: bundle route',
)
ctx.effect(
() => ctx.httpServer.tapIndex(html => injectBootManifest(html, this.composed)),
'client-modules: boot manifest injection',
)
}
/**
* Register a shell-own module (app-shell — code that ships inside the shell
* bundle and never arrives as a plugin bundle).
* @param id - entry name (shell-owned pseudo id).
* @param module - the statically imported module namespace.
* Current composed entry graph (stable object between changes).
* @returns the graph served as `window.__DSH_BOOT__`.
*/
registerStatic(id: string, module: unknown): void
graph(): WebBootGraph {
return this.composed
}
/**
* Stage-one arrival: fetch the entry's bundle and execute it, registering
* its factory (no materialization — module side effects wait for import).
* No-op for static-registered ids and ids whose factory is already
* registered; concurrent calls share one in-flight task. To force a fresh
* fetch (HMR), {@link invalidate} first.
* @param id - graph entry name.
* Absolute path of an entry's client bundle.
* @param id - entry id (package name).
* @returns the path, or undefined for an unknown id.
*/
prefetch(id: string): Promise<void>
clientPath(id: string): string | undefined {
return this.table.get(id)?.clientPath
}
/**
* Full reset of one module: drop its registered factory, its materialized
* record, and any consumed bundle text, so the next prefetch/import
* refetches and re-executes (the HMR invalidation hook).
* @param id - entry name to invalidate.
* Re-hash one bundle (the HMR watch's registration hook — the only entry
* point through which bundle content changes reach the graph).
* @param id - entry id (package name).
* @returns the new rev, or undefined for an unknown id.
*/
invalidate(id: string): void
rebuilt(id: string): string | undefined {
const record = this.table.get(id)
if (record === undefined) return undefined
const rev = shortHash(readFileSync(record.clientPath))
if (rev === record.entry.rev) return rev
record.entry = graphRow(id, rev, record.entry.inject, record.entry.immediately === true)
this.composed = this.compose()
for (const notify of this.rebuildListeners) {
// Containment: rebuilt() runs inside the HMR watch callback — a
// throwing subscriber must not kill the poll or skip later subscribers.
try {
notify(id, rev)
} catch (error) {
this.ctx.logger.error(error)
}
}
this.notifyGraphChanged()
return rev
}
/**
* Subscribe to bundle rebuilds; fires only when the re-hash changed the rev.
* @param listener - receives the entry id and its new bundle rev.
* @returns the unsubscriber.
*/
onRebuilt(listener: (id: string, rev: string) => void): () => void {
this.rebuildListeners.add(listener)
return () => { this.rebuildListeners.delete(listener) }
}
/**
* Fires after any flush that recomposed the graph (row added/removed, or a
* rebuilt rev change). Pull model: listeners re-read {@link graph}.
* @param listener - notified with no payload.
* @returns the unsubscriber.
*/
onGraphChanged(listener: () => void): () => void {
this.graphListeners.add(listener)
return () => { this.graphListeners.delete(listener) }
}
private compose(): WebBootGraph {
const entries = [...this.table.values()].map(record => record.entry)
return { rev: shortHash(JSON.stringify(entries)), entries }
}
private notifyGraphChanged(): void {
for (const listener of this.graphListeners) {
// A throwing subscriber must not skip later subscribers (or escape into
// whatever triggered the flush — possibly an fs.watchFile callback).
try {
listener()
} catch (error) {
this.ctx.logger.error(error)
}
}
}
private resolveMeta(pkgName: string): PkgMeta | null {
const cached = this.pkgMeta.get(pkgName)
if (cached !== undefined) return cached
let pkgPath: string
try {
pkgPath = this.resolvePkgJson(pkgName)
} catch {
// Not a resolvable package root: loader builtins (cordis:include) and
// subpath entries (…/gateway) land here — permanently not a client row.
this.pkgMeta.set(pkgName, null)
return null
}
const pkg = JSON.parse(readFileSync(pkgPath, 'utf8')) as Record<string, unknown>
const decl = parseDshClient(pkgName, pkg.dshClient)
if (decl === undefined || decl.platform !== 'web') {
this.pkgMeta.set(pkgName, null)
return null
}
const clientRel = clientExportOf(pkgName, pkg.exports)
if (clientRel === undefined) {
throw new Error(`client-modules: ${pkgName} declares dshClient but exports no "./client" bundle`)
}
const meta: PkgMeta = {
clientPath: join(dirname(pkgPath), clientRel),
...(decl.inject !== undefined ? { inject: decl.inject } : {}),
immediately: decl.immediately === true,
}
this.pkgMeta.set(pkgName, meta)
return meta
}
/** Reconcile one entry name against the live loader entries. @returns whether the table changed. */
private processOne(entryName: string): boolean {
let qualifies = false
for (const entry of this.ctx.loader.entries()) {
if (entry.options.name === entryName && entry.fiber !== undefined && !entry.disabled) {
qualifies = true
break
}
}
if (!qualifies) return this.table.delete(entryName)
if (this.table.has(entryName)) return false
const meta = this.resolveMeta(entryName)
if (meta === null) return false
// The rev rides the row from here on: a fiber restart reuses the row (and
// its rev) untouched; only rebuilt() re-reads the bundle.
const rev = shortHash(readFileSync(meta.clientPath))
this.table.set(entryName, { entry: graphRow(entryName, rev, meta.inject, meta.immediately), clientPath: meta.clientPath })
return true
}
private flush(onError: (err: Error) => void): void {
let changed = false
for (const entryName of [...this.dirty]) {
this.dirty.delete(entryName)
try {
if (this.processOne(entryName)) changed = true
} catch (error) {
// Steady state: one broken package must not poison the others; the
// activation pass aggregates these into a loud throw instead.
onError(error instanceof Error ? error : new Error(String(error)))
}
}
if (changed) {
this.composed = this.compose()
this.notifyGraphChanged()
}
}
private readonly serveBundle = async (req: IncomingMessage, res: ServerResponse): Promise<void> => {
if (req.method !== 'GET' && req.method !== 'HEAD') {
res.writeHead(405)
res.end()
return
}
/* v8 ignore next -- `?? '/'` arm: node:http always sets url on server requests. */
const pathname = decodeURIComponent(new URL(req.url ?? '/', 'http://x').pathname)
// The id may contain a scope slash. Anything else under /plugins (including
// /plugins/events when the HMR row is absent) is an unknown resource.
const path = pathname.startsWith('/plugins/') && pathname.endsWith('/client.js')
? this.clientPath(pathname.slice('/plugins/'.length, -'/client.js'.length))
: undefined
if (path === undefined) {
res.writeHead(404)
res.end()
return
}
try {
const body = await readFile(path)
res.writeHead(200, { 'content-type': 'text/javascript; charset=utf-8', 'cache-control': 'no-cache' })
res.end(body)
} catch {
// Registered but unreadable (bundle not built yet): loud 404 beats a silent SPA-fallback HTML page.
res.writeHead(404)
res.end()
}
}
}
/** Options for {@link createClientModuleLoader} (assembled by the web shell at boot). */
export interface ClientModuleLoaderOptions {
/** Host-composed entry graph. */
graph: WebBootGraph
/** Module-table seed: platform-singleton specifier → shell instance. */
staticModules: Record<string, unknown>
/** Bundle fetch seam (parallelizable half). Defaults to same-origin fetch().text(). */
fetchBundle?: (url: string) => Promise<string>
/**
* Bundle execution seam (synchronously performs the load() registration).
* Defaults to a <script> element carrying the code.
*/
executeBundle?: (code: string, url: string) => void
}
/**
* Build the client module system.
* @param options - entry graph, module-table staticModules, fetch/execute seams.
* @returns the loader the shell mounts as `ctx.loader.internal` and provides as `ctx.modules`.
*/
export function createClientModuleLoader(options: ClientModuleLoaderOptions): ClientModuleLoader {
return new ClientModuleLoaderImpl(options)
}
export default ClientModuleHostService

View File

@@ -15,14 +15,25 @@ export const name = 'client-modules-invariant'
export const inject = ['invariants']
/**
* No runtime invariant: the module loader is pre-plugin kernel machinery —
* it emits no cordis events (the vendored Loader owns entry lifecycle events)
* and its mutable state (loadCache, handoff slot) lives below the plugin
* layer where invariant observers cannot mount before it runs; resolve branch
* order and handoff discipline are asserted by the web boot specs against the
* real execution path.
* Owned relation: the node half's boot entry graph must stay self-consistent
* — every row must resolve a clientPath under the same id (the
* /plugins/<id>/client.js URL it advertises would otherwise 404 on a browser
* that just received the graph). Checked on every scan trigger (cordis
* 'internal/plugin'): graph() and clientPath() read the same table object,
* so the relation holds at any instant — no need to wait out the node half's
* own microtask-debounced flush.
*/
const install: InvariantInstaller = () => {}
const install: InvariantInstaller = (ctx, fail) => {
ctx.on('internal/plugin', () => {
const host = ctx.get('clientModuleHost')
if (host === undefined) return // browser side / host without the node half: nothing to audit
for (const row of host.graph().entries) {
if (host.clientPath(row.id) === undefined) {
fail(`web plugin graph row "${row.id}" advertises ${row.url} but resolves no client bundle path — the served __DSH_BOOT__ would 404 on fetch`)
}
}
}, { global: true })
}
/**
* Register this package's invariant companion.

View File

@@ -1,6 +1,6 @@
// @vitest-environment jsdom
/**
* ClientModuleLoaderImpl behavior: lazy CJS arrival (bundle execution only
* ClientModuleSystem behavior: lazy CJS arrival (bundle execution only
* registers the factory), materialization on first import/require with
* memoization and recursive self-sequencing, the resolution branch order,
* shared in-flight arrival, invalidate-refetch (HMR), style claiming, the
@@ -9,9 +9,9 @@
*/
import { afterEach, describe, expect, it, vi } from 'vitest'
import {
ClientModuleLoaderImpl, createClientModuleLoader,
type ClientModuleLoader, type ClientPluginHandoff, type DshWindow, type WebBootEntry,
} from '../src/index.ts'
ClientModuleSystem,
type BootModuleRow, type ClientModuleLoader, type ClientPluginHandoff, type DshWindow,
} from '../src/client/index.ts'
const win = globalThis as DshWindow
@@ -24,7 +24,7 @@ afterEach(() => {
for (const el of document.querySelectorAll('style, script')) el.remove()
})
const row = (id: string): WebBootEntry => ({ id, url: `/plugins/${id}/client.js?rev=0` })
const row = (id: string): BootModuleRow => ({ id, url: `/plugins/${id}/client.js?rev=0`, rev: '0' })
interface Bench {
loader: ClientModuleLoader
@@ -38,14 +38,14 @@ interface Bench {
* through the window sink (`null` scripts a bundle that never calls load).
*/
function bench(
entries: WebBootEntry[],
entries: BootModuleRow[],
bundles: Record<string, Factory | null> = {},
opts: { seed?: Record<string, unknown>; gated?: string[] } = {},
): Bench {
const fetched: string[] = []
const gates = new Map<string, () => void>()
const loader = createClientModuleLoader({
graph: { rev: 'test', entries },
const loader = new ClientModuleSystem({
modules: entries,
staticModules: opts.seed ?? {},
fetchBundle: (url) => {
fetched.push(url)
@@ -175,7 +175,7 @@ describe('require resolution', () => {
describe('static registry', () => {
it('serves shell-own modules to import and require without any fetch', async () => {
const shell = { marker: 'app-shell' }
const b = bench([row('a'), { id: 'app-shell' }], {
const b = bench([row('a')], {
a: req => ({ dep: req('app-shell') }),
})
b.loader.registerStatic('app-shell', shell)
@@ -216,18 +216,13 @@ describe('failure modes', () => {
await expect(b.loader.prefetch('nope')).rejects.toThrow('prefetch("nope") — not a graph entry')
})
it('a graph row with no url and no static registration is loud', async () => {
const b = bench([{ id: 'ghost' }])
await expect(b.loader.import('ghost', '', {})).rejects.toThrow('no bundle url and no static registration')
})
it('a duplicate graph entry is loud at construction', () => {
expect(() => bench([row('a'), row('a')])).toThrow('duplicate graph entry "a"')
})
it('double boot is loud', () => {
bench([])
expect(() => new ClientModuleLoaderImpl({ graph: { rev: 't', entries: [] }, staticModules: {} }))
expect(() => new ClientModuleSystem({ modules: [], staticModules: {} }))
.toThrow('already installed (double boot?)')
})
})
@@ -289,7 +284,7 @@ describe('default transport seams', () => {
const code = 'window.__ModuleLoader__ = document.__realmBridge;\n'
+ 'window.__ModuleLoader__.load({ id: "dee", factory: function () { return { marker: "via-script" } } })'
vi.stubGlobal('fetch', async () => ({ ok: true, text: async () => code }))
const loader = createClientModuleLoader({ graph: { rev: 't', entries: [row('dee')] }, staticModules: {} })
const loader: ClientModuleLoader = new ClientModuleSystem({ modules: [row('dee')], staticModules: {} })
;(document as unknown as Record<string, unknown>).__realmBridge = win.__ModuleLoader__
const surface = await loader.import('dee', '', {})
expect((surface as { marker: string }).marker).toBe('via-script')
@@ -300,7 +295,7 @@ describe('default transport seams', () => {
it('a non-ok bundle response is loud with the status', async () => {
vi.stubGlobal('fetch', async () => ({ ok: false, status: 404 }))
const loader = createClientModuleLoader({ graph: { rev: 't', entries: [row('dee')] }, staticModules: {} })
const loader = new ClientModuleSystem({ modules: [row('dee')], staticModules: {} })
await expect(loader.prefetch('dee')).rejects.toThrow('answered 404')
})
})

View File

@@ -3,22 +3,14 @@
"compilerOptions": {
"rootDir": "src",
"outDir": "lib/types",
"lib": [
"ES2024",
"DOM",
"DOM.Iterable"
],
"types": []
"lib": ["ES2024", "DOM", "DOM.Iterable"],
"types": ["node"]
},
"include": [
"src"
],
"include": ["src"],
"references": [
{
"path": "../../../vendor/cordis"
},
{
"path": "../../support/invariants"
}
{ "path": "../../../vendor/cordis" },
{ "path": "../../../vendor/loader" },
{ "path": "../../host/webserver" },
{ "path": "../../support/invariants" }
]
}

View File

@@ -0,0 +1,3 @@
import { clientBundle } from '../tsdown.client.ts'
export default clientBundle('@deepseek-ai/dsh-client-modules', ['lib/types/index.js', 'lib/types/invariant.js'])

View File

@@ -0,0 +1,6 @@
# Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each
# side as of the last confirmed-consistent state. Both languages carry equal authority;
# after editing either side, bring the other along and re-record with:
# pnpm run verify-translation-pairing --write
README.md: 4724ebc75d441252245a0e811a4ae34f8b529a98
README.zh.md: 6a0076742efccaf946910c77c77a9b74194b9dc5

View File

@@ -1,6 +1,22 @@
# @deepseek-ai/dsh-client-runtime
Client cordis boot + core services: SlotsService (Service wrapper over SlotCore + 'slots/changed' bridge), SessionsService (list store projection, scope tree, bindings, ancestry), the Session object layer (exported as a type; instances are owned and handed out by SessionsService — the manager/paging internals stay package-internal, tests reach them via src), ClientLoader (`./loader` subpath, statically held by the shell). Contract: api-contracts v3 §4.
English | [中文](README.zh.md)
Client cordis boot and React-free object services: SlotsService wraps SlotCore and supplies renderer data sources; SessionsService owns Session objects, list/scope/history state; WorkspacesService depends on SessionsService and owns Workspace objects, list/actions, default-target derivation, and the New Session blank-reuse entry (`connectWorkspace`). The runtime fans the shared Host stream into both managers. Client sessions are always Host-born (Session+Agent+cwd in one `session.create`); the client holds no pre-entity session state — a session's Agent scope (the client mirror of host dsh-scope, keyed by the shared agent/session id) is born when its row enters the list mirror and dies with the prune. Contract: api-contracts v3 §4.
## Workspace and Session lists
Workspace and Session lists have independent monotone `pending``ready` baseline phases and separate refresh activity/error state. Incremental frames arriving during a list request replay over its response. The first successful baseline establishes Host order; later refreshes update rows and membership without changing the relative order of identities already shown. Workspace recency is derived only after both baselines are ready and never changes Workspace list order.
SlotsService gives the renderer separate bare observables for `useSessions` and `useWorkspaces`; web-react creates the hooks. Workspace business state does not enter `SessionListState` or an entry store.
## New Session and the blank mirror
`WorkspacesService.connectWorkspace(workspaceId)` resolves the session a New Session flow lands in: it reuses the workspace's existing blank session from the list mirror (`blank && cwd == workspace.path`) or calls `session.create({workspaceId})`, returning the session id for the caller to open. `SessionSummary.blank` mirrors the host's derived empty-log bit and only ever lowers on the client: seeded by `session.list` / the `host/session-added` frame, flipped false by the first ACCEPTED local `prompt()` (on the RPC success response — acceptance proves the user message is in the host log; a rejected first prompt keeps the session blank and reusable) and by any `running: true` status frame, re-aligned by every list re-pull. List surfaces hide blank rows; the store carries every row. `SessionsService.create` accepts an optional caller-preallocated SessionId and throws `SessionCreateError` (carrying `requestedSessionId`) on failure.
## Code Mode sub-dispatch index
`ConversationSnapshot.codeDispatches` groups a `run_code` call's sub-dispatches under their parent callId, in start order, using the native call-block shapes: a `tool/code-dispatch-start` event lands the `RunningToolCall` form (rows derive the running ring from the shape) and its `tool/code-dispatch` settlement replaces it in place with the `ToolResultNode` form, `callTime` carrying the paired start's time. A settle whose start fell outside the replay window appends directly with `callTime: null` (duration unknown — never a fabricated zero). Live mux frames and history replay build the identical index; sub-calls never join the surface `nodes` flow; per-parent array and map references are memo-stable across unrelated snapshot swaps.
## Session title projection
@@ -21,5 +37,5 @@ Changing the target can change or invalidate provider-side cache reuse; this pac
## Known Limitations and Deferred Work
- **`loader.unload` is a stub (throws not-implemented)** — the full chain (fiber dispose → registration cascade → style removal) lands with the HMR project.
- **Scope teardown is stage-driven, single-occupant today** — the staged session follows `list.current` exactly (staging is the open signal: the event window opens ⟺ the session is on stage); a removed-while-staged session's scope survives frozen until the stage moves on, not until true observer count reaches zero. Resolution (`cell()`/`binding()`/`scope()`) is pure addressing, render-safe. The staged state can widen to a multi-pane list when concurrent panes land.
- **Scope teardown is stage-driven, single-occupant today** — the staged session follows `list.current` exactly (staging is the open signal: the event window opens ⟺ the session is on stage); a removed-while-staged session's scope survives frozen until the stage moves on, not until true observer count reaches zero. Resolution (`provideInfo()`/`binding()`/`scope()`) is pure addressing, render-safe. The staged state can widen to a multi-pane list when concurrent panes land.
- **Value imports of this package from plugin bundles must use the `/client` subpath** — the bare package name is not in the loader externals table and inlines a second module instance, whose private scope-tag Symbol never matches (the empty-state P0 postmortem).

View File

@@ -0,0 +1,37 @@
# @deepseek-ai/dsh-client-runtime
[English](README.md) | 中文
客户端 cordis 启动与不依赖 React 的对象服务SlotsService 包装 SlotCore 并提供 renderer 数据源SessionsService 拥有 Session 对象、列表scopehistory 状态WorkspacesService 依赖 SessionsService拥有 Workspace 对象、列表/操作、默认目标派生,以及 New Session 空会话复用入口(`connectWorkspace`)。运行时把共享 Host 流分发给两个 manager。客户端 Session 一律由 Host 出生(一次 `session.create` 同瞬产出 Session+Agent+cwd客户端不持有任何实体化之前的会话状态——Agent scopehost dsh-scope 的客户端镜像,以 agent/session 共用 id 为键)在会话行进入列表镜像时出生,随 prune 死亡。契约api-contracts v3 §4。
## Workspace 与 Session 列表
Workspace 和 Session 列表各自具有单调的 `pending``ready` 基线阶段,也有各自的刷新活动/错误状态。列表请求期间到达的增量帧会在其响应之上回放。第一次成功的基线建立 Host 顺序后续刷新更新行和成员关系但不改变已经显示的标识之间的相对顺序。Workspace 新近程度只在两条基线都 ready 后派生,且绝不改变 Workspace 列表顺序。
SlotsService 分别为 renderer 提供 `useSessions``useWorkspaces` 的裸 observableweb-react 创建 hook。Workspace 业务状态不会进入 `SessionListState` 或配置项 store。
## New Session 与 blank 镜像
`WorkspacesService.connectWorkspace(workspaceId)` 解析 New Session 流程最终落入的会话:先在列表镜像中复用该 workspace 的既有空会话(`blank && cwd == workspace.path`),未命中则调用 `session.create({workspaceId})`,返回会话 id 由调用方 open。`SessionSummary.blank` 镜像主机派生的空日志位,在客户端只降不升:由 `session.list``host/session-added` 帧播种,本地首次**受理成功**的 `prompt()`RPC 成功响应时——受理即证明用户消息已入主机日志;首讯被拒则会话保持 blank、保持可复用与任何 `running: true` 状态帧翻为 false每次列表重拉重新对齐。列表表面隐藏 blank 行store 保留全部行。`SessionsService.create` 接受可选的、由调用方预先分配的 SessionId失败时抛出 `SessionCreateError`(携带 `requestedSessionId`)。
## Code Mode 子调用索引
`ConversationSnapshot.codeDispatches` 按父调用的 callId 和启动顺序,用原生调用块形状组织一个 `run_code` 调用的子调用:`tool/code-dispatch-start` 事件落成 `RunningToolCall` 形状(行组件从该形状推导运行中的转圈状态),其 `tool/code-dispatch` 完结事件原位替换为 `ToolResultNode` 形状,`callTime` 携带成对 start 事件的时间。start 落在回放窗口之外的完结事件则直接追加,`callTime: null`耗时未知——绝不伪造零耗时。live mux 帧与历史回放构建相同的索引;子调用永不进入 surface `nodes` 流;无关快照交换不会改变每个父调用对应的数组引用和映射引用,两者均保持 memo 稳定。
## Session 标题投影
`SessionManager` 独立于列表和 Session 实例到达情况,保留最近一次通过验证的 `session/title` 控制快照。seq 更新的事件会替换旧快照,标题时间戳计入列表新近程度;订阅基线会先丢弃 seq 超过其 `lastSeq` 的任何已保留标题,再接收可选的折叠标题。显式移除 Session 也会清除已保留标题。因此,面向客户端的 `SessionSummary.title` 只包含真实的持久标题;`displayTitle` 始终存在,并依次回退到 cwd basename 和 Session id。冷启动的持久会话会保持该回退值直到打开或恢复会话促使主机折叠并投影日志支持的标题。
## 模型体验
无。客户端运行时承载浏览器侧服务与 Session 对象层;这里没有任何内容进入模型请求。
#### KV Cache 影响
无;该包既不组装也不发送提供方请求。
## 已知限制与暂缓事项
- **`loader.unload` 是 stub抛出 not-implemented**完整链路fiber 释放 → 注册级联 → 样式移除)随 HMR 项目落地。
- **scope 拆卸由阶段驱动,目前只能有一个占用者**:已 staged 的 Session 精确跟随 `list.current`staging 就是打开信号:事件窗口打开 ⟺ Session 位于 stage在 staged 状态下被移除的 Session其 scope 会冻结保留,直到 stage 转向其他 Session而非直到真实观察者数量降为零。解析`provideInfo()``binding()``scope()`)只是纯寻址,可安全用于渲染。并发 pane 落地时staged 状态可以扩展为多 pane 列表。
- **插件组合包从该包执行值导入时必须使用 `/client` 子路径**:裸包名不在 loader external 表中,会内联第二个模块实例;其私有 scope-tag Symbol 永远无法匹配(空状态 P0 事故复盘)。

View File

@@ -0,0 +1,70 @@
/**
* Client Agent-scope primitive: mint a Cordis context tagged with the owning
* Agent's identity. The mechanism mirrors the host `dsh-scope` architecture
* (no-op plugin fiber + context tag + `Context.filter` routing predicate);
* the shape deliberately diverges: the filter lives on the actx itself
* instead of a separate carrier object, so scoped dispatch is plain cordis —
* `actx.bail(actx, event, payload)` / `actx.emit(actx, ...)` — with no
* wrapper. The host needs a detached carrier because its dispatch subject is
* the business Agent object; client scope events carry only ids, so the
* actx is the natural subject. The second divergence stands: the scope key
* is the branded `SessionId` (value compared), not an object identity — the
* agent and its session share one id (1:1, same axis; no separate AgentId
* brand), and a client scope's identity IS that wire id. Third divergence,
* deliberate: the client scopes the Agent IDENTITY, not a live Agent object
* — 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 type { SessionId } from '@deepseek-ai/dsh-client-connection/client'
/** Context tag written by {@link createScope}. */
const kScope = Symbol('dsh.client.scope')
/** A minted Agent scope and its disposal boundary. */
export interface AgentScopeHandle {
/**
* Tagged context: scope-owned registrations and scoped dispatch both go
* through it (passing it as the dispatch subject routes to this agent's
* tagged listeners plus every untagged one).
*/
ctx: Context
/** Backing fiber (dispose tears down every scope-owned registration). */
fiber: Fiber
}
/** Shared no-op plugin backing each Agent scope fiber. */
function agentScope(): void {}
/**
* Mint an Agent scope under `ctx`: a no-op plugin fiber whose context
* carries the agent tag and the dispatch filter — untagged listeners are
* admitted globally, tagged listeners only for a matching agent.
* Registrations through the returned ctx dispose with the fiber.
* @param ctx - client root context the scope fiber mounts under.
* @param key - owning agent identity (the routing tag; agent id === session id).
* @returns the tagged context and its backing fiber.
*/
export function createScope(ctx: Context, key: SessionId): AgentScopeHandle {
const fiber = ctx.plugin(agentScope)
return {
fiber,
ctx: fiber.ctx.extend({
[kScope]: key,
[CordisContext.filter](listenerCtx: Context): boolean {
const tag = scopeOf(listenerCtx)
return tag === undefined || tag === key
},
}),
}
}
/**
* Read the nearest agent tag inherited by a context.
* @param ctx - any client context.
* @returns its agent identity (the session id), or undefined for root contexts.
*/
export function scopeOf(ctx: Context): SessionId | undefined {
return (ctx as Context & { [kScope]?: SessionId })[kScope]
}

View File

@@ -161,11 +161,7 @@ function deepFreeze(value: unknown): void {
}
}
// ---- defineStore shell (slot terminal design §4) ----
// The type authority is ui-slots' store family (create(scopeKey?) and
// clearPersisted() included); this module houses only the engine-backed
// implementation. The one engine-side widening left: instances expose the
// raw engine store for framework/test surfaces.
// ui-slots owns the contract; this module supplies the engine implementation.
/** A live engine instance: the contract instance plus the raw engine store. */
export interface EngineStoreInstance<T, A extends ActionsDecl<T>> extends StoreInstance<T, A> {

View File

@@ -1,55 +1,42 @@
/**
* Browser half: the whole runtime contract surface (api-contracts v3 §4) —
* SlotsService (declaration ledger + renderer seam + store axis, built-in
* 'root'), SessionsService (list store + current selection + scope tree +
* object layer), and the cordis Context/Events merges. apply mounts
* ctx.slots + ctx.sessions and wires the connection stream loop into the
* object layer. A static-arrival entry: the web shell bundles this module
* and mounts it through the host graph (module loading lives in
* @deepseek-ai/dsh-client-modules, entry governance in the vendored Loader).
*/
/** Browser runtime services for slots, sessions, workspaces, and connection-stream delivery. */
import type { Context } from 'cordis'
import type { ConnectionHandle, SessionId } from '@deepseek-ai/dsh-client-connection/client'
import type { SnapshotSelectorHook } from '@deepseek-ai/dsh-client-ui-slots'
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 { WorkspacesService } from './workspaces/service.ts'
import type { ConversationSnapshot, RunningToolCall, ToolResultNode } from './sessions/conversation.ts'
export { SlotsService } from './slots.ts'
// RootOwnerProps rides the 'root' SlotMap row (both migrated here from
// ui-layout: the framework slot is declared by the framework package).
export type { RootOwnerProps } from './slots.ts'
export { SessionsService, scopeOf } from './sessions/service.ts'
export { SessionCreateError, SessionsService, scopeOf, workspaceTitleOf } from './sessions/service.ts'
export { createScope } from './agents/scope.ts'
export type { AgentScopeHandle } from './agents/scope.ts'
export { WorkspacesService } from './workspaces/service.ts'
export type { Session } from './sessions/session.ts'
export type { SessionBinding, SessionListState, SessionSummary } from './sessions/service.ts'
// The snapshot-store engine lives here since the store migration (the data
// layer owns its substrate; web-react is React glue only). The './client'
// main export is the single serving door — no store subpath.
export type {
SessionBinding, SessionListState, SessionProvideContribution, SessionProvideDescriptor, SessionSummary,
} from './sessions/service.ts'
export type { SessionListPhase } from './sessions/manager.ts'
export type { WorkspaceListPhase } from './workspaces/manager.ts'
export type { WorkspaceListState } from './workspaces/service.ts'
export type { WorkspaceId, WorkspaceView } from '@deepseek-ai/dsh-client-connection/client'
// Runtime owns the snapshot store; web-react only binds it to React.
export { createSnapshotStore, defineStore, shallowEqual } from './contract/store.ts'
export type {
EngineStoreHandle, EngineStoreInstance, ObservableSnapshot, SnapshotStore,
} from './contract/store.ts'
export type {
AssistantBlock, AssistantMessageNode, ContextMessageNode, ConversationNode, ConversationSnapshot,
ModelSelectionSnapshot, ModelSelectionStatus, RunningToolCall, SteeringMessageNode,
ToolResultNode, UnknownSurfaceNode, UserMessageNode,
AssistantBlock, AssistantMessageNode, CodeSubCall, ComposerPhase, ContextMessageNode, ConversationNode,
ConversationSnapshot, QueuedMessage, RunningToolCall,
SteeringMessageNode, ToolResultNode, UnknownSurfaceNode, UserMessageNode,
} from './sessions/conversation.ts'
// PendingWait is a value export: tests construct fixture waits directly.
export { PendingWait } from './sessions/pending.ts'
export type { PendingInteraction, PendingKind, PendingPayloads } from './sessions/pending.ts'
export type { SessionId } from '@deepseek-ai/dsh-client-connection/client'
// ---- Narrowed aliases (the single narrowing point of the slot type chain:
// ui-slots/web-react stay generic and dependency-inverted; the client-tree
// concrete types live here, where their subjects live) ----
/**
* The client cordis context face: the base Context plus the service keys
* this package's declaration merge contributes (slots/sessions/loader) and
* every later plugin's merge. A plain alias — the merges land on Context
* itself inside the client program; the name marks intent at consumer seams.
*/
/** Client-side Cordis context after declaration merging. */
export type ClientContext = Context
/** The conversation-snapshot selector hook (ConvViewProps/ToolRowProps take this). */
@@ -69,15 +56,21 @@ declare module '@deepseek-ai/dsh-client-ui-slots' {
* every session-scope slot component receives these from the framework.
*/
interface SessionStandardProps {
/** Selector hook over this session's conversation snapshot. */
useSession: SnapshotSelectorHook<ConversationSnapshot>
/** The framework-resolved session id (owners never pass it). */
sessionId: SessionId
}
/** Global standard kit, real members: the session-list hook every slot component receives. */
/** Standard kit for slots that remain mounted while current session changes. */
interface SessionMaybeStandardProps {
useSession: MaybeSnapshotSelectorHook<ConversationSnapshot>
/** Current session id; absent in the no-session state. */
sessionId: SessionId | undefined
}
/** Props injected into every global slot component. */
interface GlobalStandardProps {
/** Selector hook over the session list snapshot (`current` included — the arbitrated selection seat). */
useSessions: SnapshotSelectorHook<SessionListState>
/** Selector hook over real Workspaces and their independent baseline lifecycle. */
useWorkspaces: SnapshotSelectorHook<import('./workspaces/service.ts').WorkspaceListState>
}
}
@@ -89,28 +82,57 @@ declare module 'cordis' {
* @param key - the mutated SlotMap key.
*/
'slots/changed'(key: string): void
/**
* The host command registry changed (host/commands-changed passthrough).
* Pure invalidation signal: subscribers refetch `command.list` in the
* background rather than diffing.
* @mode emit
*/
'commands/changed'(): void
/**
* A connection generation was (re-)established. Wire-derived caches must
* treat their state as stale and repull (commands directory; the queue
* mirrors reset themselves through the session resync path).
* @mode emit
*/
'connection/reset'(): void
}
interface Context {
slots: import('./slots.ts').SlotsService
sessions: import('./sessions/service.ts').SessionsService
workspaces: import('./workspaces/service.ts').WorkspacesService
}
}
/** Required services: the wire handle mounted by the connection plugin. */
export const inject = ['connection']
/**
* Client plugin body: mount slots + sessions, start the stream loop.
* @param ctx - client cordis context.
/** Mounts the browser runtime services and connection stream.
* @param ctx - Client Cordis context.
*/
export function apply(ctx: Context): void {
ctx.plugin(SlotsService)
const connection = ctx.get('connection') as ConnectionHandle
const sessions = new SessionsService(ctx, connection.api)
const workspaces = new WorkspacesService(ctx, connection.api, sessions)
ctx.effect(
() => workspaces.startInitialSelection(),
'runtime: initial Workspace selection',
)
const loop = connection.start({
onMuxEnvelope: (envelope) => { sessions.manager.handleMuxEnvelope(envelope) },
onHostEnvelope: (envelope) => { sessions.manager.handleHostEnvelope(envelope) },
onConnected: () => { sessions.manager.handleConnected() },
onMuxEnvelope: (envelope) => { sessions.handleMuxEnvelope(envelope) },
onHostEnvelope: (envelope) => {
sessions.handleHostEnvelope(envelope)
workspaces.handleHostEnvelope(envelope)
// Typed-event bridge: the session layer ignores registry frames (no
// session routing); consumers (command directory caches) subscribe on ctx.
if (envelope.payload.type === 'host/commands-changed') ctx.emit('commands/changed')
},
onConnected: () => {
sessions.handleConnected()
workspaces.handleConnected()
ctx.emit('connection/reset')
},
})
ctx.effect(() => () => { loop.stop() }, 'runtime: connection stream loop')
}

View File

@@ -0,0 +1,43 @@
/**
* Merge an authoritative baseline without moving identities already visible to
* the client. Baseline-only identities are inserted relative to the nearest
* following known identity; identities absent from the baseline are removed.
*
* @param current - the established client order.
* @param baseline - the latest authoritative rows.
* @param keyOf - stable identity selector.
* @returns baseline-valued rows with the established relative order retained.
*/
export function mergeOrderedBaseline<T>(
current: readonly T[],
baseline: readonly T[],
keyOf: (value: T) => unknown,
): T[] {
const baselineByKey = new Map<unknown, T>()
for (const value of baseline) baselineByKey.set(keyOf(value), value)
const merged = current
.map(value => baselineByKey.get(keyOf(value)))
.filter((value): value is T => value !== undefined)
const mergedKeys = new Set(merged.map(keyOf))
for (let index = 0; index < baseline.length; index++) {
const value = baseline[index]
/* v8 ignore next -- dense-array guard: index is bounded by baseline.length. */
if (value === undefined || mergedKeys.has(keyOf(value))) continue
let insertion = merged.length
for (let following = index + 1; following < baseline.length; following++) {
const candidate = baseline[following]
/* v8 ignore next -- dense-array guard: following is bounded by baseline.length. */
if (candidate === undefined) continue
const known = merged.findIndex(item => keyOf(item) === keyOf(candidate))
if (known !== -1) {
insertion = known
break
}
}
merged.splice(insertion, 0, value)
mergedKeys.add(keyOf(value))
}
return merged
}

View File

@@ -5,8 +5,7 @@
import type { ContentBlock } from '@deepseek-ai/dsh-llm/types'
import type {
ModelCatalogFailure, ModelProviderGroup, ModelTarget, RpcError, SessionId,
ToolCallView, ToolResultView,
RpcError, SessionId, ToolCallView, ToolResultView,
} from '@deepseek-ai/dsh-client-connection/client'
import type { PendingInteraction } from './pending.ts'
@@ -45,6 +44,8 @@ export function toAssistantBlock(block: ContentBlock): AssistantBlock {
export interface UserMessageNode {
kind: 'user'
seq: number
/** Unix epoch ms from the source session event. */
time: number
content: readonly ContentBlock[]
source: unknown
}
@@ -53,6 +54,8 @@ export interface UserMessageNode {
export interface AssistantMessageNode {
kind: 'assistant'
seq: number
/** Unix epoch ms from the source session event (or turn/end when frozen from a partial). */
time: number
turn: number
step: number
blocks: readonly AssistantBlock[]
@@ -66,6 +69,8 @@ export interface AssistantMessageNode {
export interface SteeringMessageNode {
kind: 'steering'
seq: number
/** Unix epoch ms from the source session event. */
time: number
turn: number
content: readonly ContentBlock[]
source: unknown
@@ -75,6 +80,8 @@ export interface SteeringMessageNode {
export interface ContextMessageNode {
kind: 'context'
seq: number
/** Unix epoch ms from the source session event. */
time: number
content: readonly ContentBlock[]
source: unknown
meta?: unknown
@@ -84,9 +91,13 @@ export interface ContextMessageNode {
export interface ToolResultNode {
kind: 'tool-result'
seq: number
/** Unix epoch ms from the tool/result session event. */
time: number
callId: string
/** Call head backfilled from the in-window tool/call; null when window truncation left the call outside (card head shows callId). */
call: { name: string; argsRaw: string } | null
/** Unix epoch ms of the paired tool/call when the call is still in-window; used for call-row duration. */
callTime: number | null
content: readonly ContentBlock[]
isError: boolean
error?: { name: string; code: string }
@@ -101,6 +112,8 @@ export interface ToolResultNode {
export interface UnknownSurfaceNode {
kind: 'unknown'
seq: number
/** Unix epoch ms from the source session event when known. */
time: number
type: string
data: unknown
}
@@ -114,6 +127,21 @@ export type ConversationNode =
| ToolResultNode
| UnknownSurfaceNode
/**
* One `run_code` sub-dispatch materialized in the native call-block shapes so
* every consumer (tool rows, details panel) renders it through the exact
* components that render a native call: a started-but-unsettled sub-call is a
* {@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
* {@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
* settled sub-call's complete logged outcome.
*/
export type CodeSubCall = RunningToolCall | ToolResultNode
/** In-flight tool card material: tool/call seen, tool/result not yet. */
export interface RunningToolCall {
callId: string
@@ -121,11 +149,19 @@ export interface RunningToolCall {
argsRaw: string
turn: number
step: number
/** Unix epoch ms when the tool/call event was logged. */
time: number
/** Host-computed render intent riding the tool/call frame; null = generic JSON card. */
callView: ToolCallView | null
}
/** One queued-message row mirrored from `session/queued` frames (key: the enqueueing prompt's rpcId when wire-sourced). */
export interface QueuedMessage {
readonly key: string
readonly preview: string
}
/** In-progress assistant output (chunk accumulator product). */
export interface PartialAssistant {
turn: number
@@ -136,29 +172,34 @@ export interface PartialAssistant {
/** History-open lifecycle of a Session window. */
export type OpenState = 'cold' | 'loading' | 'open' | 'error'
/**
* Input-area shape of an OPEN session, derived at snapshot assembly (the one
* place that knows the predicate — consumers switch, never re-derive):
*
* - `blank`: no activity ever (no nodes, no partial, not running, no pending
* waits, no prompt attempt) — the UI renders the blank-session guidance
* hero.
* - `engaging`: the first prompt was initiated but no content landed yet —
* the UI holds the composer through the accept → running → first-event
* frames. Entered synchronously before prompt()'s first await.
* - `active`: content exists (nodes, partial, running turn, or pending
* waits) — the ordinary conversation view.
*
* Monotone within a session object: blank → engaging → active, no returns.
* A failed first prompt stays `engaging` (composer + error strip — retry
* semantics; bouncing back to the hero would discard the error context).
* Sessions whose window is not open (`loading`/`error`) are outside phase
* jurisdiction: consumers branch on {@link ConversationSnapshot.openState}
* first (phase still reports `active`-ish facts but must not be rendered).
*/
export type ComposerPhase = 'blank' | 'engaging' | 'active'
/** Send/stop failure surfaced in the input error strip; op picks the user-facing copy (发送失败 vs 停止失败). */
export interface PromptError {
op: 'send' | 'stop'
error: RpcError
}
/** Lifecycle of the session-local model directory and selection requests. */
export type ModelSelectionStatus = 'idle' | 'loading' | 'ready' | 'selecting' | 'error'
/** Immutable model-selector state owned by the Session object layer. */
export interface ModelSelectionSnapshot {
/** Target selected for the next assembled step, or null before history opens. */
current: ModelTarget | null
/** Last successfully loaded provider groups. */
groups: readonly ModelProviderGroup[]
/** Provider-local failures from the last successful directory response. */
failures: readonly ModelCatalogFailure[]
/** Current directory or selection operation state. */
status: ModelSelectionStatus
/** Whole-request or selection failure; partial provider failures use {@link failures}. */
error: RpcError | null
}
/** The immutable snapshot contract Session hands to uSES (see the web client architecture RFC). */
export interface ConversationSnapshot {
sessionId: SessionId
@@ -168,8 +209,19 @@ export interface ConversationSnapshot {
foldDegraded: boolean
partial: PartialAssistant | null
runningCalls: readonly RunningToolCall[]
/**
* `run_code` sub-dispatches grouped under their parent callId, in dispatch
* order. Populated from in-window `tool/code-dispatch` events (live and
* replay identically); the per-parent array reference is stable across
* unrelated snapshot swaps (memo premise, same regime as `nodes`).
*/
codeDispatches: ReadonlyMap<string, readonly CodeSubCall[]>
pending: readonly PendingInteraction[]
/** Read-only inbox mirror (session/queued frames + mux-open baseline; cleared by the leave-running flip). */
queue: readonly QueuedMessage[]
running: boolean
/** Input-area shape (see {@link ComposerPhase}); derived here, switched on by consumers. */
composerPhase: ComposerPhase
/** Set after host/session-removed; the UI grays out and disables input. */
removed: boolean
openState: OpenState
@@ -177,7 +229,16 @@ export interface ConversationSnapshot {
hasMore: boolean
loadingOlder: boolean
promptError: PromptError | null
/**
* Whether this session still has an empty log (no user message yet).
* Mirrors the host summary's derived blank bit: seeded from `session.list`
* / the `host/session-added` frame, flipped false by the first ACCEPTED
* prompt locally (on the RPC success response — acceptance proves the
* user message is in the host log; a rejected first prompt keeps the
* session blank and reusable) and by any `running: true` status remotely,
* and re-aligned by every list re-pull (the summary stays authoritative).
* Blank sessions are hidden from session lists and reused by New Session.
*/
blank: boolean
lastAgentError: string | null
/** Session-local model target and advisory directory state. */
modelSelection: ModelSelectionSnapshot
}

View File

@@ -18,14 +18,16 @@ export interface CallIndexEntry {
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-eligible sentinel event (safely skipped by surfaceOpOf's undefined branch).
* 'noop/padding' is not a real event type on purpose: a genuine type with fake data would
* surface as garbage the day anyone adds handling for it (design §D.1; the cast is the one
* place a synthetic event enters the window). */
/** 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
}
@@ -38,24 +40,37 @@ function materializeNode(
): ConversationNode {
switch (event.type) {
case 'user/message':
return { kind: 'user', seq: event.seq, content: event.data.content, source: event.data.source }
// 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,
meta: event.data.meta,
}
}
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, turn: event.data.turn, step: event.data.step,
kind: 'assistant', seq: event.seq, time: event.time,
turn: event.data.turn, step: event.data.step,
blocks: toAssistantBlocks(event.data.content), usage: event.data.usage,
}
case 'steering/message':
return { kind: 'steering', seq: event.seq, turn: event.data.turn, content: event.data.content, source: event.data.source }
case 'context/message':
return {
kind: 'context', seq: event.seq, content: event.data.content, source: event.data.source,
meta: event.data.meta,
kind: 'steering', seq: event.seq, time: event.time, turn: event.data.turn,
content: event.data.content, source: event.data.source,
}
case 'tool/result': {
const call = callIndex.get(String(event.data.callId))
return {
kind: 'tool-result', seq: event.seq, callId: String(event.data.callId),
kind: 'tool-result', seq: event.seq, time: event.time,
callId: String(event.data.callId),
call: call ? { name: call.name, argsRaw: call.argsRaw } : null,
callTime: call?.time ?? null,
content: event.data.content, isError: event.data.isError,
...(event.data.error !== undefined ? { error: event.data.error } : {}),
meta: event.data.meta,
@@ -63,11 +78,14 @@ function materializeNode(
resultView,
}
}
/* v8 ignore next 2 -- defensive arm: fold output only carries the five
/* 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, type: event.type, data: (event as { data?: unknown }).data }
return {
kind: 'unknown', seq: event.seq, time: event.time,
type: event.type, data: (event as { data?: unknown }).data,
}
}
}
@@ -186,6 +204,7 @@ export class FoldAdapter {
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

View File

@@ -1,6 +1,6 @@
// flattenLineage: summaries -> flat list with lineage indentation (pure function).
// Roots sort by updatedAt desc, DFS expansion with children in the same order; orphaned lineage
// degrades to root level; cycles fail soft and emit as roots.
// The input order is authoritative; lineage only makes each child adjacent to its parent.
// Orphaned lineage degrades to root level; cycles fail soft and emit as roots.
import type { SessionId, SessionSummary } from '@deepseek-ai/dsh-client-connection/client'
@@ -15,6 +15,8 @@ export interface SessionListEntry {
title?: string
updatedAt: number
running: boolean
/** Empty-log bit mirrored from the summary; lists hide blank sessions (filtering stays with the consumer). */
blank: boolean
parentSessionId?: SessionId
cwd?: string
/** Lineage indent depth: root = 0; the UI just multiplies by the indent width. */
@@ -22,8 +24,9 @@ export interface SessionListEntry {
}
/**
* summaries -> flat list with lineage indentation (pure; roots by updatedAt
* desc, DFS children in the same order, orphans degrade to roots).
* Summaries -> flat list with lineage indentation. Root and sibling order
* follows the established input order; this projection never re-sorts a
* hydrated list from mutable timestamps.
* @param summaries - the host's session.list items.
* @returns display rows in render order.
*/
@@ -43,9 +46,6 @@ export function flattenLineage(summaries: readonly TitledSessionSummary[]): Sess
}
}
const byUpdatedDesc = (a: TitledSessionSummary, b: TitledSessionSummary): number => b.updatedAt - a.updatedAt
roots.sort(byUpdatedDesc)
const out: SessionListEntry[] = []
const visited = new Set<SessionId>()
const walk = (s: TitledSessionSummary, depth: number): void => {
@@ -57,7 +57,6 @@ export function flattenLineage(summaries: readonly TitledSessionSummary[]): Sess
out.push({ ...s, depth })
const kids = children.get(s.sessionId)
if (kids === undefined) return
kids.sort(byUpdatedDesc)
for (const kid of kids) walk(kid, depth + 1)
}
for (const root of roots) walk(root, 0)

View File

@@ -2,22 +2,44 @@
// 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 } 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'
import { mergeOrderedBaseline } from '../ordered-baseline.ts'
import type { SessionListEntry, TitledSessionSummary } from './lineage.ts'
import { flattenLineage } from './lineage.ts'
import { Notifier } from './notifier.ts'
import { Session } from './session.ts'
/**
* List arrival lifecycle, orthogonal to the pull-activity `state` axis:
* `pending` (no successful pull yet — an empty items array means "nothing
* arrived", not "nothing exists") → `ready` (at least one pull landed).
* Monotone: `ready` never steps back — later pull failures and reconnect
* re-pulls ride the `state`/`error` axis, which is where failure is modeled
* (no `error` phase here; that would duplicate `state`).
*/
export type SessionListPhase = 'pending' | 'ready'
/** Immutable session-list snapshot for useSessionList. */
export interface SessionListSnapshot {
items: readonly SessionListEntry[]
/** Selected Session id (validated against items; masked to undefined while its session is off the list). */
current: SessionId | undefined
state: 'idle' | 'loading' | 'error'
/** Arrival lifecycle (see {@link SessionListPhase}); `state` stays the pull-activity axis. */
phase: SessionListPhase
error: RpcError | null
}
type SessionListMutation =
| { kind: 'upsert'; summary: SessionSummary }
| { kind: 'remove'; sessionId: SessionId }
| { kind: 'status'; sessionId: SessionId; running: boolean }
/** Local first-send flip: the sender clears blank without waiting for a host frame. */
| { kind: 'engaged'; sessionId: SessionId }
/** Per-session cap for pre-instantiation approval/question buffering (low-frequency frames; a few dozen covers any real backlog). */
const PENDING_BUFFER_CAP = 32
@@ -39,8 +61,14 @@ export class SessionManager {
private readonly titleSnapshots = new Map<SessionId, SessionTitleSnapshot>()
private summaries: SessionSummary[] = []
private listState: 'idle' | 'loading' | 'error' = 'idle'
/** Arrival phase; the pending → ready edge fires on the first successful pull (see SessionListPhase). */
private listPhase: SessionListPhase = 'pending'
private listError: RpcError | null = null
private listInflight: Promise<void> | null = null
/** Mutations arriving after a list request starts are replayed over its response. */
private listMutations: SessionListMutation[] | null = null
private selected: SessionId | undefined
private listSnapshotCache: SessionListSnapshot
/** Entry-identity cache (§C.2 reference stability): list rebuilds reuse the previous entry
@@ -52,12 +80,50 @@ export class SessionManager {
this.listSnapshotCache = this.buildListSnapshot()
})
constructor(private readonly api: IApiClient) {
/**
* @param api - shared wire client.
* @param restoredSelection - persisted real-Session selection candidate.
*/
constructor(
private readonly api: IApiClient,
restoredSelection?: SessionId,
) {
this.selected = restoredSelection
this.listSnapshotCache = this.buildListSnapshot()
}
// ---- Selection ----
/**
* Select a listed Session.
* @param sessionId - listed Session id.
*/
select(sessionId: SessionId): void {
if (!this.summaries.some(summary => summary.sessionId === sessionId)) {
throw new Error(`sessions.select: unknown session ${sessionId}`)
}
this.selected = sessionId
this.notifier.notifyNow()
}
/** Clear the selection (the layout falls to the no-session view state). */
clearSelection(): void {
this.selected = undefined
this.notifier.notifyNow()
}
// ---- Instance management ----
/**
* Drop a session instance (scope-prune companion, decision 12: instance
* and scope share one lifecycle). The host session log is the durable
* truth — a later get() lazily rebuilds and open() backfills history.
* @param sessionId - the session to drop.
*/
drop(sessionId: SessionId): void {
this.sessions.delete(sessionId)
}
/**
* Lazy build: return the existing instance or construct one (no auto-open —
* open is triggered by the container's select callback).
@@ -67,21 +133,39 @@ export class SessionManager {
get(sessionId: SessionId): Session {
let session = this.sessions.get(sessionId)
if (session === undefined) {
session = new Session(sessionId, this.api)
session = this.createSession(sessionId)
this.sessions.set(sessionId, session)
// Sync the running bit from the list snapshot into the new instance (consistency when the list precedes open).
const summary = this.summaries.find(s => s.sessionId === sessionId)
if (summary !== undefined) session.handleRunning(summary.running)
// Replay approval/question frames buffered before instantiation (rpcId verbatim, same semantics as the subscribed baseline replay).
// Replay approval/question/queued frames buffered before instantiation (rpcId
// verbatim, same semantics as the subscribed baseline replay). Replay happens
// BEFORE the running-bit sync: a not-running summary must sweep replayed queue
// rows the same way a live status flip would (their retirement events dropped
// while the session was uninstantiated).
const buffered = this.pendingBuffers.get(sessionId)
if (buffered !== undefined) {
this.pendingBuffers.delete(sessionId)
for (const envelope of buffered) session.handleMuxEnvelope(envelope.rpcId, envelope.payload)
}
// Sync the running and blank bits from the list snapshot into the new
// instance (consistency when the list precedes open).
const summary = this.summaries.find(s => s.sessionId === sessionId)
if (summary !== undefined) {
session.handleBlank(summary.blank)
session.handleRunning(summary.running)
}
}
return session
}
private createSession(sessionId: SessionId): Session {
return new Session(sessionId, this.api, {
// The sender's local first-send flip mirrors into the list row so the
// session surfaces (lists filter on blank) before any host frame lands.
onEngaged: (engaged) => {
this.recordMutation({ kind: 'engaged', sessionId: engaged.sessionId })
},
})
}
// ---- List surface ----
/** Full refresh via session.list (single-flight: an in-flight call is reused). */
@@ -89,15 +173,28 @@ export class SessionManager {
if (this.listInflight !== null) return this.listInflight
this.listState = 'loading'
this.listError = null
const established = this.summaries
const mutations: SessionListMutation[] = []
this.listMutations = mutations
this.notifier.markDirty()
this.listInflight = (async () => {
try {
const { result } = await this.api.sessions.list({})
if (result.ok) {
this.summaries = result.value.items
let summaries = this.listPhase === 'pending'
? result.value.items
: mergeOrderedBaseline(established, result.value.items, summary => summary.sessionId)
for (const mutation of mutations) summaries = applyMutation(summaries, mutation)
this.summaries = summaries
this.listState = 'idle'
// Push running bits down to instantiated Sessions (the list is the authoritative summary source).
for (const s of this.summaries) this.sessions.get(s.sessionId)?.handleRunning(s.running)
this.listPhase = 'ready'
// Push running/blank bits down to instantiated Sessions (the list is the authoritative summary source).
for (const s of this.summaries) {
const session = this.sessions.get(s.sessionId)
if (session === undefined) continue
session.handleBlank(s.blank)
session.handleRunning(s.running)
}
} else {
this.listState = 'error'
this.listError = result.error
@@ -108,6 +205,7 @@ export class SessionManager {
/* v8 ignore next -- the `? null` arm is unreachable: transportError always returns ok:false. */
this.listError = folded.ok ? null : folded.error
} finally {
this.listMutations = null
this.listInflight = null
this.notifier.markDirty()
}
@@ -117,19 +215,38 @@ export class SessionManager {
/**
* Contract session.create; on success merge into summaries immediately (no
* wait for the next refresh).
* @param cwd - optional working directory for the new session.
* wait for the next refresh). A created session is blank by definition
* (entity birth precedes the first message).
* @param opts - target workspace or working directory, plus an optional caller-owned id.
* @returns the create result.
*/
async create(cwd?: string): Promise<RpcResult<{ sessionId: SessionId }>> {
async create(
opts: { workspaceId?: WorkspaceId; cwd?: string; sessionId?: SessionId } = {},
): Promise<RpcResult<{ sessionId: SessionId }>> {
try {
const { result } = await this.api.sessions.create(cwd === undefined ? {} : { cwd })
if (result.ok && !this.summaries.some(s => s.sessionId === result.value.sessionId)) {
this.summaries = [
{ sessionId: result.value.sessionId, updatedAt: Date.now(), running: false, ...(cwd !== undefined ? { cwd } : {}) },
...this.summaries,
]
this.notifier.markDirty()
const shared = opts.sessionId === undefined ? {} : { sessionId: opts.sessionId }
const payload = opts.workspaceId !== undefined
? { workspaceId: opts.workspaceId, ...shared }
: { ...(opts.cwd === undefined ? {} : { cwd: opts.cwd }), ...shared }
const { result } = await this.api.sessions.create(payload)
if (result.ok) {
this.recordMutation({ kind: 'upsert', summary: {
sessionId: result.value.sessionId, updatedAt: Date.now(), running: false, blank: true,
...(opts.cwd !== undefined ? { cwd: opts.cwd } : {}),
} })
} else {
const publishedSessionId = workspaceAttachSessionId(result.error)
// Publication precedes attachment. The error's id is a real Session,
// so expose it immediately as Ungrouped while the caller keeps the
// prompt buffer and decides whether to retry attachment.
if (publishedSessionId !== undefined) {
this.recordMutation({ kind: 'upsert', summary: {
sessionId: publishedSessionId,
updatedAt: Date.now(),
running: false,
blank: true,
} })
}
}
return result
} catch (error) {
@@ -137,6 +254,23 @@ export class SessionManager {
}
}
/**
* Insert-or-enrich a locally synthesized summary: a new id prepends; an
* existing entry only gains fields it lacks (the session-added frame and the
* create() echo race — whichever lands second must fill the placeholder's
* missing cwd/parentSessionId, never overwrite list-refresh data).
*/
private mergeSummary(summary: SessionSummary): void {
this.recordMutation({ kind: 'upsert', summary })
}
/** Apply immediately and retain for replay when a list response is in flight. */
private recordMutation(mutation: SessionListMutation): void {
this.listMutations?.push(mutation)
this.summaries = applyMutation(this.summaries, mutation)
this.notifier.markDirty()
}
// ---- Subscription surface (for useSessionList) ----
/**
@@ -185,16 +319,31 @@ export class SessionManager {
this.titleSnapshots.delete(frame.sessionId)
this.notifier.markDirty()
}
// New mux-generation baseline: buffered session/queued frames belong to
// the previous generation and the host is about to resend the live
// snapshot — drop them, or every reconnect appends a duplicate batch
// (and enough reconnects push real approval/question frames past the
// cap). Same re-baseline signal Session uses for its own mirror.
const buffered = this.pendingBuffers.get(frame.sessionId)
if (buffered !== undefined) {
const kept = buffered.filter(item => item.payload.type !== 'session/queued')
if (kept.length !== buffered.length) {
if (kept.length === 0) this.pendingBuffers.delete(frame.sessionId)
else this.pendingBuffers.set(frame.sessionId, kept)
}
}
}
const session = this.sessions.get(frame.sessionId)
if (session === undefined) {
// Approval/question frames never hit history: buffer for replay on instantiation;
// everything else drops (not instantiated — history fully backfills on open).
// Approval/question/queued frames never hit history: buffer for replay on
// instantiation; everything else drops (not instantiated — history fully
// backfills on open).
switch (frame.type) {
case 'approval/requested':
case 'approval/resolved':
case 'question/requested':
case 'question/resolved': {
case 'question/resolved':
case 'session/queued': {
const buffer = this.pendingBuffers.get(frame.sessionId) ?? []
buffer.push(envelope)
if (buffer.length > PENDING_BUFFER_CAP) buffer.splice(0, buffer.length - PENDING_BUFFER_CAP)
@@ -216,31 +365,24 @@ export class SessionManager {
const frame = envelope.payload
switch (frame.type) {
case 'host/session-added': {
if (!this.summaries.some(s => s.sessionId === frame.sessionId)) {
this.summaries = [
{
sessionId: frame.sessionId, updatedAt: Date.now(), running: false,
...(frame.parentSessionId !== undefined ? { parentSessionId: frame.parentSessionId } : {}),
},
...this.summaries,
]
this.notifier.markDirty()
}
this.mergeSummary({
sessionId: frame.sessionId, updatedAt: Date.now(), running: false, blank: frame.blank,
...(frame.parentSessionId !== undefined ? { parentSessionId: frame.parentSessionId } : {}),
...(frame.cwd !== undefined ? { cwd: frame.cwd } : {}),
})
this.sessions.get(frame.sessionId)?.handleBlank(frame.blank)
return
}
case 'host/session-removed': {
this.summaries = this.summaries.filter(s => s.sessionId !== frame.sessionId)
this.recordMutation({ kind: 'remove', sessionId: frame.sessionId })
this.sessions.get(frame.sessionId)?.handleRemoved() // instance survives (resident-instance rule), only flagged in the snapshot
this.pendingBuffers.delete(frame.sessionId) // a removed session's buffered frames must not replay on a future instantiation
this.titleSnapshots.delete(frame.sessionId)
this.notifier.markDirty()
return
}
case 'host/session-status': {
this.summaries = this.summaries.map(s =>
s.sessionId === frame.sessionId && s.running !== frame.running ? { ...s, running: frame.running } : s)
this.recordMutation({ kind: 'status', sessionId: frame.sessionId, running: frame.running })
this.sessions.get(frame.sessionId)?.handleRunning(frame.running)
this.notifier.markDirty()
return
}
case 'host/agent-error': {
@@ -252,7 +394,7 @@ export class SessionManager {
}
}
/** After each connection generation (first connect included): refresh the list + resync opened instances (reconnect = rebuild). */
/** After each connection generation: refresh the session baseline and rebuild opened windows. */
handleConnected(): void {
void this.refreshList()
for (const session of this.sessions.values()) void session.resync()
@@ -270,6 +412,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.parentSessionId === entry.parentSessionId && prev.cwd === entry.cwd
&& prev.title === entry.title && prev.depth === entry.depth
) return prev
@@ -281,6 +424,57 @@ export class SessionManager {
}
const sameOrder = items.length === this.itemsCache.length && items.every((e, i) => e === this.itemsCache[i])
if (!sameOrder) this.itemsCache = items
return { items: this.itemsCache, state: this.listState, error: this.listError }
const selected = this.selected
const current = selected !== undefined && items.some(item => item.sessionId === selected)
? selected
: undefined
return {
items: this.itemsCache,
current,
state: this.listState,
phase: this.listPhase,
error: this.listError,
}
}
}
/** Apply one list mutation without deriving display order. */
function applyMutation(summaries: readonly SessionSummary[], mutation: SessionListMutation): SessionSummary[] {
switch (mutation.kind) {
case 'upsert': {
const existing = summaries.find(summary => summary.sessionId === mutation.summary.sessionId)
if (existing === undefined) return [mutation.summary, ...summaries]
const filled: SessionSummary = {
...existing,
// Blank only lowers: a stale true (session-added racing the local
// first send) never re-hides an already-surfaced session.
blank: existing.blank && mutation.summary.blank,
...(existing.cwd === undefined && mutation.summary.cwd !== undefined ? { cwd: mutation.summary.cwd } : {}),
...(existing.parentSessionId === undefined && mutation.summary.parentSessionId !== undefined
? { parentSessionId: mutation.summary.parentSessionId } : {}),
}
if (filled.cwd === existing.cwd && filled.parentSessionId === existing.parentSessionId
&& filled.blank === existing.blank) return [...summaries]
return summaries.map(summary => summary.sessionId === mutation.summary.sessionId ? filled : summary)
}
case 'remove':
return summaries.filter(summary => summary.sessionId !== mutation.sessionId)
case 'status':
// running:true doubles as the cross-端 blank flip (a blank session
// never runs, so the first running frame proves a message landed).
return summaries.map(summary => summary.sessionId === mutation.sessionId
&& (summary.running !== mutation.running || (mutation.running && summary.blank))
? { ...summary, running: mutation.running, blank: summary.blank && !mutation.running }
: summary)
case 'engaged':
return summaries.map(summary => summary.sessionId === mutation.sessionId && summary.blank
? { ...summary, blank: false }
: summary)
}
}
/** Temporary source-plane bridge while the Host contract and client project build independently. */
function workspaceAttachSessionId(error: RpcError): SessionId | undefined {
const candidate = error as unknown as { code: string; details: { sessionId?: SessionId } }
return candidate.code === 'workspace-attach-failed' ? candidate.details.sessionId : undefined
}

View File

@@ -3,11 +3,17 @@
// the flush rebuilds the snapshot cache BEFORE notifying (useSyncExternalStore requires a stable
// getSnapshot reference). With no listeners the rebuild is skipped and only the dirty bit is set
// (keeps frame storms cheap); the next getSnapshot rebuilds lazily.
//
// Freshness and notification are SEPARATE bits: a pull (ensureFresh) between
// markDirty and the scheduled flush rebuilds the snapshot but must not
// swallow the notification — push subscribers (object-layer watchers) would
// otherwise starve whenever any reader pulls first.
/** Subscription + microtask-batched notification primitive (shared by Session and SessionManager). */
export class Notifier {
private listeners = new Set<() => void>()
private dirty = false
private notifyPending = false
private scheduled = false
/** @param rebuild - snapshot rebuild function injected by the owner (writes the owner's snapshotCache). */
@@ -28,14 +34,18 @@ export class Notifier {
/** State-change entry: mark dirty and schedule the batched flush. */
markDirty(): void {
this.dirty = true
this.notifyPending = true
if (this.scheduled) return
this.scheduled = true
queueMicrotask(() => {
this.scheduled = false
if (!this.dirty) return
if (this.listeners.size === 0) return // lazy: no subscribers, keep dirty for the next getSnapshot
this.dirty = false
this.rebuild()
if (!this.notifyPending) return
if (this.listeners.size === 0) return // lazy: no subscribers; dirty (if still set) rebuilds on next getSnapshot
this.notifyPending = false
if (this.dirty) {
this.dirty = false
this.rebuild()
}
for (const listener of this.listeners) listener()
})
}
@@ -46,13 +56,18 @@ export class Notifier {
*/
notifyNow(): void {
this.dirty = true
this.notifyPending = true
if (this.listeners.size === 0) return // lazy: same as markDirty, next getSnapshot rebuilds
this.notifyPending = false
this.dirty = false
this.rebuild()
for (const listener of this.listeners) listener()
}
/** Pre-getSnapshot check: rebuild synchronously when dirty (read path before first subscribe / while unobserved). */
/**
* Pre-getSnapshot check: rebuild synchronously when dirty (read path
* before first subscribe / while unobserved). Notification stays pending.
*/
ensureFresh(): void {
if (!this.dirty) return
this.dirty = false

View File

@@ -2,8 +2,9 @@
* SessionsService: root sessions service — list snapshot store (manager
* projection; carries `current`, the persisted selection every
* session-scoped surface keys off — migrated here from ui-layout per the
* slot-parity design), session scope tree (mintScope pattern: no-op plugin
* Fiber + ctx.extend scope tag), stable SessionBinding cache, ancestry walk.
* slot-parity design), Agent scope tree (mintScope pattern: no-op plugin
* Fiber + ctx.extend scope tag; one scope per session, agent id === session
* id), stable SessionBinding cache, ancestry walk.
*
* Scope lifecycle is stage-driven: a scope is minted lazily on first
* resolution (pure — resolution has no side effects and is render-safe);
@@ -15,11 +16,15 @@
* survives frozen (read-only view) until the stage moves on.
*/
import type { Context, Fiber } from 'cordis'
import type { IApiClient, SessionId } from '@deepseek-ai/dsh-client-connection/client'
import type { SessionCell } from '@deepseek-ai/dsh-client-ui-slots'
import type { IApiClient, RpcError, SessionId, WorkspaceId } from '@deepseek-ai/dsh-client-connection/client'
import type {
HostObservable, SessionMaybeProvideInfo, SessionProvideInfo,
} from '@deepseek-ai/dsh-client-ui-slots'
import type { SnapshotStore } from '../contract/store.ts'
import { createSnapshotStore } from '../contract/store.ts'
import { createScope, scopeOf as scopeTagOf } from '../agents/scope.ts'
import { SessionManager } from './manager.ts'
import type { SessionListPhase } from './manager.ts'
import type { Session } from './session.ts'
/** Session list row projected from the host list RPC plus live stream increments. */
@@ -32,6 +37,13 @@ export interface SessionSummary {
cwd?: string
parentId?: SessionId
running: boolean
/**
* Empty-log bit (host summary derivation mirror). New Session reuses a blank
* one targeting the same workspace. Filtering stays with the consumer: the
* store carries every row, while the Workspace browser shows only the
* selected blank entry.
*/
blank: boolean
updatedAt: number
}
@@ -40,7 +52,29 @@ export interface SessionSummary {
* the single useSessions standard hook reads list and selection together —
* sidebar highlighting and SessionProvider share one fact source).
*/
export interface SessionListState { ids: SessionId[]; byId: Record<SessionId, SessionSummary>; current: SessionId | undefined }
export interface SessionListState {
ids: SessionId[]
byId: Record<SessionId, SessionSummary>
current: SessionId | undefined
/** Arrival lifecycle projected 1:1 from the manager snapshot (see SessionListPhase): empty-with-ready means "truly no sessions". */
phase: SessionListPhase
}
/** Structured session-create failure. */
export class SessionCreateError extends Error {
override readonly name = 'SessionCreateError'
/**
* @param rpcError - Host business or folded transport error.
* @param requestedSessionId - caller-preallocated id used for later stream/list reconciliation.
*/
constructor(
readonly rpcError: RpcError,
readonly requestedSessionId: SessionId | undefined,
) {
super(`session create failed: ${rpcError.code}: ${rpcError.message}`)
}
}
/** Session assembly handle for SessionProvider/inject factories (identity-stable per session). */
export interface SessionBinding {
@@ -49,21 +83,25 @@ export interface SessionBinding {
readonly ctx: Context
}
/** Scope tag key (client counterpart of the host dsh-scope pattern). */
const kScope = Symbol('dsh.client.scope')
// Scope primitives live in ../agents/scope.ts (the client mirror of host
// dsh-scope, keyed by Agent identity); re-exported here so existing
// consumers keep their import site.
export { scopeOf } from '../agents/scope.ts'
/**
* Read the session scope tag off a context.
* @param ctx - any client context.
* @returns the session id, or undefined on root contexts.
* Workspace display title of a session cwd: the path's last non-empty
* segment (both separators accepted; trailing separators ignored), or ''
* for separator-only paths — callers own their fallback (session id, raw
* cwd, default-directory copy). The repo-wide single basename derivation —
* every surface naming a workspace (picker rows, toggle labels, list titles)
* calls this instead of re-splitting paths.
* @param cwd - workspace directory path.
* @returns basename title, or '' when no non-empty segment exists.
*/
export function scopeOf(ctx: Context): SessionId | undefined {
return (ctx as Context & { [kScope]?: SessionId })[kScope]
export function workspaceTitleOf(cwd: string): string {
return cwd.replace(/[/\\]+$/, '').split(/[/\\]/).pop() ?? ''
}
/** Shared no-op plugin backing each session scope fiber. */
function sessionScope(): void {}
/**
* Display title projection: durable title, project directory basename, then
* the raw id.
@@ -71,8 +109,8 @@ function sessionScope(): void {}
function displayTitleOf(title: string | undefined, cwd: string | undefined, id: SessionId): string {
if (title !== undefined) return title
if (cwd !== undefined && cwd !== '') {
const base = cwd.replace(/[/\\]+$/, '').split(/[/\\]/).pop()
if (base !== undefined && base !== '') return base
const base = workspaceTitleOf(cwd)
if (base !== '') return base
}
return id
}
@@ -81,27 +119,54 @@ interface ScopeRecord {
fiber: Fiber
ctx: Context
binding: SessionBinding
/** Render-layer standard kit (identity-stable per scope; the renderer's per-cell caches key off it). */
cell: SessionCell
/** Render-layer standard-props bundle (identity-stable per scope; the renderer's per-info caches key off it). */
provideInfo: SessionProvideInfo
}
/** One plugin's per-session standard-props contribution (see {@link SessionsService.provide}). */
export interface SessionProvideContribution {
/** Bare observable sources, keyed by hook base name ('input' → useInput). */
hooks?: Record<string, HostObservable<unknown>>
/** Stable plain members (action callbacks etc.), spread into standard props verbatim. */
props?: Record<string, unknown>
}
/**
* Static declaration plus per-session resolver for one standard-kit
* contribution. The declared names let the renderer construct the same hook
* and prop surface while no session is current.
*/
export interface SessionProvideDescriptor {
/** Hook base names (`input` becomes `useInput`). */
hooks?: readonly string[]
/** Plain standard-prop names. */
props?: readonly string[]
/** Resolve every declared member for one definite session. */
resolve(binding: SessionBinding): SessionProvideContribution
}
/** Root sessions service: list store, current selection, object-layer manager, scope tree, bindings, ancestry. */
export class SessionsService {
/** 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 (wired to the connection by the runtime apply). */
readonly manager: SessionManager
/** The object-layer instance cluster and frame dispatch entry. */
private readonly manager: SessionManager
/**
* Persisted selection cell (the durable half of `list.current`). Private on
* purpose: reads go through the list snapshot; writes through {@link
* SessionsService.open}. Projection validates it against the live list
* instead of destructively pruning, so a selection survives transient list
* states (reconnect re-pull) and resurfaces when its session returns.
* SessionsService.open} / {@link SessionsService.clear}. Projection
* validates it against the live list instead of destructively pruning, so a
* selection survives transient list states (reconnect re-pull) and
* resurfaces when its session returns.
*/
private readonly selection: SnapshotStore<{ sessionId?: SessionId }>
private readonly scopes = new Map<SessionId, ScopeRecord>()
/** Registered per-session standard-props providers, in registration order. */
private readonly providers: SessionProvideDescriptor[] = []
/** Static no-session projection, rebuilt only when the provider roster changes. */
private maybeInfo: SessionMaybeProvideInfo
/**
* The staged session id — follows `list.current` exactly, holding its last
* defined value across masked gaps (a transiently absent selection blanks
@@ -117,11 +182,13 @@ export class SessionsService {
* @param api - wire client shared with every Session.
*/
constructor(private readonly rootCtx: Context, api: IApiClient) {
this.manager = new SessionManager(api)
this.selection = createSnapshotStore<{ sessionId?: SessionId }>(
{},
{ persist: { name: 'dsh.sessions.current' } })
this.list = createSnapshotStore<SessionListState>({ ids: [], byId: {}, current: undefined })
this.manager = new SessionManager(api, this.selection.getSnapshot().sessionId)
this.list = createSnapshotStore<SessionListState>({
ids: [], byId: {}, current: undefined, phase: 'pending',
})
// The manager owns wire truth; the store is its projection. Manager
// notifications are already microtask-batched.
this.manager.subscribe(() => { this.projectList() })
@@ -132,36 +199,167 @@ export class SessionsService {
// the follower writes no list state — session.open()'s synchronous prefix
// touches only session-side state and its own microtask-batched notifier.
this.list.subscribe(() => { this.followCurrent() })
// The runtime's own contribution comes first: useSession rides the same
// provide channel every plugin uses (no renderer special case).
this.providers.push({
hooks: ['session'],
resolve: binding => ({ hooks: { session: binding.session } }),
})
this.maybeInfo = this.materializeMaybeProvideInfo()
rootCtx.reflect.provide('sessions', this, undefined)
}
/**
* Select a session as current. Unknown ids fail loud instead of navigating
* nowhere (the sole selection write path).
* @param id - session id (must exist in the list store).
* Register a per-session standard-props provider: every session-scope slot
* component receives the contributed members as standard props (`hooks`
* sources become `use<Name>` selector hooks on the render side; `props`
* spread verbatim). Contributions materialize lazily with the session's
* scope record and die with it. Registration order is resolution order;
* duplicate member names fail loud at materialization.
* @param descriptor - static member roster plus per-session resolver.
* @returns disposer removing the provider (already-materialized bundles keep their members until their scope drops).
*/
open(id: SessionId): void {
if (this.list.getSnapshot().byId[id] === undefined) {
throw new Error(`sessions.open: unknown session ${id}`)
provide(descriptor: SessionProvideDescriptor): () => void {
this.providers.push(descriptor)
// Scopes may already exist (boot order: the list lands and resolves
// scopes before later plugins register) — their bundles must include
// every provider by first render, so re-materialize on roster change.
this.rematerializeProvideBundles()
return () => {
const at = this.providers.indexOf(descriptor)
if (at >= 0) this.providers.splice(at, 1)
this.rematerializeProvideBundles()
}
this.selection.update((draft) => { draft.sessionId = id })
this.list.update((draft) => { draft.current = id })
}
/** Rebuild every live scope's standard-props bundle after a provider roster change. */
private rematerializeProvideBundles(): void {
this.maybeInfo = this.materializeMaybeProvideInfo()
for (const record of this.scopes.values()) {
record.provideInfo = this.materializeProvideInfo(record.binding)
}
}
/** Build the static no-session kit and reject duplicate declared names. */
private materializeMaybeProvideInfo(): SessionMaybeProvideInfo {
const hooks: Record<string, undefined> = {}
const props: Record<string, undefined> = {}
for (const descriptor of this.providers) {
for (const name of descriptor.hooks ?? []) {
if (Object.hasOwn(hooks, name)) throw new Error(`sessions.provide: duplicate hook "${name}"`)
hooks[name] = undefined
}
for (const name of descriptor.props ?? []) {
if (Object.hasOwn(props, name)) throw new Error(`sessions.provide: duplicate prop "${name}"`)
props[name] = undefined
}
}
return { sessionId: undefined, hooks, props }
}
/** Materialize the standard-props bundle for one session (fails loud on duplicate member names). */
private materializeProvideInfo(binding: SessionBinding): SessionProvideInfo {
const hooks: Record<string, HostObservable<unknown>> = {}
const props: Record<string, unknown> = {}
for (const descriptor of this.providers) {
const contribution = descriptor.resolve(binding)
const contributedHooks = contribution.hooks ?? {}
const contributedProps = contribution.props ?? {}
for (const name of Object.keys(contributedHooks)) {
if (!(descriptor.hooks ?? []).includes(name)) {
throw new Error(`sessions.provide: undeclared hook "${name}"`)
}
}
for (const name of Object.keys(contributedProps)) {
if (!(descriptor.props ?? []).includes(name)) {
throw new Error(`sessions.provide: undeclared prop "${name}"`)
}
}
for (const name of descriptor.hooks ?? []) {
const source = contributedHooks[name]
if (source === undefined) throw new Error(`sessions.provide: missing hook "${name}"`)
if (Object.hasOwn(hooks, name)) throw new Error(`sessions.provide: duplicate hook "${name}"`)
hooks[name] = source
}
for (const name of descriptor.props ?? []) {
if (!Object.hasOwn(contributedProps, name)) throw new Error(`sessions.provide: missing prop "${name}"`)
if (Object.hasOwn(props, name)) throw new Error(`sessions.provide: duplicate prop "${name}"`)
props[name] = contributedProps[name]
}
}
return { sessionId: binding.sessionId, hooks, props }
}
/**
* Create a session on the host.
* @param opts - creation options (project directory).
* @returns the new session id.
* Select a session as current. Unknown ids fail loud instead of navigating
* nowhere.
* @param id - session id (must exist in the list store).
*/
async create(opts: { cwd?: string } = {}): Promise<SessionId> {
const result = await this.manager.create(opts.cwd)
if (!result.ok) throw new Error(`session create failed: ${result.error.code}: ${result.error.message}`)
open(id: SessionId): void {
this.manager.select(id)
}
/**
* Clear the current selection so the layout shows the no-session empty
* state (new-session affordance and the workspace preselection flow).
* Wipes the persisted selection too — a reload stays on empty until the
* user opens or starts a session. The staged scope keeps its frozen view
* per the masked-gap contract until the next open() moves the stage.
*/
clear(): void {
this.manager.clearSelection()
}
/**
* Refresh the real Session baseline, reusing an in-flight pull.
* @returns completion of the current or newly started baseline pull.
*/
refresh(): Promise<void> {
return this.manager.refreshList()
}
/**
* Route a mux stream envelope into the Session object layer.
* @param envelope - validated mux stream envelope.
*/
handleMuxEnvelope(envelope: Parameters<SessionManager['handleMuxEnvelope']>[0]): void {
this.manager.handleMuxEnvelope(envelope)
}
/**
* Route a Host stream envelope into the Session object layer.
* @param envelope - validated Host stream envelope.
*/
handleHostEnvelope(envelope: Parameters<SessionManager['handleHostEnvelope']>[0]): void {
this.manager.handleHostEnvelope(envelope)
}
/** Rebuild the Session baseline and every opened window after connection. */
handleConnected(): void {
this.manager.handleConnected()
}
/**
* Create a session on the host. Resolution guarantee: by the time the
* promise resolves, the created session is in the list store and
* {@link SessionsService.binding} resolves it — callers (New Session
* draft hand-off) may address the scope synchronously, without waiting a
* notifier flush. The synchronous projection below makes this structural
* rather than an accident of microtask ordering.
* @param opts - target workspace or directory and an optional preallocated id.
* @returns the new session id.
* @throws {SessionCreateError} with the requested id.
*/
async create(opts: { workspaceId?: WorkspaceId; cwd?: string; sessionId?: SessionId } = {}): Promise<SessionId> {
const result = await this.manager.create(opts)
if (!result.ok) throw new SessionCreateError(result.error, opts.sessionId)
this.projectList()
return result.value.sessionId
}
/**
* Resolve a session-scoped context view (use-and-discard).
* @param id - session id.
* Resolve an Agent-scoped context view (use-and-discard).
* @param id - session id (the agent identity — 1:1 same axis).
* @returns scoped ctx, or undefined for a session neither listed nor already scoped.
*/
scope(id: SessionId): Context | undefined {
@@ -169,7 +367,7 @@ export class SessionsService {
}
/**
* Read the session scope tag off a context. Service-method seam: fetch
* Read the Agent scope tag off a context. Service-method seam: fetch
* bundles must reach scope resolution through ctx.sessions — a cross-bundle
* value import of the standalone helper would inline a second module
* instance whose private tag Symbol never matches.
@@ -177,7 +375,22 @@ export class SessionsService {
* @returns the session id, or undefined on root contexts.
*/
scopeOf(ctx: Context): SessionId | undefined {
return scopeOf(ctx)
return scopeTagOf(ctx)
}
/**
* Resolve the business Session behind an Agent-scoped context — the one
* hop every scoped consumer (event listeners, per-session controllers)
* takes from ctx-space into object-space (the client mirror of host
* `agent.session`). Same service-method seam as
* {@link SessionsService.scopeOf}.
* @param ctx - an Agent-scoped context.
* @returns the Session, or undefined when the ctx is untagged or its scope was pruned.
*/
sessionOf(ctx: Context): Session | undefined {
const id = scopeTagOf(ctx)
if (id === undefined) return undefined
return this.scopes.get(id)?.binding.session
}
/**
@@ -191,16 +404,26 @@ export class SessionsService {
}
/**
* Resolve the render-layer session cell (SessionProvider's feed through
* the renderer host; ctx never enters the render layer). Pure resolution —
* render-safe: SessionProvider calls this during render, so no staging, no
* window side effects (StrictMode double-invokes and concurrent discarded
* passes must stay free).
* Resolve the render-layer standard-props bundle (SessionProvider's feed
* through the renderer host; ctx never enters the render layer). Pure
* resolution — render-safe: SessionProvider calls this during render, so no
* staging, no window side effects (StrictMode double-invokes and concurrent
* discarded passes must stay free).
* @param id - session id.
* @returns cell, or undefined for a session neither listed nor already scoped.
* @returns the provide info, or undefined for a session neither listed nor already scoped.
*/
cell(id: string): SessionCell | undefined {
return this.resolve(id as SessionId)?.cell
provideInfo(id: string): SessionProvideInfo | undefined {
return this.resolve(id as SessionId)?.provideInfo
}
/**
* Resolve the current-session-optional standard kit. Unknown or absent ids
* return the static no-session projection rather than removing hook props.
* @param id - current session id, when selected.
* @returns a definite or no-session provide bundle.
*/
maybeProvideInfo(id: string | undefined): SessionMaybeProvideInfo {
return (id === undefined ? undefined : this.provideInfo(id)) ?? this.maybeInfo
}
/**
@@ -211,11 +434,12 @@ export class SessionsService {
* failed one retries the next time current is touched).
*/
private followCurrent(): void {
const current = this.list.getSnapshot().current
const snapshot = this.list.getSnapshot()
const current = snapshot.current
// A masked gap (current blanked while the selection's session is
// transiently absent) holds the stage: tearing down on the gap would
// destroy exactly the frozen scope the mask exists to preserve.
if (current === undefined || current === this.watched) return
if (current === undefined || snapshot.byId[current] === undefined || current === this.watched) return
this.watched = current
this.sweepDeferred()
const record = this.resolve(current)
@@ -245,30 +469,42 @@ export class SessionsService {
return chain
}
/** Lazily mint the scope + binding for a listed (or already-scoped) session. */
/**
* Lazily mint the scope + binding for an eligible session. Eligibility and
* prune share one predicate (decision 12): listed on the host — a scope is
* born when its session enters the client's view (list mirror row from the
* baseline pull, a create() echo, or the session-added frame) and dies with
* the prune when the row leaves.
*/
private resolve(id: SessionId): ScopeRecord | undefined {
const existing = this.scopes.get(id)
if (existing !== undefined) return existing
// Frozen scopes outlive the list; new scopes are only minted for listed sessions.
if (this.list.getSnapshot().byId[id] === undefined) return undefined
const fiber = this.rootCtx.plugin(sessionScope)
const ctx = fiber.ctx.extend({ [kScope]: id })
if (!this.eligible(id)) return undefined
const { fiber, ctx } = createScope(this.rootCtx, id)
const session = this.manager.get(id)
// The Session owns its scoped dispatch point (host Agent.loopCtx mirror);
// mint and bind are one step so a live scope record implies a bound actx.
session.bindScope(ctx)
const binding: SessionBinding = { sessionId: id, session, ctx }
const record: ScopeRecord = {
fiber,
ctx,
binding: { sessionId: id, session, ctx },
// Bare source form (store migration): the Session object IS the
// observable; the React side binds the useSession hook per cell.
cell: { sessionId: id, session },
binding,
// Sources are bare observables; React binds selector hooks at its own seam.
provideInfo: this.materializeProvideInfo(binding),
}
this.scopes.set(id, record)
return record
}
/** The one aliveness predicate shared by scope mint and prune: host-listed. */
private eligible(id: SessionId): boolean {
return this.list.getSnapshot().byId[id] !== undefined
}
/** Project the manager's list snapshot into the store (title derivation is display-only). */
private projectList(): void {
const items = this.manager.getListSnapshot().items
const { items, current, phase } = this.manager.getListSnapshot()
const ids: SessionId[] = []
const byId: Record<SessionId, SessionSummary> = {}
for (const entry of items) {
@@ -277,24 +513,30 @@ export class SessionsService {
id: entry.sessionId,
displayTitle: displayTitleOf(entry.title, entry.cwd, entry.sessionId),
running: entry.running,
blank: entry.blank,
updatedAt: entry.updatedAt,
...(entry.title !== undefined ? { title: entry.title } : {}),
...(entry.cwd !== undefined ? { cwd: entry.cwd } : {}),
...(entry.parentSessionId !== undefined ? { parentId: entry.parentSessionId } : {}),
}
}
// current = the persisted selection, masked while its session is absent
// (falls to the empty state; resurfaces if the session returns).
const selected = this.selection.getSnapshot().sessionId
const current = selected !== undefined && byId[selected] !== undefined ? selected : undefined
this.list.set({ ids, byId, current })
const persisted = this.selection.getSnapshot().sessionId
// No current (cleared, or masked gap) wipes the persisted cell — a reload
// stays on empty; the in-memory selection still resurfaces a masked id.
if (current === undefined) {
if (persisted !== undefined) this.selection.set({})
} else if (byId[current] !== undefined && persisted !== current) {
this.selection.set({ sessionId: current })
}
this.list.set({ ids, byId, current, phase })
this.pruneScopes(byId)
}
/** Tear down scopes for removed sessions off stage; the staged one defers until the stage moves. */
/** Tear down scope + instance for no-longer-eligible sessions off stage; the staged one defers until the stage moves. */
private pruneScopes(byId: Record<SessionId, SessionSummary>): void {
void byId
for (const [id, record] of this.scopes) {
if (byId[id] !== undefined) continue
if (this.eligible(id)) continue
if (id === this.watched) {
this.deferredRemovals.add(id)
continue
@@ -305,12 +547,22 @@ export class SessionsService {
}
}
/** Dispose a scope fiber and its session-keyed slot-store instances together (single lifecycle axis). */
/**
* One teardown for the whole per-session axis (decision 12): the scope
* fiber (cascading every actx-registered effect: input shell, slash
* controller, popup, plugin stores, listeners), the session-keyed slot
* stores, and the Session instance itself — the host session log is the
* durable truth, a reopen lazily rebuilds and backfills via open().
*/
private dropScope(id: SessionId, record: ScopeRecord): void {
void record.fiber.dispose()
// Release the Session's dispatch point with the scope it belongs to (a
// surviving instance — the live Intent — rebinds when resolve re-mints).
record.binding.session.unbindScope()
// Optional lookup: slots and sessions are sibling services with no
// declared dependency; a slots-less boot (object-layer tests) skips.
this.rootCtx.get('slots')?.pruneStoreScope(id)
this.manager.drop(id)
}
/** Run deferred teardowns whose session is no longer staged (called when the stage moves). */
@@ -320,8 +572,8 @@ export class SessionsService {
* stage move sweeps first, so the set cannot contain the id the stage just
* moved to; kept as a guard against future extra sweep call sites. */
if (id === this.watched) continue
// Still absent from the list? (A re-added id cancels the deferred teardown.)
if (this.list.getSnapshot().byId[id] !== undefined) {
// Eligible again? (A re-added id cancels the deferred teardown.)
if (this.eligible(id)) {
this.deferredRemovals.delete(id)
continue
}

View File

@@ -0,0 +1,590 @@
/**
* SessionsService: root sessions service — list snapshot store (manager
* projection; carries `current`, the persisted selection every
* session-scoped surface keys off — migrated here from ui-layout per the
* slot-parity design), Agent scope tree (mintScope pattern: no-op plugin
* Fiber + ctx.extend scope tag; one scope per session, agent id === session
* id), stable SessionBinding cache, ancestry walk.
*
* Scope lifecycle is stage-driven: a scope is minted lazily on first
* resolution (pure — resolution has no side effects and is render-safe);
* the event window and deferred teardown key off the STAGED session, which
* follows `list.current` exactly. Staging is the open signal: the window
* opens ⟺ the session is on stage (today the stage is `current`; the staged
* state can widen to a multi-pane list later). A session leaving the list
* 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 { IApiClient, RpcError, SessionId, WorkspaceId } from '@deepseek-ai/dsh-client-connection/client'
import type {
HostObservable, SessionMaybeProvideInfo, SessionProvideInfo,
} from '@deepseek-ai/dsh-client-ui-slots'
import type { SnapshotStore } from '../contract/store.ts'
import { createSnapshotStore } from '../contract/store.ts'
import { createScope, scopeOf as scopeTagOf } from '../agents/scope.ts'
import { SessionManager } from './manager.ts'
import type { SessionListPhase } from './manager.ts'
import type { Session } from './session.ts'
/** Session list row projected from the host list RPC plus live stream increments. */
export interface SessionSummary {
id: SessionId
/** Latest durable log-backed title, absent until the host projects one. */
title?: string
/** Human-facing label: durable title, project basename, then session id. */
displayTitle: string
cwd?: string
parentId?: SessionId
running: boolean
/**
* Empty-log bit (host summary derivation mirror). List surfaces hide blank
* sessions; New Session reuses a blank one targeting the same workspace.
* Filtering stays with the consumer — the store carries every row.
*/
blank: boolean
updatedAt: number
}
/**
* Session list store shape. `current` rides the same snapshot (arbitrated:
* the single useSessions standard hook reads list and selection together —
* sidebar highlighting and SessionProvider share one fact source).
*/
export interface SessionListState {
ids: SessionId[]
byId: Record<SessionId, SessionSummary>
current: SessionId | undefined
/** Arrival lifecycle projected 1:1 from the manager snapshot (see SessionListPhase): empty-with-ready means "truly no sessions". */
phase: SessionListPhase
}
/** Structured session-create failure. */
export class SessionCreateError extends Error {
override readonly name = 'SessionCreateError'
/**
* @param rpcError - Host business or folded transport error.
* @param requestedSessionId - caller-preallocated id used for later stream/list reconciliation.
*/
constructor(
readonly rpcError: RpcError,
readonly requestedSessionId: SessionId | undefined,
) {
super(`session create failed: ${rpcError.code}: ${rpcError.message}`)
}
}
/** Session assembly handle for SessionProvider/inject factories (identity-stable per session). */
export interface SessionBinding {
readonly sessionId: SessionId
readonly session: Session
readonly ctx: Context
}
// Scope primitives live in ../agents/scope.ts (the client mirror of host
// dsh-scope, keyed by Agent identity); re-exported here so existing
// consumers keep their import site.
export { scopeOf } from '../agents/scope.ts'
/**
* Workspace display title of a session cwd: the path's last non-empty
* segment (both separators accepted; trailing separators ignored), or ''
* for separator-only paths — callers own their fallback (session id, raw
* cwd, default-directory copy). The repo-wide single basename derivation —
* every surface naming a workspace (picker rows, toggle labels, list titles)
* calls this instead of re-splitting paths.
* @param cwd - workspace directory path.
* @returns basename title, or '' when no non-empty segment exists.
*/
export function workspaceTitleOf(cwd: string): string {
return cwd.replace(/[/\\]+$/, '').split(/[/\\]/).pop() ?? ''
}
/**
* Display title projection: durable title, project directory basename, then
* the raw id.
*/
function displayTitleOf(title: string | undefined, cwd: string | undefined, id: SessionId): string {
if (title !== undefined) return title
if (cwd !== undefined && cwd !== '') {
const base = workspaceTitleOf(cwd)
if (base !== '') return base
}
return id
}
interface ScopeRecord {
fiber: Fiber
ctx: Context
binding: SessionBinding
/** Render-layer standard-props bundle (identity-stable per scope; the renderer's per-info caches key off it). */
provideInfo: SessionProvideInfo
}
/** One plugin's per-session standard-props contribution (see {@link SessionsService.provide}). */
export interface SessionProvideContribution {
/** Bare observable sources, keyed by hook base name ('input' → useInput). */
hooks?: Record<string, HostObservable<unknown>>
/** Stable plain members (action callbacks etc.), spread into standard props verbatim. */
props?: Record<string, unknown>
}
/**
* Static declaration plus per-session resolver for one standard-kit
* contribution. The declared names let the renderer construct the same hook
* and prop surface while no session is current.
*/
export interface SessionProvideDescriptor {
/** Hook base names (`input` becomes `useInput`). */
hooks?: readonly string[]
/** Plain standard-prop names. */
props?: readonly string[]
/** Resolve every declared member for one definite session. */
resolve(binding: SessionBinding): SessionProvideContribution
}
/** Root sessions service: list store, current selection, object-layer manager, scope tree, bindings, ancestry. */
export class SessionsService {
/** 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. */
private readonly manager: SessionManager
/**
* Persisted selection cell (the durable half of `list.current`). Private on
* purpose: reads go through the list snapshot; writes through {@link
* SessionsService.open} / {@link SessionsService.clear}. Projection
* validates it against the live list instead of destructively pruning, so a
* selection survives transient list states (reconnect re-pull) and
* resurfaces when its session returns.
*/
private readonly selection: SnapshotStore<{ sessionId?: SessionId }>
private readonly scopes = new Map<SessionId, ScopeRecord>()
/** Registered per-session standard-props providers, in registration order. */
private readonly providers: SessionProvideDescriptor[] = []
/** Static no-session projection, rebuilt only when the provider roster changes. */
private maybeInfo: SessionMaybeProvideInfo
/**
* The staged session id — follows `list.current` exactly, holding its last
* defined value across masked gaps (a transiently absent selection blanks
* `current` without moving the stage, so reconnect re-pulls and removals
* keep the staged scope's frozen view alive until the stage moves on).
*/
private watched: SessionId | undefined
/** Removed-while-staged sessions whose teardown waits for the stage to move away. */
private readonly deferredRemovals = new Set<SessionId>()
/**
* @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) {
this.selection = createSnapshotStore<{ sessionId?: SessionId }>(
{},
{ persist: { name: 'dsh.sessions.current' } })
this.manager = new SessionManager(api, this.selection.getSnapshot().sessionId)
this.list = createSnapshotStore<SessionListState>({
ids: [], byId: {}, current: undefined, phase: 'pending',
})
// The manager owns wire truth; the store is its projection. Manager
// notifications are already microtask-batched.
this.manager.subscribe(() => { this.projectList() })
// Stage follower: every current write (open() and projection alike)
// re-evaluates staging, so startup restore (persisted selection validated
// by the projection) and reconnect resurfacing open their window with no
// dedicated code path. Safe to run synchronously inside the store notify:
// the follower writes no list state — session.open()'s synchronous prefix
// touches only session-side state and its own microtask-batched notifier.
this.list.subscribe(() => { this.followCurrent() })
// The runtime's own contribution comes first: useSession rides the same
// provide channel every plugin uses (no renderer special case).
this.providers.push({
hooks: ['session'],
resolve: binding => ({ hooks: { session: binding.session } }),
})
this.maybeInfo = this.materializeMaybeProvideInfo()
rootCtx.reflect.provide('sessions', this, undefined)
}
/**
* Register a per-session standard-props provider: every session-scope slot
* component receives the contributed members as standard props (`hooks`
* sources become `use<Name>` selector hooks on the render side; `props`
* spread verbatim). Contributions materialize lazily with the session's
* scope record and die with it. Registration order is resolution order;
* duplicate member names fail loud at materialization.
* @param descriptor - static member roster plus per-session resolver.
* @returns disposer removing the provider (already-materialized bundles keep their members until their scope drops).
*/
provide(descriptor: SessionProvideDescriptor): () => void {
this.providers.push(descriptor)
// Scopes may already exist (boot order: the list lands and resolves
// scopes before later plugins register) — their bundles must include
// every provider by first render, so re-materialize on roster change.
this.rematerializeProvideBundles()
return () => {
const at = this.providers.indexOf(descriptor)
if (at >= 0) this.providers.splice(at, 1)
this.rematerializeProvideBundles()
}
}
/** Rebuild every live scope's standard-props bundle after a provider roster change. */
private rematerializeProvideBundles(): void {
this.maybeInfo = this.materializeMaybeProvideInfo()
for (const record of this.scopes.values()) {
record.provideInfo = this.materializeProvideInfo(record.binding)
}
}
/** Build the static no-session kit and reject duplicate declared names. */
private materializeMaybeProvideInfo(): SessionMaybeProvideInfo {
const hooks: Record<string, undefined> = {}
const props: Record<string, undefined> = {}
for (const descriptor of this.providers) {
for (const name of descriptor.hooks ?? []) {
if (Object.hasOwn(hooks, name)) throw new Error(`sessions.provide: duplicate hook "${name}"`)
hooks[name] = undefined
}
for (const name of descriptor.props ?? []) {
if (Object.hasOwn(props, name)) throw new Error(`sessions.provide: duplicate prop "${name}"`)
props[name] = undefined
}
}
return { sessionId: undefined, hooks, props }
}
/** Materialize the standard-props bundle for one session (fails loud on duplicate member names). */
private materializeProvideInfo(binding: SessionBinding): SessionProvideInfo {
const hooks: Record<string, HostObservable<unknown>> = {}
const props: Record<string, unknown> = {}
for (const descriptor of this.providers) {
const contribution = descriptor.resolve(binding)
const contributedHooks = contribution.hooks ?? {}
const contributedProps = contribution.props ?? {}
for (const name of Object.keys(contributedHooks)) {
if (!(descriptor.hooks ?? []).includes(name)) {
throw new Error(`sessions.provide: undeclared hook "${name}"`)
}
}
for (const name of Object.keys(contributedProps)) {
if (!(descriptor.props ?? []).includes(name)) {
throw new Error(`sessions.provide: undeclared prop "${name}"`)
}
}
for (const name of descriptor.hooks ?? []) {
const source = contributedHooks[name]
if (source === undefined) throw new Error(`sessions.provide: missing hook "${name}"`)
if (Object.hasOwn(hooks, name)) throw new Error(`sessions.provide: duplicate hook "${name}"`)
hooks[name] = source
}
for (const name of descriptor.props ?? []) {
if (!Object.hasOwn(contributedProps, name)) throw new Error(`sessions.provide: missing prop "${name}"`)
if (Object.hasOwn(props, name)) throw new Error(`sessions.provide: duplicate prop "${name}"`)
props[name] = contributedProps[name]
}
}
return { sessionId: binding.sessionId, hooks, props }
}
/**
* Select a session as current. Unknown ids fail loud instead of navigating
* nowhere.
* @param id - session id (must exist in the list store).
*/
open(id: SessionId): void {
this.manager.select(id)
}
/**
* Clear the current selection so the layout shows the no-session empty
* state (new-session affordance and the workspace preselection flow).
* Wipes the persisted selection too — a reload stays on empty until the
* user opens or starts a session. The staged scope keeps its frozen view
* per the masked-gap contract until the next open() moves the stage.
*/
clear(): void {
this.manager.clearSelection()
}
/**
* Refresh the real Session baseline, reusing an in-flight pull.
* @returns completion of the current or newly started baseline pull.
*/
refresh(): Promise<void> {
return this.manager.refreshList()
}
/**
* Route a mux stream envelope into the Session object layer.
* @param envelope - validated mux stream envelope.
*/
handleMuxEnvelope(envelope: Parameters<SessionManager['handleMuxEnvelope']>[0]): void {
this.manager.handleMuxEnvelope(envelope)
}
/**
* Route a Host stream envelope into the Session object layer.
* @param envelope - validated Host stream envelope.
*/
handleHostEnvelope(envelope: Parameters<SessionManager['handleHostEnvelope']>[0]): void {
this.manager.handleHostEnvelope(envelope)
}
/** Rebuild the Session baseline and every opened window after connection. */
handleConnected(): void {
this.manager.handleConnected()
}
/**
* Create a session on the host. Resolution guarantee: by the time the
* promise resolves, the created session is in the list store and
* {@link SessionsService.binding} resolves it — callers (New Session
* draft hand-off) may address the scope synchronously, without waiting a
* notifier flush. The synchronous projection below makes this structural
* rather than an accident of microtask ordering.
* @param opts - target workspace or directory and an optional preallocated id.
* @returns the new session id.
* @throws {SessionCreateError} with the requested id.
*/
async create(opts: { workspaceId?: WorkspaceId; cwd?: string; sessionId?: SessionId } = {}): Promise<SessionId> {
const result = await this.manager.create(opts)
if (!result.ok) throw new SessionCreateError(result.error, opts.sessionId)
this.projectList()
return result.value.sessionId
}
/**
* Resolve an Agent-scoped context view (use-and-discard).
* @param id - session id (the agent identity — 1:1 same axis).
* @returns scoped ctx, or undefined for a session neither listed nor already scoped.
*/
scope(id: SessionId): Context | undefined {
return this.resolve(id)?.ctx
}
/**
* Read the Agent scope tag off a context. Service-method seam: fetch
* bundles must reach scope resolution through ctx.sessions — a cross-bundle
* value import of the standalone helper would inline a second module
* instance whose private tag Symbol never matches.
* @param ctx - any client context.
* @returns the session id, or undefined on root contexts.
*/
scopeOf(ctx: Context): SessionId | undefined {
return scopeTagOf(ctx)
}
/**
* Resolve the business Session behind an Agent-scoped context — the one
* hop every scoped consumer (event listeners, per-session controllers)
* takes from ctx-space into object-space (the client mirror of host
* `agent.session`). Same service-method seam as
* {@link SessionsService.scopeOf}.
* @param ctx - an Agent-scoped context.
* @returns the Session, or undefined when the ctx is untagged or its scope was pruned.
*/
sessionOf(ctx: Context): Session | undefined {
const id = scopeTagOf(ctx)
if (id === undefined) return undefined
return this.scopes.get(id)?.binding.session
}
/**
* Resolve the stable session binding (scope-addressed assembly feed). Pure
* resolution — no staging, no window side effects.
* @param id - session id.
* @returns binding, or undefined for a session neither listed nor already scoped.
*/
binding(id: SessionId): SessionBinding | undefined {
return this.resolve(id)?.binding
}
/**
* Resolve the render-layer standard-props bundle (SessionProvider's feed
* through the renderer host; ctx never enters the render layer). Pure
* resolution — render-safe: SessionProvider calls this during render, so no
* staging, no window side effects (StrictMode double-invokes and concurrent
* discarded passes must stay free).
* @param id - session id.
* @returns the provide info, or undefined for a session neither listed nor already scoped.
*/
provideInfo(id: string): SessionProvideInfo | undefined {
return this.resolve(id as SessionId)?.provideInfo
}
/**
* Resolve the current-session-optional standard kit. Unknown or absent ids
* return the static no-session projection rather than removing hook props.
* @param id - current session id, when selected.
* @returns a definite or no-session provide bundle.
*/
maybeProvideInfo(id: string | undefined): SessionMaybeProvideInfo {
return (id === undefined ? undefined : this.provideInfo(id)) ?? this.maybeInfo
}
/**
* Move the stage to the list's current session: sweep teardowns deferred
* behind the previous occupant and pull the new occupant's history window.
* Staging IS the open signal — the window opens ⟺ the session is on stage
* — and open() is idempotent (an in-flight or completed open no-ops; a
* failed one retries the next time current is touched).
*/
private followCurrent(): void {
const snapshot = this.list.getSnapshot()
const current = snapshot.current
// A masked gap (current blanked while the selection's session is
// transiently absent) holds the stage: tearing down on the gap would
// destroy exactly the frozen scope the mask exists to preserve.
if (current === undefined || snapshot.byId[current] === undefined || current === this.watched) return
this.watched = current
this.sweepDeferred()
const record = this.resolve(current)
/* v8 ignore next 3 -- defensive: current is always a listed id (open()
* validates and the projection masks absent selections), so resolve
* cannot miss; kept so a future current writer cannot crash the notify. */
if (record !== undefined) {
void record.binding.session.open()
}
}
/**
* Breadcrumb feed: walk parentId links inside the list store.
* @param id - session id.
* @returns summaries from root ancestor to the session itself (empty when unknown; a broken link stops the walk).
*/
ancestry(id: SessionId): SessionSummary[] {
const { byId } = this.list.getSnapshot()
const chain: SessionSummary[] = []
let cursor: SessionId | undefined = id
while (cursor !== undefined) {
const summary: SessionSummary | undefined = byId[cursor]
if (summary === undefined || chain.includes(summary)) break
chain.unshift(summary)
cursor = summary.parentId
}
return chain
}
/**
* Lazily mint the scope + binding for an eligible session. Eligibility and
* prune share one predicate (decision 12): listed on the host — a scope is
* born when its session enters the client's view (list mirror row from the
* baseline pull, a create() echo, or the session-added frame) and dies with
* the prune when the row leaves.
*/
private resolve(id: SessionId): ScopeRecord | undefined {
const existing = this.scopes.get(id)
if (existing !== undefined) return existing
if (!this.eligible(id)) return undefined
const { fiber, ctx } = createScope(this.rootCtx, id)
const session = this.manager.get(id)
// The Session owns its scoped dispatch point (host Agent.loopCtx mirror);
// mint and bind are one step so a live scope record implies a bound actx.
session.bindScope(ctx)
const binding: SessionBinding = { sessionId: id, session, ctx }
const record: ScopeRecord = {
fiber,
ctx,
binding,
// Sources are bare observables; React binds selector hooks at its own seam.
provideInfo: this.materializeProvideInfo(binding),
}
this.scopes.set(id, record)
return record
}
/** The one aliveness predicate shared by scope mint and prune: host-listed. */
private eligible(id: SessionId): boolean {
return this.list.getSnapshot().byId[id] !== undefined
}
/** Project the manager's list snapshot into the store (title derivation is display-only). */
private projectList(): void {
const { items, current, phase } = this.manager.getListSnapshot()
const ids: SessionId[] = []
const byId: Record<SessionId, SessionSummary> = {}
for (const entry of items) {
ids.push(entry.sessionId)
byId[entry.sessionId] = {
id: entry.sessionId,
displayTitle: displayTitleOf(entry.title, entry.cwd, entry.sessionId),
running: entry.running,
blank: entry.blank,
updatedAt: entry.updatedAt,
...(entry.title !== undefined ? { title: entry.title } : {}),
...(entry.cwd !== undefined ? { cwd: entry.cwd } : {}),
...(entry.parentSessionId !== undefined ? { parentId: entry.parentSessionId } : {}),
}
}
const persisted = this.selection.getSnapshot().sessionId
// No current (cleared, or masked gap) wipes the persisted cell — a reload
// stays on empty; the in-memory selection still resurfaces a masked id.
if (current === undefined) {
if (persisted !== undefined) this.selection.set({})
} else if (byId[current] !== undefined && persisted !== current) {
this.selection.set({ sessionId: current })
}
this.list.set({ ids, byId, current, phase })
this.pruneScopes(byId)
}
/** Tear down scope + instance for no-longer-eligible sessions off stage; the staged one defers until the stage moves. */
private pruneScopes(byId: Record<SessionId, SessionSummary>): void {
void byId
for (const [id, record] of this.scopes) {
if (this.eligible(id)) continue
if (id === this.watched) {
this.deferredRemovals.add(id)
continue
}
this.scopes.delete(id)
this.deferredRemovals.delete(id)
this.dropScope(id, record)
}
}
/**
* One teardown for the whole per-session axis (decision 12): the scope
* fiber (cascading every actx-registered effect: input shell, slash
* controller, popup, plugin stores, listeners), the session-keyed slot
* stores, and the Session instance itself — the host session log is the
* durable truth, a reopen lazily rebuilds and backfills via open().
*/
private dropScope(id: SessionId, record: ScopeRecord): void {
void record.fiber.dispose()
// Release the Session's dispatch point with the scope it belongs to (a
// surviving instance — the live Intent — rebinds when resolve re-mints).
record.binding.session.unbindScope()
// Optional lookup: slots and sessions are sibling services with no
// declared dependency; a slots-less boot (object-layer tests) skips.
this.rootCtx.get('slots')?.pruneStoreScope(id)
this.manager.drop(id)
}
/** Run deferred teardowns whose session is no longer staged (called when the stage moves). */
private sweepDeferred(): void {
for (const id of [...this.deferredRemovals]) {
/* v8 ignore next -- defensive: only the staged id ever defers, and every
* stage move sweeps first, so the set cannot contain the id the stage just
* moved to; kept as a guard against future extra sweep call sites. */
if (id === this.watched) continue
// Eligible again? (A re-added id cancels the deferred teardown.)
if (this.eligible(id)) {
this.deferredRemovals.delete(id)
continue
}
const record = this.scopes.get(id)
this.deferredRemovals.delete(id)
/* v8 ignore next -- defensive: prune deletes a scope and its deferral
* together, so a deferred id always still owns its record; kept so a
* future teardown path cannot double-dispose. */
if (record !== undefined) {
this.scopes.delete(id)
this.dropScope(id, record)
}
}
}
}

View File

@@ -1,21 +1,19 @@
// Session: wraps every contract call that needs a sessionId + all conversation state for this
// session (design §A.2/§A.9/§D.2/§D.3). Instances are resident (ruling 2): never destroyed once
// created, they keep consuming mux frames in the background; React connects directly via
// subscribe/getSnapshot.
// 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 { SessionEvent } from '@deepseek-ai/dsh-session/types'
import type {
HistoryEntry, IApiClient, ModelTarget, MuxFrame, RpcError, RpcId, RpcResult,
SessionId, SessionModels, ToolEventView,
HistoryEntry, IApiClient, MuxFrame, RpcError, RpcId, RpcResult,
SessionId, ToolEventView,
} 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'
import type { ObservableSnapshot } from '../contract/store.ts'
import type {
ConversationNode, ConversationSnapshot, ModelSelectionSnapshot, OpenState, PromptError,
RunningToolCall,
CodeSubCall, ComposerPhase, ConversationNode, ConversationSnapshot, OpenState,
PromptError, QueuedMessage, RunningToolCall,
} from './conversation.ts'
import type { PendingInteraction } from './pending.ts'
import { PendingWait } from './pending.ts'
@@ -23,14 +21,45 @@ import { FoldAdapter } from './fold-adapter.ts'
import { Notifier } from './notifier.ts'
import { PartialAccumulator } from './partial.ts'
/** Messages per page (F.4 ledger: promote to Config at graduation; every call site references this constant). */
/** Messages requested per history page. */
export const PAGE_MESSAGES = 50
/** Manager-owned observers of a Session object's local state edges. */
export interface SessionOptions {
/**
* First ACCEPTED prompt on a blank session (fires at most once, on the
* prompt RPC's success response): the manager mirrors the blank→false flip
* into its list row so the session surfaces without waiting for a host
* frame. Acceptance is the flip point because it proves the user message
* is in the host log; a rejected first prompt keeps the session blank
* (hidden, still reusable by connectWorkspace).
*/
onEngaged?(session: Session): void
}
/** Queue-row preview cap: the dock renders one line, the full content never leaves the host mirror. */
const QUEUE_PREVIEW_CHARS = 200
/** Internal inbox-mirror entry: the snapshot row plus the retirement-matching fields the frames carry. */
interface QueuedEntry {
row: QueuedMessage
steering: boolean
/** JSON-serialized MessageSource (steering retirement matches by source, the host-mirror precedent). */
sourceJson: string
}
/** Single-line queue-row preview: text blocks flattened, non-text as tags, capped by code point. */
function queuePreviewOf(content: readonly ContentBlock[]): string {
const flat = content
.map(block => (block.type === 'text' ? block.text : `[${block.type}]`))
.join(' ').replace(/\s+/g, ' ').trim()
const chars = Array.from(flat)
return chars.length > QUEUE_PREVIEW_CHARS ? `${chars.slice(0, QUEUE_PREVIEW_CHARS).join('')}` : flat
}
/**
* Per-session state owner: event window + fold + partial, snapshot out via
* subscribe/getSnapshot (see the web client architecture RFC). Bare source
* only (store migration): the React machinery binds the per-cell useSession
* hook at its own seam — no selector hook member lives on the data layer.
* Owns a session's event window, derived conversation state, and observable
* snapshot. React bindings remain outside this data layer.
*/
export class Session implements ObservableSnapshot<ConversationSnapshot> {
// ---- Window and derived state (all private; the snapshot is the only read surface) ----
@@ -55,8 +84,7 @@ export class Session implements ObservableSnapshot<ConversationSnapshot> {
* Derived from window events (turn/end sweep) — rebuilt by rebuildDerivedFromWindow like partial/openCalls. */
private frozenNodes: ConversationNode[] = []
private pending = new Map<string, PendingInteraction>()
// Revision counters + caches backing the snapshot's reference-stability contract (§A.9.4/§C.2,
// audit S5): buildSnapshot reuses the previous array when the revision is unchanged, so
// 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
// tool card and pending card). Mutation sites bump the matching revision. partial needs no
// counter — PartialAccumulator.toPartial already returns a cached reference when unchanged.
@@ -64,26 +92,33 @@ export class Session implements ObservableSnapshot<ConversationSnapshot> {
private callsCache: { rev: number; value: RunningToolCall[] } | null = null
private pendingRev = 0
private pendingCache: { rev: number; value: PendingInteraction[] } | null = null
/** Inbox mirror (session/queued frames + mux-open baseline). Queue frames never hit history,
* so this is stream-only state: reconnect clears it and the fresh baseline re-populates. */
private queued: QueuedEntry[] = []
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[]>()
private dispatchesRev = 0
private dispatchesCache: { rev: number; value: ReadonlyMap<string, readonly CodeSubCall[]> } | null = null
private running = false
/**
* Sticky send marker, private input of the composerPhase derivation: set
* synchronously before prompt()'s first await, never reset — the blank →
* engaging edge of the phase machine (see ComposerPhase).
*/
private promptAttempted = false
/** Empty-log mirror (see ConversationSnapshot.blank); monotone false once flipped. */
private blankBit = false
private removed = false
private promptError: PromptError | null = null
private lastAgentError: string | null = null
private modelSelection: ModelSelectionSnapshot = {
current: null,
groups: [],
failures: [],
status: 'idle',
error: null,
}
/** Latest model-directory/selection operation; stale responses drop all writes. */
private modelGeneration = 0
/** Failed selection target; null means the retryable operation is a directory refresh. */
private modelRetryTarget: ModelTarget | null = null
/** Buffer for live events arriving while open/resync is in flight (stitched by seq once history lands, §D.3). */
/** Live events buffered during open/resync and stitched by sequence once history lands. */
private liveBuffer: { event: SessionEvent; view: ToolEventView | undefined }[] = []
/** Gap-repair (resync-lite) in flight: acceptLiveEvent detours to liveBuffer until the tail page lands (audit S3). */
/** Gap repair in flight; live events detour to the buffer until the tail page lands. */
private stitching = false
/** subscribed.lastSeq baseline (gap detection; null when no subscribed frame arrived — degrade to the liveBuffer dedup path). */
private subscribedLastSeq: number | null = null
@@ -92,11 +127,46 @@ export class Session implements ObservableSnapshot<ConversationSnapshot> {
private readonly notifier = new Notifier(() => {
this.snapshotCache = this.buildSnapshot()
})
/**
* Agent-scoped cordis context, bound once by SessionsService when it
* mints the scope (the client mirror of the host Agent's loopCtx). The
* Session dispatches its own scoped events through it; undefined means
* unbound (bare object-layer construction) or already pruned — both skip
* dispatch-dependent behavior rather than fail.
*/
private actx: Context | undefined
constructor(readonly sessionId: SessionId, private readonly api: IApiClient) {
/**
* @param sessionId - Host session identity (client sessions are always Host-born).
* @param api - shared wire client.
* @param options - optional manager-owned state observers.
*/
constructor(
readonly sessionId: SessionId,
private readonly api: IApiClient,
private readonly options: SessionOptions = {},
) {
this.snapshotCache = this.buildSnapshot()
}
/**
* Bind the Agent-scoped context minted by SessionsService (single write;
* a second bind is a wiring error and throws). Direction stays one-way at
* the seam: consumers still reach the Session via `sessions.sessionOf`,
* while the Session holds its own dispatch point (host Agent.loopCtx
* mirror).
* @param actx - the agent's scoped context.
*/
bindScope(actx: Context): void {
if (this.actx !== undefined) throw new Error(`session ${this.sessionId} already has a bound scope`)
this.actx = actx
}
/** Release the bound scope at prune time (a later rebind accompanies a freshly minted scope). */
unbindScope(): void {
this.actx = undefined
}
// ---- Operations ----
/**
@@ -108,6 +178,10 @@ export class Session implements ObservableSnapshot<ConversationSnapshot> {
async prompt(content: ContentBlock[], mode: 'queue' | 'steer'): Promise<RpcResult<{ accepted: true }>> {
this.promptError = null
this.lastAgentError = null
// Synchronous, before the first await: the blank → engaging edge must be
// visible on the session area's very first frame when a caller sends
// ahead of navigation (first-send flow).
this.promptAttempted = true
this.notifier.markDirty()
let result: RpcResult<{ accepted: true }>
try {
@@ -118,6 +192,18 @@ export class Session implements ObservableSnapshot<ConversationSnapshot> {
if (!result.ok) {
this.promptError = { op: 'send', error: result.error }
this.notifier.markDirty()
return result
}
// Blank flips on ACCEPTANCE, not attempt: an accepted prompt has logged
// its user/message on the host (events.length > 0 is fact, not
// optimism), while a rejected first prompt must keep the session blank
// — the client-side blank mirror only ever lowers, so flipping early on
// a failure would surface the session forever and strip its
// connectWorkspace reuse eligibility against the host's authority.
if (this.blankBit) {
this.blankBit = false
this.options.onEngaged?.(this)
this.notifier.markDirty()
}
return result
}
@@ -140,102 +226,6 @@ export class Session implements ObservableSnapshot<ConversationSnapshot> {
return result
}
/**
* Refresh the advisory provider/model directory. Independent provider
* failures remain in a successful snapshot; whole-request failures preserve
* the last usable groups and current target.
* @returns the model-directory RPC result.
*/
async refreshModels(): Promise<RpcResult<SessionModels>> {
const generation = ++this.modelGeneration
this.modelRetryTarget = null
this.modelSelection = {
...this.modelSelection,
status: 'loading',
error: null,
}
this.notifier.markDirty()
let result: RpcResult<SessionModels>
try {
result = (await this.api.sessions.models({ sessionId: this.sessionId })).result
} catch (error: unknown) {
result = transportError(error)
}
if (generation !== this.modelGeneration) return result
this.modelSelection = result.ok
? {
current: result.value.current,
groups: result.value.groups,
failures: result.value.failures,
status: 'ready',
error: null,
}
: {
...this.modelSelection,
status: 'error',
error: result.error,
}
this.notifier.markDirty()
return result
}
/**
* Select the complete route for this session. The host snapshots it at the
* next prompt-assembly boundary, so running work keeps its assembled target.
* @param target - Provider and provider-owned model id.
* @returns the selection RPC result.
*/
async selectModel(target: ModelTarget): Promise<RpcResult<{ selected: ModelTarget }>> {
const generation = ++this.modelGeneration
this.modelRetryTarget = target
this.modelSelection = {
...this.modelSelection,
status: 'selecting',
error: null,
}
this.notifier.markDirty()
let result: RpcResult<{ selected: ModelTarget }>
try {
result = (await this.api.sessions.selectModel({
sessionId: this.sessionId,
provider: target.provider,
model: target.model,
})).result
} catch (error: unknown) {
result = transportError(error)
}
if (generation !== this.modelGeneration) return result
if (result.ok) this.modelRetryTarget = null
this.modelSelection = result.ok
? {
...this.modelSelection,
current: result.value.selected,
status: 'ready',
error: null,
}
: {
...this.modelSelection,
status: 'error',
error: result.error,
}
this.notifier.markDirty()
return result
}
/**
* Repeat the operation that produced the visible model error.
* A failed selection retains its exact target; directory failures refresh.
* @returns Whether a model selection succeeded; directory retries return false.
*/
async retryModelOperation(): Promise<boolean> {
const target = this.modelRetryTarget
if (target === null) {
await this.refreshModels()
return false
}
return (await this.selectModel(target)).ok
}
/** First open: pull the tail page (idempotent — in-flight/already-open returns the existing promise). */
open(): Promise<void> {
if (this.openState === 'open') return Promise.resolve()
@@ -290,6 +280,11 @@ export class Session implements ObservableSnapshot<ConversationSnapshot> {
* in-flight open first — its history request rode the dead connection and must not settle
* the fresh generation into 'error' (audit S4). */
async resync(): Promise<void> {
// The queue mirror is NOT cleared here: onConnected (which drives resync)
// races the mux frames — the fresh generation's baseline may have landed
// already, and the host never resends it. The mirror re-baselines on the
// session/subscribed frame instead (same stream as the queue snapshot
// that follows it, so ordering is guaranteed).
if (this.openState === 'cold') return // never opened: no window to rebuild (doOpen flips to 'loading' synchronously, so cold implies no in-flight open)
this.openGeneration++
this.openPromise = null
@@ -338,12 +333,35 @@ export class Session implements ObservableSnapshot<ConversationSnapshot> {
handleMuxEnvelope(rpcId: RpcId, frame: MuxFrame): void {
switch (frame.type) {
case 'session/event': {
this.retireQueued(frame.event)
this.acceptLiveEvent(frame.event, frame.view)
return
}
case 'session/queued': {
// Row key: the enqueueing prompt's rpcId when it rode this wire (the
// provisional-echo reconciliation key); otherwise the frame envelope id.
const key = 'rpcId' in frame.source ? String(frame.source.rpcId) : `f:${rpcId}`
this.queued.push({
row: { key, preview: queuePreviewOf(frame.content) },
steering: frame.steering,
sourceJson: JSON.stringify(frame.source),
})
this.queueRev++
this.notifier.markDirty()
return
}
case 'session/subscribed': {
this.subscribedLastSeq = frame.lastSeq
return // pure baseline bookkeeping, no visible change
// New mux-generation baseline: the host pushes this session's queue
// snapshot AFTER the subscribed frame on the same stream, so the
// stale mirror clears here — race-free against onConnected/resync
// timing (clearing there could wipe a baseline that already landed).
if (this.queued.length > 0) {
this.queued = []
this.queueRev++
this.notifier.markDirty()
}
return
}
case 'approval/requested': {
const { type: _type, sessionId: _sid, ...payload } = frame
@@ -380,11 +398,40 @@ export class Session implements ObservableSnapshot<ConversationSnapshot> {
* @param running - the new running state.
*/
handleRunning(running: boolean): void {
// Leave-running sweep (host queuedMirror precedent): discard paths (cancel,
// terminal steering drop) have no per-entry frame, so ANY not-running signal
// with a nonempty mirror clears it — checked before the equality return so a
// stale replay on an already-idle session still sweeps.
if (!running && this.queued.length > 0) {
this.queued = []
this.queueRev++
this.notifier.markDirty()
}
// Turn-start conversion: a blank session never runs, so the first
// running:true proves another端's first message landed (设计稿 2.2).
if (running && this.blankBit) {
this.blankBit = false
this.notifier.markDirty()
}
if (this.running === running) return
this.running = running
this.notifier.markDirty()
}
/**
* Blank-bit relay from the authoritative summary source (list baseline and
* the session-added frame). Monotone: once any signal (local first send,
* running flip, an earlier summary) cleared it, a stale true never
* re-blanks.
* @param blank - the summary's derived empty-log bit.
*/
handleBlank(blank: boolean): void {
if (blank === this.blankBit) return
if (blank && (this.promptAttempted || this.running)) return
this.blankBit = blank
this.notifier.markDirty()
}
/** host/session-removed relay: flag the snapshot (instance survives — resident-instance rule). */
handleRemoved(): void {
this.removed = true
@@ -400,8 +447,7 @@ export class Session implements ObservableSnapshot<ConversationSnapshot> {
this.notifier.markDirty()
}
/** Instance-eviction hook, reserved no-op (design §F.6): resident instances are never destroyed
* in v1; an eviction policy lands here (unsubscribe, drop buffers) without touching call sites. */
/** No-op because session instances remain resident. */
dispose(): void {}
// ---- 私有 ----
@@ -426,7 +472,6 @@ export class Session implements ObservableSnapshot<ConversationSnapshot> {
this.openError = null
this.notifier.markDirty()
try {
let modelGeneration = this.modelGeneration
let { result } = await this.api.sessions.history({ sessionId: this.sessionId, maxMessages: PAGE_MESSAGES })
if (generation !== this.openGeneration) return
if (!result.ok) {
@@ -434,24 +479,13 @@ export class Session implements ObservableSnapshot<ConversationSnapshot> {
this.openError = result.error
return
}
this.installWindow(
result.value.events,
result.value.hasMore,
modelGeneration === this.modelGeneration ? result.value.modelTarget : undefined,
)
this.installWindow(result.value.events, result.value.hasMore)
// Gap detection (§D.3-4): baseline past the window tail and liveBuffer did not cover it -> pull the tail page once more.
const tailSeq = this.windowTailSeq()
if (this.subscribedLastSeq !== null && tailSeq !== null && this.subscribedLastSeq > tailSeq) {
modelGeneration = this.modelGeneration
result = (await this.api.sessions.history({ sessionId: this.sessionId, maxMessages: PAGE_MESSAGES })).result
if (generation !== this.openGeneration) return
if (result.ok) {
this.installWindow(
result.value.events,
result.value.hasMore,
modelGeneration === this.modelGeneration ? result.value.modelTarget : undefined,
)
}
if (result.ok) this.installWindow(result.value.events, result.value.hasMore)
}
this.openState = 'open'
} catch (error) {
@@ -469,30 +503,13 @@ export class Session implements ObservableSnapshot<ConversationSnapshot> {
* Stitching MUST NOT route through acceptLiveEvent: openState is still 'loading' here
* (doOpen flips it after install), so recursing would push every buffered event straight
* back into liveBuffer where nothing ever drains it — a silent drop loop (audit S1). */
private installWindow(entries: HistoryEntry[], hasMore: boolean, modelTarget?: ModelTarget): void {
private installWindow(entries: HistoryEntry[], hasMore: boolean): void {
this.events = entries.map(e => e.event)
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.rebuildDerivedFromWindow()
if (modelTarget !== undefined) {
const current = this.modelSelection.current
if (
current === null
|| current.provider !== modelTarget.provider
|| current.model !== modelTarget.model
|| this.modelSelection.error !== null
) {
this.modelRetryTarget = null
this.modelSelection = {
...this.modelSelection,
current: modelTarget,
status: this.modelSelection.groups.length > 0 ? 'ready' : 'idle',
error: null,
}
}
}
const buffered = this.liveBuffer
this.liveBuffer = []
for (const item of buffered) this.appendLive(item.event, item.view)
@@ -537,16 +554,11 @@ export class Session implements ObservableSnapshot<ConversationSnapshot> {
if (this.stitching) return
this.stitching = true
const generation = this.openGeneration
const modelGeneration = this.modelGeneration
try {
const { result } = await this.api.sessions.history({ sessionId: this.sessionId, maxMessages: PAGE_MESSAGES })
// Failure or superseded by a full resync: drop — the resync path rebuilds and clears the buffer itself.
if (result.ok && generation === this.openGeneration && this.openState === 'open') {
this.installWindow(
result.value.events,
result.value.hasMore,
modelGeneration === this.modelGeneration ? result.value.modelTarget : undefined,
)
this.installWindow(result.value.events, result.value.hasMore)
}
} catch (error) {
console.error('[web-runtime] gap repair failed:', error)
@@ -555,9 +567,89 @@ export class Session implements ObservableSnapshot<ConversationSnapshot> {
}
}
/** Consumption-event retirement, mirroring the host queuedMirror rules: a message-triggered
* turn/start claims the oldest non-steering entry; a steering/message drains the oldest
* steering entry with the same source (loop-authored steering matches nothing and drops none). */
private retireQueued(event: SessionEvent): void {
if (this.queued.length === 0) return
let index = -1
if (event.type === 'turn/start') {
if (event.data.trigger.kind !== 'message') return
index = this.queued.findIndex(entry => !entry.steering)
} else if (event.type === 'steering/message') {
const source = JSON.stringify(event.data.source)
index = this.queued.findIndex(entry => entry.steering && entry.sourceJson === source)
} else {
return
}
if (index < 0) return
this.queued.splice(index, 1)
this.queueRev++
this.notifier.markDirty()
}
/** Per-event side effects (right column of the §A.9 dispatch table):
* chunk accumulation / partial clear on finalize / openCalls add-remove. */
private applyEventSideEffects(event: SessionEvent, view?: ToolEventView): void {
// 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
// wire consumer narrows them structurally — the same posture as every
// other cross-wire event payload.
if ((event.type as string) === 'tool/code-dispatch-start') {
// A started sub-dispatch enters the index as a RunningToolCall — the
// exact shape a native in-flight call renders from — under its parent
// run_code callId; it never joins the surface flow.
const data = event.data as unknown as {
parentCallId: string
subCallId: string
name: string
arguments: unknown
}
const running: CodeSubCall = {
callId: data.subCallId, name: data.name,
argsRaw: JSON.stringify(data.arguments),
turn: 0, step: 0, time: event.time, callView: null,
}
const siblings = this.codeDispatches.get(data.parentCallId) ?? []
this.codeDispatches.set(data.parentCallId, [...siblings, running])
this.dispatchesRev++
return
}
if ((event.type as string) === 'tool/code-dispatch') {
// Settlement replaces the running entry in place (same array position,
// so parallel sub-calls keep their start order) with the
// ToolResultNode form; a settle with no observed start (history window
// cut mid-pair, or a pre-start-event log) appends directly.
const data = event.data as unknown as {
parentCallId: string
subCallId: string
name: string
arguments: unknown
isError: boolean
content: ContentBlock[]
}
const siblings = this.codeDispatches.get(data.parentCallId) ?? []
const at = siblings.findIndex(sub => sub.callId === data.subCallId)
const started = at === -1 ? undefined : siblings[at]
const settled: CodeSubCall = {
kind: 'tool-result', seq: event.seq, time: event.time,
callId: data.subCallId,
call: { name: data.name, argsRaw: JSON.stringify(data.arguments) },
// Duration source: the paired start's time when observed; null =
// unknown (settle-only window), matching the native tool-result
// contract so views never present a fabricated zero duration.
callTime: started === undefined ? null : started.time,
content: data.content, isError: data.isError,
callView: null, resultView: null,
}
this.codeDispatches.set(
data.parentCallId,
at === -1 ? [...siblings, settled] : siblings.map((sub, index) => (index === at ? settled : sub)),
)
this.dispatchesRev++
return
}
switch (event.type) {
case 'assistant/chunk': {
const { turn, step, chunk } = event.data
@@ -576,7 +668,7 @@ export class Session implements ObservableSnapshot<ConversationSnapshot> {
case 'tool/call': {
this.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,
turn: event.data.turn, step: event.data.step, time: event.time,
callView: view?.for === 'call' ? view.view : null,
})
this.callsRev++
@@ -597,7 +689,8 @@ export class Session implements ObservableSnapshot<ConversationSnapshot> {
if (visible) {
// Fractional seq: strictly after every event of this turn (all < turn/end seq), before the next turn.
this.frozenNodes.push({
kind: 'assistant', seq: event.seq - 0.9, turn: this.partial.turn, step: this.partial.step,
kind: 'assistant', seq: event.seq - 0.9, time: event.time,
turn: this.partial.turn, step: this.partial.step,
blocks, interrupted: true,
})
this.frozenRev++
@@ -611,8 +704,10 @@ export class Session implements ObservableSnapshot<ConversationSnapshot> {
this.callsRev++
// The spinner card becomes an interrupted terminal card (never vanishes mid-flow).
this.frozenNodes.push({
kind: 'tool-result', seq: event.seq - 0.8 + callOffset++ * 0.01, callId,
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,
})
@@ -634,6 +729,8 @@ export class Session implements ObservableSnapshot<ConversationSnapshot> {
this.callsRev++
this.frozenNodes = []
this.frozenRev++
this.codeDispatches = new Map()
this.dispatchesRev++
for (let i = 0; i < this.events.length; i++) {
const event = this.events[i]
/* v8 ignore next -- dense-array guard: i stays within events.length, so the undefined arm needs a sparse array no caller builds. */
@@ -666,22 +763,50 @@ export class Session implements ObservableSnapshot<ConversationSnapshot> {
if (this.pendingCache === null || this.pendingCache.rev !== this.pendingRev) {
this.pendingCache = { rev: this.pendingRev, value: [...this.pending.values()] }
}
if (this.dispatchesCache === null || this.dispatchesCache.rev !== this.dispatchesRev) {
this.dispatchesCache = { rev: this.dispatchesRev, value: new Map(this.codeDispatches) }
}
if (this.queueCache === null || this.queueCache.rev !== this.queueRev) {
this.queueCache = { rev: this.queueRev, value: this.queued.map(entry => entry.row) }
}
const partial = this.partial?.toPartial() ?? null
return {
sessionId: this.sessionId,
nodes,
foldDegraded: degraded,
partial: this.partial?.toPartial() ?? null,
partial,
runningCalls: this.callsCache.value,
pending: this.pendingCache.value,
codeDispatches: this.dispatchesCache.value,
queue: this.queueCache.value,
running: this.running,
composerPhase: derivePhase(
nodes.length > 0 || partial !== null || this.running || this.pendingCache.value.length > 0,
this.promptAttempted,
),
removed: this.removed,
openState: this.openState,
openError: this.openError,
hasMore: this.hasMore,
loadingOlder: this.loadingOlder,
promptError: this.promptError,
blank: this.blankBit,
lastAgentError: this.lastAgentError,
modelSelection: this.modelSelection,
}
}
}
/**
* The composerPhase judgment — the single site that knows the predicate
* (consumers switch on the result, never re-derive). Monotone per session
* object: `hasContent` only grows within a window and `promptAttempted` is
* sticky, so blank → engaging → active never steps back; a failed first
* prompt stays engaging (retry semantics — see ComposerPhase).
* @param hasContent - any conversation material exists (nodes, partial, running turn, pending waits).
* @param promptAttempted - a prompt was initiated on this session object.
* @returns the derived phase.
*/
function derivePhase(hasContent: boolean, promptAttempted: boolean): ComposerPhase {
if (hasContent) return 'active'
return promptAttempted ? 'engaging' : 'blank'
}

View File

@@ -235,13 +235,17 @@ export class SlotsService extends Service {
}
}
/** Build (once) the host face the installed renderer reads; sessions resolve lazily at first render. */
/** Build once after both object-layer services mount; per-session provide bundles still resolve lazily. */
private hostFace(): SlotRendererHost {
if (this._host !== undefined) return this._host
const sessions = this.ctx.get('sessions')
if (sessions === undefined) {
throw new Error("renderSlot('root') before the sessions service mounted — boot order puts runtime apply first")
}
const workspaces = this.ctx.get('workspaces')
if (workspaces === undefined) {
throw new Error("renderSlot('root') before the workspaces service mounted — boot order puts runtime apply first")
}
// Identity-stable view: current rides the list snapshot (arbitrated), but
// the provider consumes it as its own observable; one cached object keeps
// the renderer's per-source hook cache stable.
@@ -260,8 +264,10 @@ export class SlotsService extends Service {
sessions: {
list: sessions.list,
current,
cell: id => sessions.cell(id),
provideInfo: id => sessions.provideInfo(id),
maybeProvideInfo: id => sessions.maybeProvideInfo(id),
},
workspaces: { list: workspaces.list },
}
return this._host
}
@@ -270,13 +276,13 @@ export class SlotsService extends Service {
private resolveStore(handle: EngineStoreHandle, sessionId: string | undefined): StoreInstanceLike {
const record = this._stores.get(handle)
if (record === undefined) throw new Error('store handle is not registered (entry unloaded, or the handle never went through register)')
const key = record.scope === 'session' ? sessionId : ROOT_INSTANCE_KEY
if (key === undefined) throw new Error('session-scoped store resolution requires a session id')
const key = record.scope === 'root' ? ROOT_INSTANCE_KEY : sessionId
if (key === undefined) throw new Error(`${record.scope} store resolution requires a session id`)
let instance = record.instances.get(key)
if (instance === undefined) {
// Session instances get the scope key (the engine suffixes the persist
// key per session); root instances stay keyless.
instance = record.scope === 'session' ? handle.create(key) : handle.create()
instance = record.scope === 'root' ? handle.create() : handle.create(key)
record.instances.set(key, instance)
}
return instance

View File

@@ -0,0 +1,236 @@
/** Workspace baseline, incremental-frame, and unary-action owner. */
import type {
HostFrame, IApiClient, RpcError, RpcRequest, RpcResult, SessionId, WorkspaceId, WorkspaceView,
} from '@deepseek-ai/dsh-client-connection/client'
import { transportError } from '@deepseek-ai/dsh-host-apiproxy/api'
import { mergeOrderedBaseline } from '../ordered-baseline.ts'
import { Notifier } from '../sessions/notifier.ts'
import { Workspace, type WorkspaceCreateInput } from './workspace.ts'
/** Monotone workspace-list arrival lifecycle. */
export type WorkspaceListPhase = 'pending' | 'ready'
/** Immutable workspace-list snapshot. */
export interface WorkspaceListSnapshot {
items: readonly WorkspaceView[]
state: 'idle' | 'loading' | 'error'
phase: WorkspaceListPhase
error: RpcError | null
}
/** Workspace object cluster driven by one list baseline and changed-frame upserts. */
export class WorkspaceManager {
private items: Workspace[] = []
private itemViewsSource: readonly Workspace[] | null = null
private itemViewsCache: readonly WorkspaceView[] = []
private state: WorkspaceListSnapshot['state'] = 'idle'
private phase: WorkspaceListPhase = 'pending'
private error: RpcError | null = null
private inflight: Promise<void> | null = null
private refreshFrames: WorkspaceView[] | null = null
private snapshotCache: WorkspaceListSnapshot
private readonly notifier = new Notifier(() => {
this.snapshotCache = this.buildSnapshot()
})
/** @param api - shared wire client. */
constructor(private readonly api: IApiClient) {
this.snapshotCache = this.buildSnapshot()
}
/**
* Refresh from workspace.list. The first successful response establishes
* Host order; later responses update membership and values without moving
* identities already visible to the client. Frames arriving during the RPC
* are replayed over its response.
* @returns the shared in-flight refresh.
*/
refresh(): Promise<void> {
if (this.inflight !== null) return this.inflight
this.state = 'loading'
this.error = null
const established = this.itemViews()
const frames: WorkspaceView[] = []
this.refreshFrames = frames
this.notifier.markDirty()
this.inflight = (async () => {
try {
const { result } = await this.api.workspace.list({})
if (result.ok) {
let items = this.phase === 'pending'
? result.value.items
: mergeOrderedBaseline(established, result.value.items, workspace => workspace.workspaceId)
for (const workspace of frames) items = upsertWorkspace(items, workspace)
this.installViews(items)
this.state = 'idle'
this.phase = 'ready'
} else {
this.state = 'error'
this.error = result.error
}
} catch (error) {
this.state = 'error'
const folded = transportError<never>(error)
/* v8 ignore next -- transportError always returns the failure branch. */
this.error = folded.ok ? null : folded.error
} finally {
this.refreshFrames = null
this.inflight = null
this.notifier.markDirty()
}
})()
return this.inflight
}
/**
* 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.
* @returns the wire result.
*/
async create(input: WorkspaceCreateInput): Promise<RpcResult<{ workspace: WorkspaceView; created: boolean }>> {
const workspace = new Workspace(this.api, input)
const completion = workspace.materialize()
if (completion === undefined) throw new Error('a local Workspace must be materializable')
const result = await completion
if (result.ok) this.upsert(result.value.workspace, workspace)
return result
}
/**
* Rename a Workspace, then publish its returned snapshot without waiting
* for the changed frame.
* @param workspaceId - target workspace.
* @param title - new display title.
* @returns the wire result.
*/
async rename(workspaceId: WorkspaceId, title: string): Promise<RpcResult<{ workspace: WorkspaceView }>> {
const { result } = await this.api.workspace.rename({ workspaceId, title })
if (result.ok) this.upsert(result.value.workspace)
return result
}
/**
* Move a session within its Workspace's manual order, then publish the
* returned snapshot without waiting for the changed frame.
* @param workspaceId - owning workspace.
* @param sessionId - accounted session to move.
* @param beforeSessionId - accounted anchor to insert before; omitted appends.
* @returns the wire result.
*/
async insertSessionBefore(
workspaceId: WorkspaceId,
sessionId: SessionId,
beforeSessionId?: SessionId,
): Promise<RpcResult<{ workspace: WorkspaceView }>> {
const { result } = await this.api.workspace.insertSessionBefore({
workspaceId, sessionId,
...beforeSessionId === undefined ? {} : { beforeSessionId },
})
if (result.ok) this.upsert(result.value.workspace)
return result
}
/**
* Host-frame entry. Non-workspace frames are ignored so the runtime can
* fan one host stream out to both object managers.
* @param envelope - host stream envelope.
*/
handleHostEnvelope(envelope: RpcRequest<HostFrame>): void {
if (envelope.payload.type === 'host/workspace-changed') this.upsert(envelope.payload.workspace)
}
/** Re-pull the baseline after each connection generation. */
handleConnected(): void {
void this.refresh()
}
/**
* Subscribe to workspace snapshot invalidation.
* @param listener - snapshot invalidation callback.
* @returns unsubscribe function.
*/
subscribe(listener: () => void): () => void {
return this.notifier.subscribe(listener)
}
/**
* Read the cached workspace snapshot after flushing pending notifications.
* @returns the cached workspace snapshot.
*/
getSnapshot(): WorkspaceListSnapshot {
this.notifier.ensureFresh()
return this.snapshotCache
}
private buildSnapshot(): WorkspaceListSnapshot {
return {
items: this.itemViews(),
state: this.state,
phase: this.phase,
error: this.error,
}
}
/** Upsert one Host view, optionally retaining the local object that materialized it. */
private upsert(view: WorkspaceView, identity?: Workspace): void {
this.refreshFrames?.push(view)
const index = this.items.findIndex(item => item.getSnapshot().view?.workspaceId === view.workspaceId)
// Mutation responses and changed frames race (two carriers, no ordering):
// reject a snapshot strictly older than the installed projection so a
// late unary response cannot roll back a newer frame.
const installed = index === -1 ? undefined : this.items[index]?.getSnapshot().view
if (installed !== undefined && Date.parse(view.updatedAt) < Date.parse(installed.updatedAt)) return
if (identity !== undefined) {
this.items = index === -1
? [identity, ...this.items]
: this.items.map((item, position) => position === index ? identity : item)
} else if (index === -1) {
this.items = [new Workspace(this.api, view), ...this.items]
} else {
this.items[index]?.adopt(view)
this.items = [...this.items]
}
this.notifier.markDirty()
}
private installViews(views: readonly WorkspaceView[]): void {
const existing = new Map(
this.items.flatMap((workspace) => {
const view = workspace.getSnapshot().view
return view === undefined ? [] : [[view.workspaceId, workspace] as const]
}),
)
const installed = new Map<WorkspaceView['workspaceId'], Workspace>()
for (const view of views) {
const duplicate = installed.get(view.workspaceId)
if (duplicate !== undefined) {
duplicate.adopt(view)
continue
}
const workspace = existing.get(view.workspaceId) ?? new Workspace(this.api, view)
workspace.adopt(view)
installed.set(view.workspaceId, workspace)
}
this.items = [...installed.values()]
}
private itemViews(): readonly WorkspaceView[] {
if (this.itemViewsSource === this.items) return this.itemViewsCache
this.itemViewsSource = this.items
this.itemViewsCache = this.items.flatMap((workspace) => {
const view = workspace.getSnapshot().view
return view === undefined ? [] : [view]
})
return this.itemViewsCache
}
}
/** Known ids retain their position; a newly created Workspace enters first. */
function upsertWorkspace(items: readonly WorkspaceView[], workspace: WorkspaceView): WorkspaceView[] {
const index = items.findIndex(item => item.workspaceId === workspace.workspaceId)
return index === -1
? [workspace, ...items]
: items.map((item, position) => position === index ? workspace : item)
}

View File

@@ -0,0 +1,250 @@
/** WorkspacesService projects the Workspace object manager for UI consumers. */
import type { Context } from 'cordis'
import type {
IApiClient, RpcError, SessionId, WorkspaceId, WorkspaceView,
} from '@deepseek-ai/dsh-client-connection/client'
import type { SnapshotStore } from '../contract/store.ts'
import { createSnapshotStore } from '../contract/store.ts'
import type { SessionsService } from '../sessions/service.ts'
import { WorkspaceManager, type WorkspaceListPhase } from './manager.ts'
/** Workspace list plus the two-baseline readiness and default-target projection. */
export interface WorkspaceListState {
items: readonly WorkspaceView[]
state: 'idle' | 'loading' | 'error'
phase: WorkspaceListPhase
error: RpcError | null
/** True only after both workspace.list and session.list have succeeded. */
baselinesReady: boolean
/** Most recently active Workspace, derived without changing `items` order. */
recentWorkspaceId: WorkspaceId | undefined
}
/** Real Workspace object layer and Host actions. */
export class WorkspacesService {
/** UI-facing immutable projection; the manager remains wire truth. */
readonly list: SnapshotStore<WorkspaceListState>
/** Workspace baseline and frame owner. */
private readonly manager: WorkspaceManager
/** In-flight blank-session creates keyed by workspace (connectWorkspace coalescing). */
private readonly connecting = new Map<WorkspaceId, Promise<SessionId>>()
/** Guards the runtime-owned one-shot initial-selection subscription. */
private initialSelectionStarted = false
/**
* @param ctx - client root context.
* @param api - shared wire client.
* @param sessions - lower-level Session service used for recency and blank-session reuse.
*/
constructor(ctx: Context, api: IApiClient, private readonly sessions: SessionsService) {
this.manager = new WorkspaceManager(api)
this.list = createSnapshotStore<WorkspaceListState>({
items: [], state: 'idle', phase: 'pending', error: null,
baselinesReady: false, recentWorkspaceId: undefined,
})
this.manager.subscribe(() => { this.project() })
this.sessions.list.subscribe(() => { this.project() })
ctx.reflect.provide('workspaces', this, undefined)
}
/**
* Resolve the session a New Session flow lands in once this Workspace is
* chosen: reuse the workspace's existing blank session when one is in the
* list mirror, else create a fresh one on the host (`session.create` births
* the full Session+Agent — the client holds no intermediate state). The
* caller owns navigation: take the returned id to `sessions.open`.
* Resolution guarantee (both arms): the returned id is already in the list
* store and `sessions.binding(id)` resolves synchronously — draft hand-off
* may write the new scope's machine before opening.
* @param workspaceId - chosen Workspace (must be in the workspace list).
* @returns the reused or newly created session id.
*/
async connectWorkspace(workspaceId: WorkspaceId): Promise<SessionId> {
const workspace = this.list.getSnapshot().items.find(item => item.workspaceId === workspaceId)
if (workspace === undefined) throw new Error(`workspaces.connectWorkspace: unknown workspace ${workspaceId}`)
// Coalesce concurrent connects: a create's summary lands without cwd
// until the host frame arrives, so a second call inside that window
// would miss the reuse scan and mint another hidden blank session.
const inflight = this.connecting.get(workspaceId)
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).
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
}
const attempt = this.sessions.create({ workspaceId })
.finally(() => { this.connecting.delete(workspaceId) })
this.connecting.set(workspaceId, attempt)
return attempt
}
/**
* Follow the first complete Workspace/Session baseline and select a default
* session exactly once. A restored current session wins; otherwise the most
* recent Workspace is connected (reusing or creating its blank session).
* Later explicit clears stay cleared instead of retriggering this startup
* policy. A failed connect may retry on the next baseline projection.
* @returns disposer for the baseline subscription; late work cannot navigate after disposal.
*/
startInitialSelection(): () => void {
if (this.initialSelectionStarted) {
throw new Error('workspaces.startInitialSelection: already started')
}
this.initialSelectionStarted = true
let state: 'waiting' | 'connecting' | 'done' = 'waiting'
let disposed = false
const reconcile = (): void => {
if (disposed || state !== 'waiting') return
const workspace = this.list.getSnapshot()
if (!workspace.baselinesReady) return
const current = this.sessions.list.getSnapshot().current
const target = workspace.recentWorkspaceId
if (current !== undefined || target === undefined) {
state = 'done'
return
}
state = 'connecting'
void this.connectWorkspace(target).then(
(sessionId) => {
if (disposed) return
if (this.sessions.list.getSnapshot().current === undefined) {
this.sessions.open(sessionId)
}
state = 'done'
},
(reason: unknown) => {
if (disposed) return
state = 'waiting'
console.warn('initial workspace selection failed:', reason)
},
)
}
const unsubscribe = this.list.subscribe(reconcile)
reconcile()
return () => {
disposed = true
unsubscribe()
}
}
/**
* The shared New Session action behind the shell entry points (sidebar
* button, workspace browser): resolve the target Workspace — explicit wins,
* else the recent-Workspace projection — connect its blank session and
* navigate there; with no Workspace at all, clear the selection into the
* New Session view state. Connect failures are non-fatal (console
* diagnostics; the current view stays usable).
* @param workspaceId - explicit target Workspace for scoped actions.
*/
startSession(workspaceId?: WorkspaceId): void {
const target = workspaceId ?? this.list.getSnapshot().recentWorkspaceId
if (target === undefined) {
this.sessions.clear()
return
}
void this.connectWorkspace(target).then(
(sessionId) => { this.sessions.open(sessionId) },
(reason: unknown) => { console.warn('new session failed:', reason) },
)
}
/**
* Create a Workspace by name or register an existing path.
* @param input - exactly one Host create spelling.
* @returns the created or idempotently resolved Workspace.
*/
async create(input: { name: string } | { path: string }): Promise<WorkspaceView> {
const result = await this.manager.create(input)
if (!result.ok) throw new Error(`workspace create failed: ${result.error.code}: ${result.error.message}`)
return result.value.workspace
}
/**
* Rename a Workspace.
* @param workspaceId - target workspace.
* @param title - new display title (trimmed non-empty by the Host).
* @returns the renamed Workspace view.
*/
async rename(workspaceId: WorkspaceId, title: string): Promise<WorkspaceView> {
const result = await this.manager.rename(workspaceId, title)
if (!result.ok) throw new Error(`workspace rename failed: ${result.error.code}: ${result.error.message}`)
return result.value.workspace
}
/**
* Move a session within its Workspace's manual order (DOM-insertBefore-like).
* @param workspaceId - owning workspace.
* @param sessionId - accounted session to move.
* @param beforeSessionId - accounted anchor to insert before; omitted appends.
* @returns the updated Workspace view.
*/
async insertSessionBefore(
workspaceId: WorkspaceId,
sessionId: SessionId,
beforeSessionId?: SessionId,
): Promise<WorkspaceView> {
const result = await this.manager.insertSessionBefore(workspaceId, sessionId, beforeSessionId)
if (!result.ok) throw new Error(`workspace move failed: ${result.error.code}: ${result.error.message}`)
return result.value.workspace
}
/**
* Refresh the workspace baseline, reusing an in-flight pull.
* @returns completion of the current or newly started workspace baseline pull.
*/
refresh(): Promise<void> {
return this.manager.refresh()
}
/**
* Route a Host stream envelope into the Workspace object layer.
* @param envelope - validated Host stream envelope.
*/
handleHostEnvelope(envelope: Parameters<WorkspaceManager['handleHostEnvelope']>[0]): void {
this.manager.handleHostEnvelope(envelope)
}
/** Rebuild the Workspace baseline after connection. */
handleConnected(): void {
this.manager.handleConnected()
}
private project(): void {
const workspace = this.manager.getSnapshot()
const sessions = this.sessions.list.getSnapshot()
const baselinesReady = workspace.phase === 'ready' && sessions.phase === 'ready'
this.list.set({
items: workspace.items,
state: workspace.state,
phase: workspace.phase,
error: workspace.error,
baselinesReady,
recentWorkspaceId: baselinesReady ? recentWorkspace(workspace.items, sessions.byId) : undefined,
})
}
}
/** Stable tie-breaking follows Host Workspace order. */
function recentWorkspace(
workspaces: readonly WorkspaceView[],
sessions: ReturnType<SessionsService['list']['getSnapshot']>['byId'],
): WorkspaceId | undefined {
let selected: WorkspaceId | undefined
let selectedTime = Number.NEGATIVE_INFINITY
for (const workspace of workspaces) {
let latest = Number.NEGATIVE_INFINITY
for (const sessionId of workspace.sessionIds) {
const session = sessions[sessionId]
if (session !== undefined) latest = Math.max(latest, session.updatedAt)
}
if (latest === Number.NEGATIVE_INFINITY) latest = Date.parse(workspace.createdAt)
if (selected === undefined || latest > selectedTime) {
selected = workspace.workspaceId
selectedTime = latest
}
}
return selected
}

View File

@@ -0,0 +1,143 @@
/** React-free Workspace entity with a client-local materialization lifecycle. */
import type {
IApiClient, RpcResult, WorkspaceView,
} from '@deepseek-ai/dsh-client-connection/client'
import { transportError } from '@deepseek-ai/dsh-host-apiproxy/api'
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 }
/** Observable state of a client-local Workspace intent. */
export interface WorkspaceIntentSnapshot {
name: string
phase: 'ready' | 'creating'
error?: string
}
/** A Workspace is either a local intent or a materialized Host view. */
export interface WorkspaceSnapshot {
view: WorkspaceView | undefined
intent: WorkspaceIntentSnapshot | undefined
}
interface WorkspaceIntent {
input: WorkspaceCreateInput
snapshot: WorkspaceIntentSnapshot
}
/**
* Observable Workspace object whose identity survives Host materialization.
* Local instances retain their create input and failure state; materialized
* instances expose the latest Host view.
*/
export class Workspace implements ObservableSnapshot<WorkspaceSnapshot> {
private view: WorkspaceView | undefined
private intent: WorkspaceIntent | undefined
private materialization: Promise<RpcResult<{ workspace: WorkspaceView; created: boolean }>> | null = null
private snapshotCache: WorkspaceSnapshot
private readonly notifier = new Notifier(() => {
this.snapshotCache = this.buildSnapshot()
})
/**
* @param api - shared wire client.
* @param source - local create input or an existing Host Workspace view.
*/
constructor(private readonly api: IApiClient, source: WorkspaceCreateInput | WorkspaceView) {
if ('workspaceId' in source) {
this.view = source
} else {
this.intent = {
input: source,
snapshot: { name: intentName(source), phase: 'ready' },
}
}
this.snapshotCache = this.buildSnapshot()
}
/**
* Materialize this local Workspace through the Host create seam.
* Re-entry shares the in-flight completion; a materialized instance returns undefined.
* @returns the Host result, or undefined when this Workspace is already materialized.
*/
materialize(): Promise<RpcResult<{ workspace: WorkspaceView; created: boolean }>> | undefined {
if (this.materialization !== null) return this.materialization
const intent = this.intent
if (intent === undefined) return undefined
intent.snapshot = { name: intent.snapshot.name, phase: 'creating' }
this.notifier.notifyNow()
const completion = this.completeMaterialization(intent).finally(() => {
if (this.materialization === completion) this.materialization = null
})
this.materialization = completion
return completion
}
/**
* Adopt a Host view without replacing this Workspace object.
* An existing materialized identity accepts updates only for the same Workspace id.
* @param view - latest Host projection.
*/
adopt(view: WorkspaceView): void {
if (this.view !== undefined && this.view.workspaceId !== view.workspaceId) {
throw new Error('cannot adopt a different Workspace id')
}
this.view = view
this.intent = undefined
this.notifier.markDirty()
}
/**
* Subscribe to Workspace snapshot invalidation.
* @param listener - snapshot invalidation callback.
* @returns unsubscribe function.
*/
subscribe(listener: () => void): () => void {
return this.notifier.subscribe(listener)
}
/**
* Read the cached Workspace snapshot after flushing pending notifications.
* @returns the cached Workspace snapshot.
*/
getSnapshot(): WorkspaceSnapshot {
this.notifier.ensureFresh()
return this.snapshotCache
}
private async completeMaterialization(
intent: WorkspaceIntent,
): Promise<RpcResult<{ workspace: WorkspaceView; created: boolean }>> {
let result: RpcResult<{ workspace: WorkspaceView; created: boolean }>
try {
result = (await this.api.workspace.create(intent.input)).result
} catch (error) {
result = transportError(error)
}
if (this.intent !== intent) return result
if (result.ok) {
this.adopt(result.value.workspace)
} else {
intent.snapshot = {
name: intent.snapshot.name,
phase: 'ready',
error: `${result.error.code}: ${result.error.message}`,
}
this.notifier.markDirty()
}
return result
}
private buildSnapshot(): WorkspaceSnapshot {
return { view: this.view, intent: this.intent?.snapshot }
}
}
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

@@ -1,11 +1,4 @@
/**
* Runtime plugin, node half. The implementation lives entirely in the client
* half (src/client/ — SlotsService, SessionsService + object layer, and the
* shell-held ClientLoader under ./loader); consumers import the /client or
* /loader subpaths. The empty apply exists so the plugin appears in the host
* Loader (lifecycle governance + dshClient discovery). Contract:
* api-contracts v3 section 4.
*/
/** Host loader entry for the browser runtime exported from `./client` and `./loader`. */
/** Host plugin body — no host-side behavior for the runtime plugin. */
export function apply(_ctx: unknown): void {}

View File

@@ -1,5 +1,5 @@
/**
* Runtime plugin browser-half apply: slots + sessions mounting over the
* Runtime plugin browser-half apply: slots + object services mounting over the
* connection handle, stream-loop sink wiring into the object layer, and the
* fiber-scoped loop teardown.
*/
@@ -8,7 +8,9 @@ import { describe, expect, it } from 'vitest'
import type { ConnectionHandle } from '@deepseek-ai/dsh-client-connection/client'
import type { ConnectionSinks } from '@deepseek-ai/dsh-client-connection/client'
import * as RuntimeClient from '../src/client/index.ts'
import { FakeApiClient } from './fake-api.ts'
import type { SessionsService } from '../src/client/sessions/service.ts'
import type { WorkspacesService } from '../src/client/workspaces/service.ts'
import { FakeApiClient, ok } from './fake-api.ts'
interface Bench {
ctx: Context
@@ -33,29 +35,73 @@ async function mount(): Promise<Bench> {
return bench
}
async function flushMicrotasks(): Promise<void> {
for (let i = 0; i < 12; i++) await Promise.resolve()
}
describe('runtime client apply', () => {
it('mounts ctx.slots + ctx.sessions and wires the stream sinks into the manager', async () => {
it('mounts slots, Sessions, and Workspaces and fans host frames into both managers', async () => {
const bench = await mount()
expect(bench.ctx.get('slots') !== undefined).toBe(true)
// The built-in 'root' declaration ships with this package's SlotsService
// (the SlotMap 'root' merge lives here since the slot-parity rework).
expect(bench.ctx.slots.spec('root')).toEqual({ kind: 'single', scope: 'root' })
const sessions = bench.ctx.get('sessions')
const workspaces = bench.ctx.get('workspaces')
expect(sessions !== undefined).toBe(true)
expect(workspaces !== undefined).toBe(true)
if (workspaces === undefined) throw new Error('WorkspacesService missing after runtime apply')
expect(bench.sinks).toBeDefined()
// Frame sinks reach the object layer: a host session-added lands in the list store.
bench.sinks?.onHostEnvelope?.({
rpcId: 'r1' as never,
payload: { type: 'host/session-added', sessionId: 's-new' } as never,
payload: { type: 'host/session-added', blank: true, sessionId: 's-new' } as never,
})
await Promise.resolve()
expect((sessions as { list: { getSnapshot(): { ids: string[] } } }).list.getSnapshot().ids).toContain('s-new')
bench.sinks?.onHostEnvelope?.({
rpcId: 'r-workspace' as never,
payload: {
type: 'host/workspace-changed',
workspace: {
workspaceId: 'w-new', path: '/w/new', title: 'new', sessionIds: [],
createdAt: '2026-01-01T00:00:00.000Z', updatedAt: '2026-01-01T00:00:00.000Z',
},
} as never,
})
await Promise.resolve()
expect(workspaces.list.getSnapshot().items[0]?.workspaceId).toBe('w-new')
// Mux sink and onConnected route without throwing (manager semantics own the behavior).
bench.sinks?.onMuxEnvelope?.({ rpcId: 'r2' as never, payload: { type: 'stream/error', message: 'x' } as never })
bench.sinks?.onConnected?.()
})
it('selects the recent Workspace once when the first baselines have no current session', async () => {
const bench = await mount()
bench.api.onWorkspaceList = () => Promise.resolve(ok({
items: [{
workspaceId: 'w-recent', path: '/w/recent', title: 'recent', sessionIds: [],
createdAt: '2026-01-01T00:00:00.000Z', updatedAt: '2026-01-01T00:00:00.000Z',
}] as never[],
}))
bench.api.onList = () => Promise.resolve(ok({ items: [] }))
bench.sinks?.onConnected?.()
await flushMicrotasks()
const sessions = bench.ctx.get('sessions') as SessionsService
const workspaces = bench.ctx.get('workspaces') as WorkspacesService
expect(bench.api.callsOf('session.create')).toEqual([{ workspaceId: 'w-recent' }])
expect(sessions.list.getSnapshot().current).toBe('fk-new')
sessions.clear()
await workspaces.refresh()
await flushMicrotasks()
expect(sessions.list.getSnapshot().current).toBeUndefined()
expect(bench.api.callsOf('session.create')).toHaveLength(1)
})
it('stops the stream loop when the plugin fiber unloads', async () => {
const bench = await mount()
const fiber = [...bench.ctx.registry.values()].find(f => f.name?.includes('client'))

View File

@@ -26,6 +26,16 @@ export const ev = {
at(seq, { type: 'tool/call', data: { turn, step, callId, name, arguments: args } }),
toolResult: (seq: number, turn: number, callId: string, body: string, step = 0): SessionEvent =>
at(seq, { type: 'tool/result', surfaceOp: 'append', data: { turn, step, callId, content: text(body), isError: false } }),
codeDispatchStart: (seq: number, parentCallId: string, n: number, name: string, args: unknown): SessionEvent =>
at(seq, {
type: 'tool/code-dispatch-start',
data: { parentCallId, subCallId: `${parentCallId}:code:${n}`, name, arguments: args },
}),
codeDispatch: (seq: number, parentCallId: string, n: number, name: string, args: unknown, body: string, isError = false): SessionEvent =>
at(seq, {
type: 'tool/code-dispatch',
data: { parentCallId, subCallId: `${parentCallId}:code:${n}`, name, arguments: args, isError, content: text(body) },
}),
stepEnd: (seq: number, turn: number, step = 0): SessionEvent =>
at(seq, { type: 'step/end', data: { turn, step } }),
turnEnd: (seq: number, turn: number, reason: 'completed' | 'cancelled' = 'completed'): SessionEvent =>

View File

@@ -2,11 +2,25 @@
// data source on a real clock; behavior tests need per-case responses and
// deferred-controlled timing). Streams are hand pumps: pushMux/pushHost.
import type {
ClientResponse, HostFrame, IApiClient, ModelTarget, MuxFrame, RpcError, RpcReceipt,
RpcRequest, RpcResponse, SessionId, SessionModels,
ClientResponse, CommandDescriptor, CommandExecuteResult, HostFrame, IApiClient, MuxFrame,
RpcError, RpcReceipt, RpcRequest, RpcResponse, SessionId, SkillEntry,
WorkspaceId, WorkspaceView,
} from '@deepseek-ai/dsh-client-connection/client'
import { RpcId } from '@deepseek-ai/dsh-client-connection/client'
/** Programmable-default workspace row (branded id, ISO-ish times). */
function fakeWorkspace(id: string, over: Partial<WorkspaceView> = {}): WorkspaceView {
return {
workspaceId: id as WorkspaceId,
path: '/f/ws',
title: 'ws',
sessionIds: [],
createdAt: '2026-01-01T00:00:00.000Z',
updatedAt: '2026-01-01T00:00:00.000Z',
...over,
}
}
export interface Deferred<T> {
promise: Promise<T>
resolve(value: T): void
@@ -47,23 +61,10 @@ export class FakeApiClient implements IApiClient {
// Programmable slots (defaults answer OK-empty); reassign per case.
onList: (payload: unknown) => Promise<RpcResponse<{ items: never[] }>> = () => Promise.resolve(ok({ items: [] }))
onCreate: (payload: unknown) => Promise<RpcResponse<{ sessionId: SessionId }>> = () => Promise.resolve(ok({ sessionId: 'fk-new' as SessionId }))
readonly defaultModel: ModelTarget = { provider: 'deepseek', model: 'deepseek-v4-flash' }
onHistory: (payload: { sessionId: SessionId; beforeSeq?: number; maxMessages?: number })
=> Promise<RpcResponse<{ events: never[]; hasMore: boolean; modelTarget: ModelTarget }>> =
() => Promise.resolve(ok({ events: [], hasMore: false, modelTarget: this.defaultModel }))
=> Promise<RpcResponse<{ events: never[]; hasMore: boolean }>> =
() => Promise.resolve(ok({ events: [], hasMore: false }))
onModels: (payload: unknown) => Promise<RpcResponse<SessionModels>> = () => Promise.resolve(ok({
current: this.defaultModel,
groups: [{
id: 'deepseek',
name: 'DeepSeek',
models: [{ id: 'deepseek-v4-flash', name: 'DeepSeek V4 Flash' }],
}],
failures: [],
}))
onSelectModel: (payload: { provider: string; model: string }) =>
Promise<RpcResponse<{ selected: ModelTarget }>> =
payload => Promise.resolve(ok({ selected: { provider: payload.provider, model: payload.model } }))
onPrompt: (payload: unknown) => Promise<RpcResponse<{ accepted: true }>> = () => Promise.resolve(ok({ accepted: true as const }))
onCancel: (payload: unknown) => Promise<RpcResponse<{ accepted: true }>> = () => Promise.resolve(ok({ accepted: true as const }))
onDescribe: (payload: unknown) => Promise<RpcResponse<{ version: string; cwd: string; attachedSessions: number }>> =
@@ -80,9 +81,6 @@ export class FakeApiClient implements IApiClient {
create: (payload: unknown) => this.record('session.create', payload, this.onCreate(payload)),
history: (payload: { sessionId: SessionId; beforeSeq?: number; maxMessages?: number }) =>
this.record('session.history', payload, this.onHistory(payload)),
models: (payload: unknown) => this.record('session.models', payload, this.onModels(payload)),
selectModel: (payload: { provider: string; model: string }) =>
this.record('session.selectModel', payload, this.onSelectModel(payload)),
prompt: (payload: unknown) => this.record('session.prompt', payload, this.onPrompt(payload)),
cancel: (payload: unknown) => this.record('session.cancel', payload, this.onCancel(payload)),
}
@@ -91,6 +89,43 @@ export class FakeApiClient implements IApiClient {
describe: (payload: unknown) => this.record('host.describe', payload, this.onDescribe(payload)),
}
onWorkspaceList: (payload: unknown) => Promise<RpcResponse<{ items: never[] }>> = () => Promise.resolve(ok({ items: [] }))
onWorkspaceCreate: (payload: unknown) => Promise<RpcResponse<{ workspace: WorkspaceView; created: boolean }>> =
() => Promise.resolve(ok({ workspace: fakeWorkspace('fk-ws'), created: true }))
onWorkspaceRename: (payload: unknown) => Promise<RpcResponse<{ workspace: WorkspaceView }>> =
() => Promise.resolve(ok({ workspace: fakeWorkspace('fk-ws') }))
onWorkspaceInsertSessionBefore: (payload: unknown) => Promise<RpcResponse<{ workspace: WorkspaceView }>> =
() => Promise.resolve(ok({ workspace: fakeWorkspace('fk-ws') }))
readonly workspace: IApiClient['workspace'] = {
list: (payload: unknown) => this.record('workspace.list', payload, this.onWorkspaceList(payload)),
create: (payload: unknown) => this.record('workspace.create', payload, this.onWorkspaceCreate(payload)),
rename: (payload: unknown) => this.record('workspace.rename', payload, this.onWorkspaceRename(payload)),
insertSessionBefore: (payload: unknown) =>
this.record('workspace.insertSessionBefore', payload, this.onWorkspaceInsertSessionBefore(payload)),
}
// Payloads stay `unknown` (lint-lane note above); response rows are the real
// wire shapes so cases can program requires-bearing catalogs and dual-address
// skill lists without casts.
onCommandList: (payload: unknown) => Promise<RpcResponse<{ commands: CommandDescriptor[] }>>
= () => Promise.resolve(ok({ commands: [] }))
onCommandExecute: (payload: unknown) => Promise<RpcResponse<{ matched: boolean; result?: CommandExecuteResult }>>
= () => Promise.resolve(ok({ matched: false }))
onSkillList: (payload: unknown) => Promise<RpcResponse<{ skills: SkillEntry[] }>>
= () => Promise.resolve(ok({ skills: [] }))
readonly commands: IApiClient['commands'] = {
list: (payload: unknown) => this.record('command.list', payload, this.onCommandList(payload)),
execute: (payload: unknown) => this.record('command.execute', payload, this.onCommandExecute(payload)),
}
readonly skills: IApiClient['skills'] = {
list: (payload: unknown) => this.record('skill.list', payload, this.onSkillList(payload)),
}
/** When true, streams never fire onOpen (misbehaving-carrier material for the handshake timeout guard). */
suppressStreamOpen = false

View File

@@ -40,7 +40,7 @@ describe('FoldAdapter', () => {
ev.user(0, '用户'),
ev.assistant(1, 0, '助手'),
at(2, { type: 'steering/message', surfaceOp: 'append', data: { turn: 0, content: [{ type: 'text', text: '插话' }], source: { kind: 'user' } } }),
at(3, { type: 'context/message', surfaceOp: 'append', data: { content: [{ type: 'text', text: '上下文' }], source: { kind: 'plugin', plugin: 'p' } } }),
at(3, { type: 'user/message', surfaceOp: 'append', data: { content: [{ type: 'text', text: '上下文' }], source: { kind: 'plugin', plugin: 'p' } } }),
ev.toolCall(4, 0, 'c1', 'echo', '{"x":1}'),
ev.toolResult(5, 0, 'c1', '结果'),
]

View File

@@ -8,12 +8,12 @@ import type { SessionId, SessionSummary } from '@deepseek-ai/dsh-client-connecti
import { flattenLineage } from '../src/client/sessions/lineage.ts'
const s = (id: string, updatedAt: number, parent?: string): SessionSummary => ({
sessionId: id as SessionId, updatedAt, running: false,
sessionId: id as SessionId, updatedAt, running: false, blank: false,
...(parent !== undefined ? { parentSessionId: parent as SessionId } : {}),
})
describe('flattenLineage', () => {
it('sorts roots by updatedAt desc and expands children DFS with depth, children sorted too', () => {
it('keeps established root and sibling order while expanding children DFS with depth', () => {
const out = flattenLineage([
s('old-root', 10),
s('new-root', 30),
@@ -22,7 +22,7 @@ describe('flattenLineage', () => {
s('grandkid', 5, 'kid-new'),
])
expect(out.map(e => [e.sessionId, e.depth])).toEqual([
['new-root', 0], ['kid-new', 1], ['grandkid', 2], ['kid-old', 1], ['old-root', 0],
['old-root', 0], ['new-root', 0], ['kid-old', 1], ['kid-new', 1], ['grandkid', 2],
])
})

View File

@@ -12,8 +12,10 @@ import { entries, plainTurn } from './event-script.ts'
const S1 = 'fk-m1' as SessionId
const S2 = 'fk-m2' as SessionId
function summary(sessionId: SessionId, over: Partial<{ updatedAt: number; running: boolean; parentSessionId: SessionId }> = {}) {
return { sessionId, updatedAt: 100, running: false, ...over }
type SummaryOver = Partial<{ updatedAt: number; running: boolean; blank: boolean; parentSessionId: SessionId }>
function summary(sessionId: SessionId, over: SummaryOver = {}) {
return { sessionId, updatedAt: 100, running: false, blank: false, ...over }
}
describe('instances', () => {
@@ -57,7 +59,7 @@ describe('instances', () => {
})
describe('list lifecycle', () => {
it('single-flights refreshList and lands items sorted through lineage flattening', async () => {
it('single-flights refreshList and preserves the Host baseline order', async () => {
const api = new FakeApiClient()
const gate = deferred<Awaited<ReturnType<FakeApiClient['onList']>>>()
api.onList = () => gate.promise
@@ -65,12 +67,33 @@ describe('list lifecycle', () => {
const first = manager.refreshList()
const second = manager.refreshList()
expect(manager.getListSnapshot().state).toBe('loading')
gate.resolve(ok({ items: [summary(S1), summary(S2, { updatedAt: 200 })] as never[] }))
gate.resolve(ok({ items: [summary(S2, { updatedAt: 200 }), summary(S1)] as never[] }))
await Promise.all([first, second])
expect(api.callsOf('session.list')).toHaveLength(1)
const snapshot = manager.getListSnapshot()
expect(snapshot.state).toBe('idle')
expect(snapshot.items.map(i => i.sessionId)).toEqual([S2, S1]) // updatedAt desc
expect(snapshot.items.map(i => i.sessionId)).toEqual([S2, S1])
})
it('replays incremental frames over hydration and never batch-reorders established ids', async () => {
const api = new FakeApiClient()
const first = deferred<Awaited<ReturnType<FakeApiClient['onList']>>>()
api.onList = () => first.promise
const manager = new SessionManager(api)
const hydration = manager.refreshList()
manager.handleHostEnvelope({
rpcId: 'during-first' as never,
payload: { type: 'host/session-added', blank: true, sessionId: S2 },
})
first.resolve(ok({ items: [summary(S1)] as never[] }))
await hydration
expect(manager.getListSnapshot().items.map(item => item.sessionId)).toEqual([S2, S1])
api.onList = () => Promise.resolve(ok({
items: [summary(S1, { updatedAt: 900 }), summary(S2, { updatedAt: 800 })] as never[],
}))
await manager.refreshList()
expect(manager.getListSnapshot().items.map(item => item.sessionId)).toEqual([S2, S1])
})
it('keeps the error in the list snapshot on failure', async () => {
@@ -79,6 +102,26 @@ describe('list lifecycle', () => {
const manager = new SessionManager(api)
await manager.refreshList()
expect(manager.getListSnapshot()).toMatchObject({ state: 'error', error: { code: 'internal' } })
// A failed pull does not step the arrival phase: still pending.
expect(manager.getListSnapshot().phase).toBe('pending')
})
it('phase steps pending → ready on the first successful pull and never returns', async () => {
const api = new FakeApiClient()
const manager = new SessionManager(api)
expect(manager.getListSnapshot().phase).toBe('pending')
await manager.refreshList()
expect(manager.getListSnapshot().phase).toBe('ready')
// Sticky across later failures: the pull-activity axis reports the error,
// the arrival phase holds.
api.onList = () => Promise.resolve(err({ code: 'internal', message: 'down', details: {} }))
await manager.refreshList()
expect(manager.getListSnapshot()).toMatchObject({ state: 'error', phase: 'ready' })
// And across an empty re-pull (empty-with-ready = truly no sessions).
api.onList = () => Promise.resolve(ok({ items: [] as never[] }))
await manager.refreshList()
expect(manager.getListSnapshot()).toMatchObject({ state: 'idle', phase: 'ready' })
expect(manager.getListSnapshot().items).toEqual([])
})
it('merges create into the list immediately without waiting for a refresh', async () => {
@@ -116,7 +159,7 @@ describe('list lifecycle', () => {
expect(titled.items[1]?.title).toBeUndefined()
manager.handleHostEnvelope({ rpcId: 'removed' as never, payload: { type: 'host/session-removed', sessionId: S1 } })
manager.handleHostEnvelope({ rpcId: 'readded' as never, payload: { type: 'host/session-added', sessionId: S1 } })
manager.handleHostEnvelope({ rpcId: 'readded' as never, payload: { type: 'host/session-added', blank: true, sessionId: S1 } })
expect(manager.getListSnapshot().items.find(item => item.sessionId === S1)?.title).toBeUndefined()
})
@@ -155,8 +198,8 @@ describe('host frame routing', () => {
it('adds/removes/flips sessions from host frames and keeps removed instances resident', async () => {
const api = new FakeApiClient()
const manager = new SessionManager(api)
manager.handleHostEnvelope({ rpcId: 'h1' as never, payload: { type: 'host/session-added', sessionId: S1 } })
manager.handleHostEnvelope({ rpcId: 'h2' as never, payload: { type: 'host/session-added', sessionId: S1 } }) // dup: ignored
manager.handleHostEnvelope({ rpcId: 'h1' as never, payload: { type: 'host/session-added', blank: true, sessionId: S1 } })
manager.handleHostEnvelope({ rpcId: 'h2' as never, payload: { type: 'host/session-added', blank: true, sessionId: S1 } }) // dup: ignored
expect(manager.getListSnapshot().items).toHaveLength(1)
const session = manager.get(S1)
@@ -192,14 +235,14 @@ describe('remaining branches', () => {
expect(session.getSnapshot().running).toBe(true)
})
it('create passes cwd through, folds transport throws, and skips the merge when already listed', async () => {
it('create passes cwd and a preallocated id, folds transport throws, and deduplicates the echo', async () => {
const api = new FakeApiClient()
api.onCreate = () => Promise.resolve(ok({ sessionId: S1 }))
const manager = new SessionManager(api)
await manager.create('/tmp/w')
expect(api.callsOf('session.create')).toEqual([{ cwd: '/tmp/w' }])
await manager.create({ cwd: '/tmp/w', sessionId: S1 })
expect(api.callsOf('session.create')).toEqual([{ cwd: '/tmp/w', sessionId: S1 }])
expect(manager.getListSnapshot().items[0]).toMatchObject({ sessionId: S1, cwd: '/tmp/w' })
await manager.create('/tmp/w') // same id returned: no duplicate row
await manager.create({ cwd: '/tmp/w' }) // same id returned: no duplicate row
expect(manager.getListSnapshot().items).toHaveLength(1)
api.onCreate = () => Promise.reject(new Error('create wire down'))
expect(await manager.create()).toMatchObject({ ok: false, error: { code: 'internal' } })
@@ -208,6 +251,42 @@ describe('remaining branches', () => {
expect(await manager.create()).toMatchObject({ ok: false })
})
it('publishes a real Ungrouped summary from workspace-attach-failed', async () => {
const api = new FakeApiClient()
api.onCreate = () => Promise.resolve(err({
code: 'workspace-attach-failed',
message: 'published but unattached',
details: { sessionId: S1, workspaceId: 'w1' },
} as never))
const manager = new SessionManager(api)
const result = await manager.create({ workspaceId: 'w1' as never, sessionId: S1 })
expect(result).toMatchObject({ ok: false, error: { code: 'workspace-attach-failed' } })
expect(manager.getListSnapshot().items).toEqual([expect.objectContaining({ sessionId: S1 })])
expect(manager.getListSnapshot().items[0]).not.toHaveProperty('cwd')
})
it('reconciles a preallocated id after an ordinary transport failure', async () => {
const api = new FakeApiClient()
api.onCreate = () => Promise.reject(new Error('response lost'))
const manager = new SessionManager(api)
const failed = await manager.create({ workspaceId: 'w1' as never, sessionId: S1 })
expect(failed).toMatchObject({ ok: false, error: { message: 'response lost' } })
expect(manager.getListSnapshot().items).toEqual([])
manager.handleHostEnvelope({
rpcId: 'published-later' as never,
payload: { type: 'host/session-added', blank: true, sessionId: S1, cwd: '/w/one' },
})
expect(manager.getListSnapshot().items).toEqual([
expect.objectContaining({ sessionId: S1, cwd: '/w/one' }),
])
manager.handleHostEnvelope({
rpcId: 'duplicate-frame' as never,
payload: { type: 'host/session-added', blank: true, sessionId: S1, cwd: '/w/one' },
})
expect(manager.getListSnapshot().items).toHaveLength(1)
})
it('subscribe notifies on list changes and stops after unsubscribe', async () => {
const api = new FakeApiClient()
const manager = new SessionManager(api)
@@ -218,7 +297,7 @@ describe('remaining branches', () => {
expect(notified).toBeGreaterThan(0)
const seen = notified
unsubscribe()
manager.handleHostEnvelope({ rpcId: 'h' as never, payload: { type: 'host/session-added', sessionId: S1 } })
manager.handleHostEnvelope({ rpcId: 'h' as never, payload: { type: 'host/session-added', blank: true, sessionId: S1 } })
await new Promise(resolve => setTimeout(resolve, 0))
expect(notified).toBe(seen)
})
@@ -257,8 +336,8 @@ describe('remaining branches', () => {
it('carries parentSessionId from host/session-added into the lineage row', () => {
const api = new FakeApiClient()
const manager = new SessionManager(api)
manager.handleHostEnvelope({ rpcId: 'h1' as never, payload: { type: 'host/session-added', sessionId: S1 } })
manager.handleHostEnvelope({ rpcId: 'h2' as never, payload: { type: 'host/session-added', sessionId: S2, parentSessionId: S1 } })
manager.handleHostEnvelope({ rpcId: 'h1' as never, payload: { type: 'host/session-added', blank: true, sessionId: S1 } })
manager.handleHostEnvelope({ rpcId: 'h2' as never, payload: { type: 'host/session-added', blank: true, sessionId: S2, parentSessionId: S1 } })
const items = manager.getListSnapshot().items
expect(items.find(e => e.sessionId === S2)).toMatchObject({ parentSessionId: S1, depth: 1 })
})

View File

@@ -0,0 +1,193 @@
/**
* Queue mirror semantics (web input-triggers queue cut 1): session/queued
* intake, host-rule retirement (message turn/start claims oldest non-steering;
* steering/message drains by source), leave-running sweep, reconnect reset,
* pre-instantiation buffering, and snapshot reference stability.
*/
import { describe, expect, it } from 'vitest'
import type { ContentBlock } from '@deepseek-ai/dsh-llm/types'
import type { MuxFrame, RpcId, SessionId } from '@deepseek-ai/dsh-client-connection/client'
import { Session } from '../src/client/sessions/session.ts'
import { SessionManager } from '../src/client/sessions/manager.ts'
import { FakeApiClient } from './fake-api.ts'
import { ev } from './event-script.ts'
const SID = 'fk-q1' as SessionId
const text = (t: string): ContentBlock[] => [{ type: 'text', text: t }]
const rid = (id: string): RpcId => id as RpcId
/** session/queued frame with the wire-sourced rpcId key (the host prompt path). */
function queuedFrame(body: string, rpcId: string, steering = false): MuxFrame {
return {
type: 'session/queued', sessionId: SID, content: text(body),
source: { kind: 'user', rpcId: rid(rpcId) } as never, steering,
}
}
function makeSession(): Session {
return new Session(SID, new FakeApiClient())
}
describe('queue intake', () => {
it('lands a queued frame as a row keyed by the source rpcId with a flat preview', () => {
const session = makeSession()
session.handleMuxEnvelope(rid('env-1'), queuedFrame('第一条 排队\n消息', 'p-1'))
const queue = session.getSnapshot().queue
expect(queue).toEqual([{ key: 'p-1', preview: '第一条 排队 消息' }])
})
it('falls back to the envelope rpcId when the source carries none, and tags non-text blocks', () => {
const session = makeSession()
session.handleMuxEnvelope(rid('env-2'), {
type: 'session/queued', sessionId: SID,
content: [{ type: 'text', text: 'hi' }, { type: 'image', data: 'x' } as never],
source: { kind: 'plugin', plugin: 'loop' }, steering: false,
})
expect(session.getSnapshot().queue).toEqual([{ key: 'f:env-2', preview: 'hi [image]' }])
})
it('caps the preview at 200 code points with an ellipsis', () => {
const session = makeSession()
session.handleMuxEnvelope(rid('env-3'), queuedFrame('长'.repeat(201), 'p-cap'))
const preview = session.getSnapshot().queue[0]?.preview ?? ''
expect(Array.from(preview)).toHaveLength(201) // 200 + …
expect(preview.endsWith('…')).toBe(true)
})
it('keeps the queue array reference stable across unrelated snapshot swaps', () => {
const session = makeSession()
session.handleMuxEnvelope(rid('env-4'), queuedFrame('稳定', 'p-s'))
const before = session.getSnapshot().queue
session.handleAgentError('unrelated') // dirties the snapshot without touching the queue
expect(session.getSnapshot().queue).toBe(before)
})
})
describe('queue retirement (host queuedMirror rules)', () => {
it('a message-triggered turn/start claims the oldest non-steering row', () => {
const session = makeSession()
session.handleMuxEnvelope(rid('e1'), queuedFrame('先', 'p-1'))
session.handleMuxEnvelope(rid('e2'), queuedFrame('后', 'p-2'))
session.handleMuxEnvelope(rid('e3'), { type: 'session/event', sessionId: SID, event: ev.turnStart(0, 0) })
expect(session.getSnapshot().queue.map(r => r.key)).toEqual(['p-2'])
})
it('an injection-triggered turn/start claims nothing', () => {
const session = makeSession()
session.handleMuxEnvelope(rid('e1'), queuedFrame('留', 'p-1'))
const injection = {
...ev.turnStart(0, 0),
data: { turn: 0, trigger: { kind: 'injection', source: { kind: 'plugin', plugin: 'x' } } },
} as never
session.handleMuxEnvelope(rid('e2'), { type: 'session/event', sessionId: SID, event: injection })
expect(session.getSnapshot().queue).toHaveLength(1)
})
it('steering/message drains the source-matched steering row only', () => {
const session = makeSession()
session.handleMuxEnvelope(rid('e1'), queuedFrame('普通', 'p-1'))
session.handleMuxEnvelope(rid('e2'), queuedFrame('插话', 'p-2', true))
// Loop-authored steering (different source) must not consume the user entry.
const foreignSteering = {
seq: 0, time: 1,
type: 'steering/message', surfaceOp: 'append',
data: { turn: 0, content: text('loop'), source: { kind: 'plugin', plugin: 'loop' } },
} as never
session.handleMuxEnvelope(rid('e3'), { type: 'session/event', sessionId: SID, event: foreignSteering })
expect(session.getSnapshot().queue).toHaveLength(2)
const matchedSteering = {
seq: 1, time: 2,
type: 'steering/message', surfaceOp: 'append',
data: { turn: 0, content: text('插话'), source: { kind: 'user', rpcId: rid('p-2') } },
} as never
session.handleMuxEnvelope(rid('e4'), { type: 'session/event', sessionId: SID, event: matchedSteering })
expect(session.getSnapshot().queue.map(r => r.key)).toEqual(['p-1'])
})
it('a leave-running flip sweeps the whole mirror (cancel/terminal-drop cover)', () => {
const session = makeSession()
session.handleRunning(true)
session.handleMuxEnvelope(rid('e1'), queuedFrame('一', 'p-1'))
session.handleMuxEnvelope(rid('e2'), queuedFrame('二', 'p-2', true))
session.handleRunning(false)
expect(session.getSnapshot().queue).toEqual([])
})
it('a stale not-running relay on an idle session still sweeps replayed rows', () => {
const session = makeSession()
session.handleMuxEnvelope(rid('e1'), queuedFrame('孤儿', 'p-1'))
session.handleRunning(false) // running already false: equality path must not skip the sweep
expect(session.getSnapshot().queue).toEqual([])
})
})
describe('queue reconnect semantics', () => {
it('session/subscribed re-baselines the mirror: stale rows drop, the following snapshot lands fresh', () => {
const session = makeSession()
session.handleMuxEnvelope(rid('e1'), queuedFrame('旧连接', 'p-old'))
// New mux generation: subscribed arrives first on the same stream...
session.handleMuxEnvelope(rid('e2'), { type: 'session/subscribed', sessionId: SID, lastSeq: 10 })
expect(session.getSnapshot().queue).toEqual([])
// ...then the queue snapshot replays the live inbox.
session.handleMuxEnvelope(rid('e3'), queuedFrame('新基线', 'p-new'))
expect(session.getSnapshot().queue.map(r => r.key)).toEqual(['p-new'])
})
it('resync must NOT clear the mirror (regression: onConnected races the mux baseline)', async () => {
const session = makeSession()
// Reconnect ordering that broke: mux opened first and already delivered
// the fresh generation's baseline; host stream (and with it onConnected →
// resync) lands after. The host never resends — clearing here left the
// dock empty until the next enqueue.
session.handleMuxEnvelope(rid('e1'), { type: 'session/subscribed', sessionId: SID, lastSeq: 5 })
session.handleMuxEnvelope(rid('e2'), queuedFrame('新基线', 'p-fresh'))
await session.resync()
expect(session.getSnapshot().queue.map(r => r.key)).toEqual(['p-fresh'])
})
})
describe('manager buffering of queued frames', () => {
it('buffers session/queued for uninstantiated sessions and replays before the running sync', () => {
const api = new FakeApiClient()
const manager = new SessionManager(api)
manager.handleMuxEnvelope({ rpcId: rid('b1'), payload: queuedFrame('预热', 'p-b1') })
// Instantiation replays the buffer; no summary exists, so no running sweep runs.
const session = manager.get(SID)
expect(session.getSnapshot().queue.map(r => r.key)).toEqual(['p-b1'])
// The buffer is consumed: a second get must not double-replay.
expect(manager.get(SID).getSnapshot().queue).toHaveLength(1)
})
it('a not-running list summary sweeps replayed rows at instantiation', async () => {
const api = new FakeApiClient()
api.onList = () => Promise.resolve(ok([{ sessionId: SID, updatedAt: 1, running: false }]))
const manager = new SessionManager(api)
await manager.refreshList()
manager.handleMuxEnvelope({ rpcId: rid('b2'), payload: queuedFrame('该扫掉', 'p-b2') })
expect(manager.get(SID).getSnapshot().queue).toEqual([])
})
it('subscribed re-baselines the uninstantiated buffer: stale queued frames drop, non-queue frames survive (regression: reconnect duplication)', () => {
const api = new FakeApiClient()
const manager = new SessionManager(api)
// Generation 1 baseline lands while the session is uninstantiated, along
// with a pending approval (never re-derivable from history).
manager.handleMuxEnvelope({ rpcId: rid('g1a'), payload: queuedFrame('第一代', 'p-g1') })
manager.handleMuxEnvelope({
rpcId: rid('g1b'),
payload: { type: 'approval/requested', sessionId: SID, approvalId: 'ap-1' as never, toolName: 'bash' },
})
// Reconnect: generation 2 replays subscribed + the SAME live queue entry.
manager.handleMuxEnvelope({ rpcId: rid('g2a'), payload: { type: 'session/subscribed', sessionId: SID, lastSeq: 3 } })
manager.handleMuxEnvelope({ rpcId: rid('g2b'), payload: queuedFrame('第一代', 'p-g1') })
const snapshot = manager.get(SID).getSnapshot()
// One queue row (no duplicate batch); the approval survived the re-baseline.
expect(snapshot.queue.map(r => r.key)).toEqual(['p-g1'])
expect(snapshot.pending.map(p => p.kind)).toEqual(['approval'])
})
})
/** ok wrapper with a typed items payload (the shared helper pins value to never[]). */
function ok(items: { sessionId: SessionId; updatedAt: number; running: boolean }[]) {
return { rpcId: rid(`ok-${items.length}`), result: { ok: true as const, value: { items: items as never[] } } }
}

View File

@@ -0,0 +1,84 @@
/**
* Agent-scope primitive spec: the actx minted by createScope carries the
* tag and the dispatch filter itself, so plain cordis dispatch with the actx
* as subject routes by agent — same-agent tagged listeners receive,
* foreign-agent ones are filtered out, untagged listeners hear everything,
* and a subject-less root dispatch stays unfiltered. Scope-owned listeners
* dispose with the fiber.
*/
import { Context } from 'cordis'
import { describe, expect, it } from 'vitest'
import type { SessionId } from '@deepseek-ai/dsh-client-connection/client'
import { createScope, scopeOf } from '../src/client/agents/scope.ts'
const sid = (k: string): SessionId => k as SessionId
declare module 'cordis' {
interface Events {
/**
* Test-only routed probe event.
* @param payload - marker payload.
* @mode bail
*/
'test/scope-probe'(payload: { from: string }): true | undefined
}
}
function bench() {
const root = new Context()
const a = createScope(root, sid('a'))
const b = createScope(root, sid('b'))
const seen: string[] = []
const listen = (label: string, ctx: Context, answer?: true) => {
ctx.on('test/scope-probe', (payload) => {
seen.push(`${label}:${payload.from}`)
return answer
})
}
return { root, a, b, seen, listen }
}
describe('createScope', () => {
it('tags the ctx (scopeOf) and leaves the root untagged', () => {
const { root, a } = bench()
expect(scopeOf(a.ctx)).toBe(sid('a'))
expect(scopeOf(root)).toBeUndefined()
})
it('scoped dispatch reaches same-session and untagged listeners, never a foreign session', () => {
const { root, a, b, seen, listen } = bench()
listen('a', a.ctx)
listen('b', b.ctx)
listen('root', root)
a.ctx.bail(a.ctx, 'test/scope-probe', { from: 'a' })
expect(seen).toEqual(['a:a', 'root:a'])
seen.length = 0
b.ctx.emit(b.ctx, 'test/scope-probe', { from: 'b' })
expect(seen).toEqual(['b:b', 'root:b'])
})
it('bail answers the first same-scope listener and skips filtered foreign ones', () => {
const { a, b, listen } = bench()
listen('b', b.ctx, true) // registered first, but foreign → filtered out
expect(a.ctx.bail(a.ctx, 'test/scope-probe', { from: 'a' })).toBeUndefined()
listen('a', a.ctx, true)
expect(a.ctx.bail(a.ctx, 'test/scope-probe', { from: 'a' })).toBe(true)
})
it('a subject-less root dispatch is unfiltered (every listener hears it)', () => {
const { root, a, b, seen, listen } = bench()
listen('a', a.ctx)
listen('b', b.ctx)
listen('root', root)
root.emit('test/scope-probe', { from: 'root' })
expect(seen).toEqual(['a:root', 'b:root', 'root:root'])
})
it('fiber disposal removes scope-owned listeners', async () => {
const { a, seen, listen } = bench()
listen('a', a.ctx)
await a.fiber.dispose()
a.ctx.emit(a.ctx, 'test/scope-probe', { from: 'late' })
expect(seen).toEqual([])
})
})

View File

@@ -388,19 +388,33 @@ describe('paging', () => {
})
describe('prompt and cancel errors', () => {
it('sends content through session.prompt with the mode passed through', async () => {
it('sends content through session.prompt; composerPhase steps blank → engaging synchronously at send entry', async () => {
const { api, session } = makeSession()
const result = await session.prompt([{ type: 'text', text: '要发的' }], 'queue')
// The blank → engaging edge fires before the RPC settles: the first-send
// flow reads the phase on the session area's first frame to keep the
// guidance hero from flashing back in.
expect(session.getSnapshot().composerPhase).toBe('blank')
const inFlight = session.prompt([{ type: 'text', text: '要发的' }], 'queue')
expect(session.getSnapshot().composerPhase).toBe('engaging')
const result = await inFlight
expect(result.ok).toBe(true)
// Monotone: settlement alone does not step the phase anywhere.
expect(session.getSnapshot().composerPhase).toBe('engaging')
expect(api.callsOf('session.prompt')).toMatchObject([{ sessionId: SID, mode: 'queue', content: [{ type: 'text', text: '要发的' }] }])
// First content lands (running turn): engaging → active.
session.handleRunning(true)
expect(session.getSnapshot().composerPhase).toBe('active')
})
it('business failure lands in promptError with op=send', async () => {
it('business failure lands in promptError with op=send; the phase stays engaging (retry, no hero bounce)', async () => {
const { api, session } = makeSession()
api.onPrompt = () => Promise.resolve(err({ code: 'agent-busy', message: 'busy', details: { reason: 'x' } }))
const result = await session.prompt([{ type: 'text', text: '失败的' }], 'queue')
expect(result.ok).toBe(false)
expect(session.getSnapshot().promptError).toMatchObject({ op: 'send', error: { code: 'agent-busy' } })
// Failed first prompt: composer + error strip is the retry surface —
// blank is unreachable once a send was initiated.
expect(session.getSnapshot().composerPhase).toBe('engaging')
})
it('lands cancel failures in promptError with op=stop', async () => {
@@ -814,6 +828,95 @@ describe('resync', () => {
})
})
describe('run_code sub-dispatch indexing', () => {
it('a start event lands as a running-shaped sub-call and its settle replaces it in place', async () => {
const { api, session } = makeSession()
api.onHistory = () => histResponse(plainTurn(0, 0, '问', '答'))
await session.open()
const feed = (event: SessionEvent) => { session.handleMuxEnvelope('r' as never, { type: 'session/event', sessionId: SID, event }) }
feed(ev.turnStart(6, 1))
feed(ev.toolCall(7, 1, 'p1', 'run_code', '{"code":"1","description":"d"}'))
feed(ev.codeDispatchStart(8, 'p1', 1, 'bash', { command: 'sleep' }))
feed(ev.codeDispatchStart(9, 'p1', 2, 'read', { path: 'a.txt' }))
const live = session.getSnapshot().codeDispatches.get('p1')
expect(live).toHaveLength(2)
// Running shape (no 'kind'): the exact RunningToolCall form native rows use.
expect(live?.[0]).toMatchObject({ callId: 'p1:code:1', name: 'bash', argsRaw: '{"command":"sleep"}' })
expect(live?.[0] !== undefined && 'kind' in live[0]).toBe(false)
// Settle out of order (parallel run): #2 first — replaces in place, keeping start order.
feed(ev.codeDispatch(10, 'p1', 2, 'read', { path: 'a.txt' }, 'alpha'))
const mixed = session.getSnapshot().codeDispatches.get('p1')
expect(mixed?.map(sub => 'kind' in sub)).toEqual([false, true])
expect(mixed?.[1]).toMatchObject({ callId: 'p1:code:2', content: [{ type: 'text', text: 'alpha' }] })
// The settle carries the paired start's time as callTime (duration source).
feed(ev.codeDispatch(11, 'p1', 1, 'bash', { command: 'sleep' }, 'done'))
const settled = session.getSnapshot().codeDispatches.get('p1')
expect(settled?.map(sub => 'kind' in sub)).toEqual([true, true])
expect(settled?.[0]).toMatchObject({ callId: 'p1:code:1', callTime: 1_700_000_000_008 })
})
it('indexes live tool/code-dispatch events under their parent as native-shaped result nodes', async () => {
const { api, session } = makeSession()
api.onHistory = () => histResponse(plainTurn(0, 0, '问', '答'))
await session.open()
const feed = (event: SessionEvent) => { session.handleMuxEnvelope('r' as never, { type: 'session/event', sessionId: SID, event }) }
feed(ev.turnStart(6, 1))
feed(ev.toolCall(7, 1, 'p1', 'run_code', '{"code":"return 1","description":"跑一个程序"}'))
feed(ev.codeDispatch(8, 'p1', 1, 'bash', { command: 'ls', description: '列目录' }, 'demo.txt'))
feed(ev.codeDispatch(9, 'p1', 2, 'read', { path: 'a.txt' }, 'Error: ENOENT', true))
const subs = session.getSnapshot().codeDispatches.get('p1')
expect(subs).toHaveLength(2)
expect(subs?.[0]).toMatchObject({
kind: 'tool-result', callId: 'p1:code:1',
call: { name: 'bash', argsRaw: '{"command":"ls","description":"列目录"}' },
// The settle event carries no start time: callTime stays null (never a
// fabricated zero-duration).
callTime: null,
isError: false, content: [{ type: 'text', text: 'demo.txt' }],
})
expect(subs?.[1]).toMatchObject({ callId: 'p1:code:2', isError: true })
// No paired start in the window: duration is UNKNOWN (null), never a
// fabricated zero-duration span.
expect(subs?.[0]).toMatchObject({ callTime: null })
// Sub-dispatches never join the surface flow.
expect(session.getSnapshot().nodes.some(n => n.kind === 'tool-result' && n.callId.includes(':code:'))).toBe(false)
})
it('rebuilds the same index from a history window (replay parity)', async () => {
const { api, session } = makeSession()
api.onHistory = () => histResponse([
...plainTurn(0, 0, '问', '答'),
ev.turnStart(6, 1),
ev.toolCall(7, 1, 'p1', 'run_code', '{"code":"return 1","description":"跑一个程序"}'),
ev.codeDispatch(8, 'p1', 1, 'bash', { command: 'ls' }, 'demo.txt'),
ev.toolResult(9, 1, 'p1', '{"done":true}'),
ev.turnEnd(10, 1),
])
await session.open()
const subs = session.getSnapshot().codeDispatches.get('p1')
expect(subs).toHaveLength(1)
expect(subs?.[0]).toMatchObject({ callId: 'p1:code:1', call: { name: 'bash' } })
})
it('keeps the dispatch map reference across unrelated changes and swaps it on a new dispatch', async () => {
const { api, session } = makeSession()
api.onHistory = () => histResponse(plainTurn(0, 0, '稳', '定'))
await session.open()
const feed = (event: SessionEvent) => { session.handleMuxEnvelope('r' as never, { type: 'session/event', sessionId: SID, event }) }
feed(ev.turnStart(6, 1))
feed(ev.toolCall(7, 1, 'p1', 'run_code', '{"code":"1","description":"d"}'))
feed(ev.codeDispatch(8, 'p1', 1, 'bash', { command: 'ls' }, 'x'))
const before = session.getSnapshot()
feed(ev.chunkStart(9, 1))
feed(ev.chunkText(10, 1, '流式'))
const after = session.getSnapshot()
expect(after.codeDispatches).toBe(before.codeDispatches)
feed(ev.codeDispatch(11, 'p1', 2, 'read', { path: 'a' }, 'y'))
expect(session.getSnapshot().codeDispatches).not.toBe(after.codeDispatches)
expect(session.getSnapshot().codeDispatches.get('p1')).toHaveLength(2)
})
})
describe('reference stability (the memo contract)', () => {
it('keeps unchanged node references across an append and swaps the snapshot object', async () => {
const { api, session } = makeSession()

View File

@@ -9,8 +9,8 @@
import { Context } from 'cordis'
import { afterEach, describe, expect, it, vi } from 'vitest'
import type { SessionId } from '@deepseek-ai/dsh-client-connection/client'
import { SessionsService, scopeOf } from '../src/client/sessions/service.ts'
import { FakeApiClient, ok } from './fake-api.ts'
import { SessionCreateError, SessionsService, scopeOf } from '../src/client/sessions/service.ts'
import { FakeApiClient, deferred, ok } from './fake-api.ts'
const sid = (s: string): SessionId => s as SessionId
@@ -28,22 +28,24 @@ function bench(): Bench {
}
/** Refresh the manager list from programmable rows and flush the microtask batch. */
async function feedList(b: Bench, rows: { id: string; cwd?: string; parentId?: string; running?: boolean }[]): Promise<void> {
type FeedRow = { id: string; cwd?: string; parentId?: string; running?: boolean; blank?: boolean }
async function feedList(b: Bench, rows: FeedRow[]): Promise<void> {
b.api.onList = () => Promise.resolve(ok({
items: rows.map(r => ({
sessionId: sid(r.id), updatedAt: 1, running: r.running ?? false,
sessionId: sid(r.id), updatedAt: 1, running: r.running ?? false, blank: r.blank ?? false,
...(r.cwd !== undefined ? { cwd: r.cwd } : {}),
...(r.parentId !== undefined ? { parentSessionId: sid(r.parentId) } : {}),
})),
}) as never)
await b.svc.manager.refreshList()
await b.svc.refresh()
await Promise.resolve() // manager notifier flush
}
describe('list store projection', () => {
it('projects durable titles separately from cwd/id display fallbacks and parent links', async () => {
const b = bench()
b.svc.manager.handleMuxEnvelope({
b.svc.handleMuxEnvelope({
rpcId: 'title' as never,
payload: { type: 'session/title', sessionId: sid('s1'), title: 'Durable title', eventSeq: 2, updatedAt: 3 },
})
@@ -61,7 +63,7 @@ describe('list store projection', () => {
it('reflects live increments (host stream via manager) into the store', async () => {
const b = bench()
await feedList(b, [{ id: 's1' }])
b.svc.manager.handleHostEnvelope({ rpcId: 'r1' as never, payload: { type: 'host/session-added', sessionId: sid('s2') } as never })
b.svc.handleHostEnvelope({ rpcId: 'r1' as never, payload: { type: 'host/session-added', blank: true, sessionId: sid('s2') } as never })
await Promise.resolve()
expect(b.svc.list.getSnapshot().ids).toContain('s2')
})
@@ -77,7 +79,7 @@ describe('scope tree', () => {
expect(scopeOf(scoped as Context)).toBe('s1')
expect(scopeOf(b.ctx)).toBeUndefined()
const binding = b.svc.binding(sid('s1'))
expect(binding?.session).toBe(b.svc.manager.get(sid('s1')))
expect(binding?.session).toBe(b.svc.provideInfo('s1')?.hooks['session'])
expect(b.svc.binding(sid('s1'))).toBe(binding)
expect(binding?.ctx).toBe(scoped)
})
@@ -133,6 +135,26 @@ describe('current selection (migrated from ui-layout, arbitrated into the list s
expect(b.svc.list.getSnapshot().current).toBe('s1') // failed open leaves the selection alone
})
it('clear() blanks list.current and the persisted selection', async () => {
const storage = new Map<string, string>()
vi.stubGlobal('localStorage', {
getItem: (k: string) => storage.get(k) ?? null,
setItem: (k: string, v: string) => { storage.set(k, v) },
removeItem: (k: string) => { storage.delete(k) },
clear: () => { storage.clear() },
})
const b = bench()
await feedList(b, [{ id: 's1' }])
b.svc.open(sid('s1'))
expect(storage.get('dsh.sessions.current')).toContain('s1')
b.svc.clear()
expect(b.svc.list.getSnapshot().current).toBeUndefined()
// Persisted wipe: a fresh service with the same storage stays on empty.
const again = bench()
await feedList(again, [{ id: 's1' }])
expect(again.svc.list.getSnapshot().current).toBeUndefined()
})
it('masks (not destroys) the selection while its session is off the list', async () => {
const b = bench()
await feedList(b, [{ id: 's1' }, { id: 's2' }])
@@ -164,21 +186,20 @@ describe('cell (render-layer session kit)', () => {
it('resolves an identity-stable {sessionId, session} cell; unknown ids yield undefined', async () => {
const b = bench()
await feedList(b, [{ id: 's1' }])
const cell = b.svc.cell('s1')
expect(cell).toBeDefined()
expect(cell?.sessionId).toBe('s1')
// Bare-source form (store migration): the cell carries the Session
// observable itself; hook binding happens in the React machinery.
expect(cell?.session).toBe(b.svc.manager.get(sid('s1')))
expect(b.svc.cell('s1')).toBe(cell)
expect(b.svc.cell('ghost')).toBeUndefined()
const info = b.svc.provideInfo('s1')
expect(info).toBeDefined()
expect(info?.sessionId).toBe('s1')
// The bundle carries bare observables; hook binding happens in React.
expect(info?.hooks['session']).toBe(b.svc.binding(sid('s1'))?.session)
expect(b.svc.provideInfo('s1')).toBe(info)
expect(b.svc.provideInfo('ghost')).toBeUndefined()
})
it('cell()/binding() are pure resolution: no staging, no deferred sweep', async () => {
it('provideInfo()/binding() are pure resolution: no staging, no deferred sweep', async () => {
const b = bench()
await feedList(b, [{ id: 's1' }, { id: 's2' }])
b.svc.open(sid('s1')) // staged
b.svc.cell('s2') // resolution only — must NOT move the stage
b.svc.provideInfo('s2') // resolution only — must NOT move the stage
b.svc.binding(sid('s2'))
await feedList(b, [{ id: 's2' }]) // s1 removed: still staged → deferred, scope survives
expect(b.svc.scope(sid('s1'))).toBeDefined()
@@ -190,7 +211,7 @@ describe('cell (render-layer session kit)', () => {
const historyCalls = () => b.api.calls.filter(c => c.method === 'session.history')
// Resolution is addressing, not staging: no window pull.
b.svc.scope(sid('s1'))
b.svc.cell('s1')
b.svc.provideInfo('s1')
b.svc.binding(sid('s1'))
expect(historyCalls()).toHaveLength(0)
b.svc.open(sid('s1'))
@@ -265,15 +286,157 @@ describe('ancestry', () => {
})
describe('create', () => {
it('returns the new id on ok and throws a coded error on failure', async () => {
it('passes a preallocated id and preserves it on ordinary failure', async () => {
const b = bench()
b.api.onCreate = () => Promise.resolve(ok({ sessionId: sid('fresh') }))
await expect(b.svc.create({ cwd: '/w' })).resolves.toBe('fresh')
await expect(b.svc.create({ cwd: '/w', sessionId: sid('fresh') })).resolves.toBe('fresh')
expect(b.api.callsOf('session.create')).toEqual([{ cwd: '/w', sessionId: 'fresh' }])
b.api.onCreate = () => Promise.resolve({
rpcId: 'e' as never,
result: { ok: false as const, error: { code: 'internal' as const, message: '爆了', details: {} } },
} as never)
await expect(b.svc.create()).rejects.toThrow(/internal: 爆了/)
const failure = await b.svc.create({ sessionId: sid('candidate') }).catch((error: unknown) => error)
expect(failure).toBeInstanceOf(SessionCreateError)
expect(failure).toMatchObject({
requestedSessionId: 'candidate',
rpcError: { code: 'internal', message: '爆了' },
})
})
it('resolves with the session already listed and binding-resolvable (no flush wait)', async () => {
const b = bench()
b.api.onCreate = () => Promise.resolve(ok({ sessionId: sid('born') }))
const born = await b.svc.create({ workspaceId: 'ws' as never })
// Synchronously after resolution — the draft hand-off contract: the
// create echo IS the entity entering the client's view (blank row +
// resolvable scope/binding), no notifier flush in between.
expect(b.svc.list.getSnapshot().byId[born]).toMatchObject({ id: 'born', blank: true })
expect(b.svc.binding(born)).toBeDefined()
expect(b.svc.scope(born)).toBeDefined()
})
it('lists the published id after Workspace attachment fails (publication precedes attachment)', async () => {
const b = bench()
b.api.onCreate = () => Promise.resolve({
rpcId: 'attach' as never,
result: {
ok: false,
error: {
code: 'workspace-attach-failed', message: 'ledger unavailable',
details: { sessionId: sid('published'), workspaceId: 'ws' },
},
},
} as never)
const failure = await b.svc.create({
workspaceId: 'ws' as never,
sessionId: sid('published'),
}).catch((error: unknown) => error)
await Promise.resolve()
expect(failure).toBeInstanceOf(SessionCreateError)
expect(failure).toMatchObject({
requestedSessionId: 'published',
rpcError: { code: 'workspace-attach-failed' },
})
expect(b.svc.list.getSnapshot().byId[sid('published')]).toMatchObject({ id: 'published', blank: true })
})
})
describe('scope lifecycle rides the list mirror (entity parity: no client-side pre-birth)', () => {
it('a session-added frame births the row (blank) and makes the scope resolvable; removal prunes it', async () => {
const b = bench()
await feedList(b, [])
expect(b.svc.scope(sid('s-new'))).toBeUndefined() // not in view: no scope, no exceptions
b.svc.handleHostEnvelope({
rpcId: 'add' as never,
payload: { type: 'host/session-added', sessionId: sid('s-new'), blank: true, cwd: '/w/a' } as never,
})
await Promise.resolve()
const scoped = b.svc.scope(sid('s-new'))
expect(scoped).toBeDefined()
expect(scopeOf(scoped as Context)).toBe('s-new')
b.svc.handleHostEnvelope({
rpcId: 'rm' as never,
payload: { type: 'host/session-removed', sessionId: sid('s-new') },
})
await Promise.resolve()
expect(b.svc.scope(sid('s-new'))).toBeUndefined()
})
})
describe('blank mirror', () => {
it('flips blank=false from the running:true status frame (cross-client conversion)', async () => {
const b = bench()
await feedList(b, [{ id: 's1', blank: true }])
expect(b.svc.list.getSnapshot().byId[sid('s1')]).toMatchObject({ blank: true })
b.svc.handleHostEnvelope({
rpcId: 'st' as never,
payload: { type: 'host/session-status', sessionId: sid('s1'), running: true },
})
await Promise.resolve()
expect(b.svc.list.getSnapshot().byId[sid('s1')]).toMatchObject({ blank: false, running: true })
// The instantiated Session mirrors the same flip.
expect(b.svc.binding(sid('s1'))?.session.getSnapshot().blank).toBe(false)
})
it('flips blank=false on prompt ACCEPTANCE, not on the attempt', async () => {
const b = bench()
await feedList(b, [{ id: 's1', blank: true, cwd: '/w/a' }])
const session = b.svc.binding(sid('s1'))!.session
expect(session.getSnapshot().blank).toBe(true)
const gate = deferred<Awaited<ReturnType<FakeApiClient['onPrompt']>>>()
b.api.onPrompt = () => gate.promise
const send = session.prompt([{ type: 'text', text: 'hi' }], 'queue')
// In flight: still blank (the flip point is the success response, which
// proves the user message reached the host log).
expect(session.getSnapshot().blank).toBe(true)
gate.resolve(ok({ accepted: true as const }))
await send
expect(session.getSnapshot().blank).toBe(false)
await Promise.resolve()
expect(b.svc.list.getSnapshot().byId[sid('s1')]).toMatchObject({ blank: false })
})
it('keeps a rejected first prompt blank: hidden and still reusable', async () => {
const b = bench()
await feedList(b, [{ id: 's1', blank: true, cwd: '/w/a' }])
const session = b.svc.binding(sid('s1'))!.session
b.api.onPrompt = () => Promise.resolve({
rpcId: 'busy' as never,
result: { ok: false as const, error: { code: 'internal' as const, message: 'agent busy', details: {} } },
} as never)
const result = await session.prompt([{ type: 'text', text: 'hi' }], 'queue')
expect(result.ok).toBe(false)
// No flip on failure: local stays aligned with the host authority
// (events.length still 0), so the session stays hidden and reusable.
expect(session.getSnapshot().blank).toBe(true)
await Promise.resolve()
expect(b.svc.list.getSnapshot().byId[sid('s1')]).toMatchObject({ blank: true })
})
it('takes session-added blank=true as the hidden birth and list blank as reconnect authority', async () => {
const b = bench()
await feedList(b, [])
b.svc.handleHostEnvelope({
rpcId: 'add' as never,
payload: { type: 'host/session-added', sessionId: sid('s-new'), blank: true, cwd: '/w/a' } as never,
})
await Promise.resolve()
expect(b.svc.list.getSnapshot().byId[sid('s-new')]).toMatchObject({ blank: true })
// Reconnect re-pull: the summary's blank=false wins (authoritative alignment).
await feedList(b, [{ id: 's-new', blank: false, cwd: '/w/a' }])
expect(b.svc.list.getSnapshot().byId[sid('s-new')]).toMatchObject({ blank: false })
})
it('never re-blanks: a stale blank=true summary cannot hide an engaged session', async () => {
const b = bench()
await feedList(b, [{ id: 's1', blank: true }])
const session = b.svc.binding(sid('s1'))!.session
await session.prompt([{ type: 'text', text: 'hi' }], 'queue')
await Promise.resolve()
expect(b.svc.list.getSnapshot().byId[sid('s1')]).toMatchObject({ blank: false })
// The next list pull still claims blank (host hasn't logged the message yet).
await feedList(b, [{ id: 's1', blank: true }])
expect(b.svc.binding(sid('s1'))?.session.getSnapshot().blank).toBe(false)
})
})

View File

@@ -85,18 +85,29 @@ function captureHost(bench: Bench, children?: object): SlotRendererHost {
})
bench.erased.register({ name: 'root', ...(children !== undefined ? { children } : {}) }, C)
bench.ctx.reflect.provide('sessions', fakeSessions())
bench.ctx.reflect.provide('workspaces', fakeWorkspaces())
bench.erased.renderSlot('root', {})
if (host === undefined) throw new Error('renderer never received the host')
return host
}
/** Minimal sessions face for the host seam (list observable + cell). */
/** Minimal independent Workspace list source for the renderer host seam. */
function fakeWorkspaces() {
const state = { items: [], phase: 'ready' as const }
return { list: { getSnapshot: () => state, subscribe: () => () => undefined } }
}
/** Minimal sessions face for the host seam (list observable + provide bundle). */
function fakeSessions() {
const state = { ids: [], byId: {}, current: undefined as string | undefined }
return {
list: { getSnapshot: () => state, subscribe: () => () => undefined },
cell: (id: string) => (id === 'known'
? { sessionId: id, session: { getSnapshot: () => undefined, subscribe: () => () => undefined } }
provideInfo: (id: string) => (id === 'known'
? {
sessionId: id,
hooks: { session: { getSnapshot: () => undefined, subscribe: () => () => undefined } },
props: {},
}
: undefined),
}
}
@@ -190,9 +201,18 @@ describe('renderer install seam', () => {
bench.erased.install({ renderRoot })
bench.erased.register({ name: 'root' }, C)
bench.ctx.reflect.provide('sessions', fakeSessions())
bench.ctx.reflect.provide('workspaces', fakeWorkspaces())
expect(bench.erased.renderSlot('root', {})).toBe('tree')
expect(renderRoot).toHaveBeenCalledTimes(1)
})
it('fails before rendering when the Workspace object layer is absent', async () => {
const bench = await boot()
bench.erased.install({ renderRoot: () => null })
bench.erased.register({ name: 'root' }, C)
bench.ctx.reflect.provide('sessions', fakeSessions())
expect(() => bench.erased.renderSlot('root', {})).toThrow(/workspaces service mounted/)
})
})
describe('host face', () => {
@@ -212,13 +232,19 @@ describe('host face', () => {
expect(host.entriesOf('t.host')).toHaveLength(0)
})
it('exposes sessions list/current/cell (current riding the list snapshot)', async () => {
it('exposes sessions list/current/provideInfo (current riding the list snapshot)', async () => {
const bench = await boot()
const host = captureHost(bench)
expect(host.sessions.list.getSnapshot()).toMatchObject({ ids: [] })
expect(host.sessions.current.getSnapshot()).toBeUndefined()
expect(host.sessions.cell('known')).toMatchObject({ sessionId: 'known' })
expect(host.sessions.cell('ghost')).toBeUndefined()
expect(host.sessions.provideInfo('known')).toMatchObject({ sessionId: 'known' })
expect(host.sessions.provideInfo('ghost')).toBeUndefined()
})
it('exposes the independent Workspace list source', async () => {
const bench = await boot()
const host = captureHost(bench)
expect(host.workspaces.list.getSnapshot()).toEqual({ items: [], phase: 'ready' })
})
})
@@ -315,6 +341,7 @@ describe('entry-unload cascade', () => {
renderRoot: (h: SlotRendererHost) => { host = h; return 'rendered' },
})
bench.ctx.reflect.provide('sessions', fakeSessions())
bench.ctx.reflect.provide('workspaces', fakeWorkspaces())
// The declarer here is NOT the root occupant: root stays occupied by a
// separate entry so disposing the declarer only kills its children.
const disposeRoot = bench.erased.register({ name: 'root' }, C)

View File

@@ -0,0 +1,55 @@
/**
* Wire-to-typed-event bridge (web input-triggers cut 1): host/commands-changed
* → ctx 'commands/changed'; each established connection generation →
* ctx 'connection/reset' (the forced cache-invalidation broadcast).
*/
import { Context } from 'cordis'
import { describe, expect, it } from 'vitest'
import type { ConnectionHandle, ConnectionSinks } from '@deepseek-ai/dsh-client-connection/client'
import * as RuntimeClient from '../src/client/index.ts'
import { FakeApiClient } from './fake-api.ts'
interface Bench {
ctx: Context
sinks: ConnectionSinks | undefined
}
async function mount(): Promise<Bench> {
const ctx = new Context()
const api = new FakeApiClient()
const bench: Bench = { ctx, sinks: undefined }
const handle: ConnectionHandle = {
api,
start: (sinks) => {
bench.sinks = sinks
return { stop: () => {} }
},
}
ctx.reflect.provide('connection', handle)
await ctx.plugin(RuntimeClient).await()
return bench
}
describe('wire event bridge', () => {
it('broadcasts commands/changed on a host/commands-changed frame, not on other host frames', async () => {
const bench = await mount()
let changed = 0
bench.ctx.on('commands/changed', () => { changed++ })
bench.sinks?.onHostEnvelope?.({ rpcId: 'r1' as never, payload: { type: 'host/commands-changed' } })
expect(changed).toBe(1)
bench.sinks?.onHostEnvelope?.({
rpcId: 'r2' as never,
payload: { type: 'host/session-status', sessionId: 's1' as never, running: true },
})
expect(changed).toBe(1)
})
it('broadcasts connection/reset on every established generation (reconnect invalidation)', async () => {
const bench = await mount()
let resets = 0
bench.ctx.on('connection/reset', () => { resets++ })
bench.sinks?.onConnected?.()
bench.sinks?.onConnected?.() // second generation after a reconnect
expect(resets).toBe(2)
})
})

View File

@@ -0,0 +1,178 @@
import { Context } from 'cordis'
import { describe, expect, it } from 'vitest'
import type { SessionId, WorkspaceId, WorkspaceView } from '@deepseek-ai/dsh-client-connection/client'
import { SessionsService } from '../src/client/sessions/service.ts'
import { WorkspaceManager } from '../src/client/workspaces/manager.ts'
import { WorkspacesService } from '../src/client/workspaces/service.ts'
import { FakeApiClient, deferred, err, ok } from './fake-api.ts'
const sid = (id: string): SessionId => id as SessionId
const wid = (id: string): WorkspaceId => id as WorkspaceId
function workspace(id: string, sessionIds: SessionId[] = [], createdAt = '2026-01-01T00:00:00.000Z'): WorkspaceView {
return {
workspaceId: wid(id), path: `/w/${id}`, title: id, sessionIds,
createdAt, updatedAt: createdAt,
}
}
describe('WorkspaceManager', () => {
it('replays changed frames over hydration and keeps established order on refresh', async () => {
const api = new FakeApiClient()
const gate = deferred<Awaited<ReturnType<FakeApiClient['onWorkspaceList']>>>()
api.onWorkspaceList = () => gate.promise
const manager = new WorkspaceManager(api)
const hydration = manager.refresh()
manager.handleHostEnvelope({
rpcId: 'changed' as never,
payload: { type: 'host/workspace-changed', workspace: workspace('new') },
})
gate.resolve(ok({ items: [workspace('old')] as never[] }))
await hydration
expect(manager.getSnapshot()).toMatchObject({ phase: 'ready', state: 'idle' })
expect(manager.getSnapshot().items.map(item => item.workspaceId)).toEqual(['new', 'old'])
api.onWorkspaceList = () => Promise.resolve(ok({
items: [workspace('old'), workspace('new')] as never[],
}))
await manager.refresh()
expect(manager.getSnapshot().items.map(item => item.workspaceId)).toEqual(['new', 'old'])
})
it('single-flights refreshes and exposes result and transport failures independently of readiness', async () => {
const api = new FakeApiClient()
const gate = deferred<Awaited<ReturnType<FakeApiClient['onWorkspaceList']>>>()
api.onWorkspaceList = () => gate.promise
const manager = new WorkspaceManager(api)
const first = manager.refresh()
const second = manager.refresh()
expect(manager.getSnapshot().state).toBe('loading')
gate.resolve(ok({ items: [] }))
await Promise.all([first, second])
expect(api.callsOf('workspace.list')).toHaveLength(1)
api.onWorkspaceList = () => Promise.resolve(err({ code: 'internal', message: 'down', details: {} }))
await manager.refresh()
expect(manager.getSnapshot()).toMatchObject({ phase: 'ready', state: 'error', error: { message: 'down' } })
api.onWorkspaceList = () => Promise.reject(new Error('wire down'))
await manager.refresh()
expect(manager.getSnapshot()).toMatchObject({ phase: 'ready', state: 'error', error: { message: 'wire down' } })
})
it('creates by name/path, prepends a new row, and folds failures', async () => {
const api = new FakeApiClient()
const manager = new WorkspaceManager(api)
api.onWorkspaceCreate = payload => Promise.resolve(ok({
workspace: workspace('created', [], '2026-02-01T00:00:00.000Z'),
created: true,
payload,
} as never))
await expect(manager.create({ name: 'created' })).resolves.toMatchObject({ ok: true })
expect(api.callsOf('workspace.create')).toEqual([{ name: 'created' }])
expect(manager.getSnapshot().items[0]?.workspaceId).toBe('created')
api.onWorkspaceCreate = () => Promise.reject(new Error('create transport'))
await expect(manager.create({ path: '/w/existing' })).resolves.toMatchObject({
ok: false, error: { code: 'internal', message: 'create transport' },
})
})
})
describe('WorkspacesService', () => {
it('feeds readiness and recent-Workspace targeting without changing Host order', async () => {
const ctx = new Context()
const api = new FakeApiClient()
const sessions = new SessionsService(ctx, api)
const workspaces = new WorkspacesService(ctx, api, sessions)
api.onWorkspaceList = () => Promise.resolve(ok({
items: [
workspace('stable-first', [], '2026-01-03T00:00:00.000Z'),
workspace('active', [sid('s-active')], '2026-01-01T00:00:00.000Z'),
] as never[],
}))
await workspaces.refresh()
await Promise.resolve()
expect(workspaces.list.getSnapshot()).toMatchObject({ baselinesReady: false, recentWorkspaceId: undefined })
api.onList = () => Promise.resolve(ok({
items: [{ sessionId: sid('s-active'), updatedAt: Date.parse('2026-02-01'), running: false, blank: false }] as never[],
}))
await sessions.refresh()
await Promise.resolve()
await Promise.resolve()
expect(workspaces.list.getSnapshot()).toMatchObject({
baselinesReady: true,
recentWorkspaceId: 'active',
})
expect(workspaces.list.getSnapshot().items.map(item => item.workspaceId)).toEqual(['stable-first', 'active'])
})
it('connectWorkspace reuses the workspace-matched blank session and creates otherwise', async () => {
const ctx = new Context()
const api = new FakeApiClient()
const sessions = new SessionsService(ctx, api)
const workspaces = new WorkspacesService(ctx, api, sessions)
api.onWorkspaceList = () => Promise.resolve(ok({
items: [workspace('alpha'), workspace('beta')] as never[],
}))
api.onList = () => Promise.resolve(ok({
items: [
// Blank session already parked in alpha (cwd == workspace path canon).
{ sessionId: sid('s-blank'), updatedAt: 2, running: false, blank: true, cwd: '/w/alpha' },
// Non-blank sibling in beta must never be reused.
{ sessionId: sid('s-active'), updatedAt: 3, running: false, blank: false, cwd: '/w/beta' },
] as never[],
}))
await Promise.all([workspaces.refresh(), sessions.refresh()])
await Promise.resolve()
// Hit: same workspace → the parked blank session comes back, no create RPC.
await expect(workspaces.connectWorkspace(wid('alpha'))).resolves.toBe('s-blank')
expect(api.callsOf('session.create')).toEqual([])
// Resolution guarantee: the id is binding-resolvable synchronously.
expect(sessions.binding(sid('s-blank'))).toBeDefined()
// Miss: beta has only a non-blank session → host create with workspaceId.
api.onCreate = () => Promise.resolve(ok({ sessionId: sid('s-fresh') }))
await expect(workspaces.connectWorkspace(wid('beta'))).resolves.toBe('s-fresh')
expect(api.callsOf('session.create')).toEqual([{ workspaceId: 'beta' }])
// Same guarantee on the create arm (draft hand-off writes the machine pre-open).
expect(sessions.binding(sid('s-fresh'))).toBeDefined()
// Unknown workspace fails loud instead of silently creating in nowhere.
await expect(workspaces.connectWorkspace(wid('ghost'))).rejects.toThrow(/unknown workspace ghost/)
})
it('a rejected first prompt keeps the blank session eligible for connectWorkspace reuse', async () => {
const ctx = new Context()
const api = new FakeApiClient()
const sessions = new SessionsService(ctx, api)
const workspaces = new WorkspacesService(ctx, api, sessions)
api.onWorkspaceList = () => Promise.resolve(ok({ items: [workspace('alpha')] as never[] }))
api.onList = () => Promise.resolve(ok({
items: [{ sessionId: sid('s-blank'), updatedAt: 2, running: false, blank: true, cwd: '/w/alpha' }] as never[],
}))
await Promise.all([workspaces.refresh(), sessions.refresh()])
await Promise.resolve()
const session = sessions.binding(sid('s-blank'))!.session
api.onPrompt = () => Promise.resolve(err({ code: 'internal', message: 'agent busy', details: {} }) as never)
await session.prompt([{ type: 'text', text: 'hi' }], 'queue')
await Promise.resolve()
// Failure leaves blank intact, so the same session is still the reuse hit.
await expect(workspaces.connectWorkspace(wid('alpha'))).resolves.toBe('s-blank')
expect(api.callsOf('session.create')).toEqual([])
})
it('returns created Workspaces and preserves Host business errors', async () => {
const ctx = new Context()
const api = new FakeApiClient()
const sessions = new SessionsService(ctx, api)
const workspaces = new WorkspacesService(ctx, api, sessions)
await expect(workspaces.create({ path: '/w/existing' })).resolves.toMatchObject({ workspaceId: 'fk-ws' })
expect(api.callsOf('workspace.create')).toEqual([{ path: '/w/existing' }])
api.onWorkspaceCreate = () => Promise.resolve(err({
code: 'workspace-invalid-path', message: 'missing', details: { path: '/missing' },
}))
await expect(workspaces.create({ path: '/missing' })).rejects.toThrow(/workspace-invalid-path: missing/)
})
})

View File

@@ -33,7 +33,7 @@ export const INLINE_SAFE = /^@deepseek-ai\/dsh-(host-apiproxy|session|llm|tools|
* Documented TEMPORARY exemption, not a platform module (hence not in
* platform.ts): the snapshot-store engine (createSnapshotStore/defineStore/
* shallowEqual) lives in runtime pending its promotion-time rehoming, and
* five importers (i18n, ui-layout, ui-conversation ×3) ride this single
* five importers (locale, ui-layout, ui-conversation ×3) ride this single
* exemption. At runtime the lazy CJS table answers the require natively:
* runtime is an immediately-tier row, its factory is registered before any
* dependent bundle materializes. TODO(webload/store-rehome): remove with the

Some files were not shown because too many files have changed in this diff Show More