Merge remote-tracking branch 'origin/master' into worktree/web-background-tasks-display-258f7e
# Conflicts: # docs/subsystems/tasks.i18n.yaml # docs/subsystems/tasks.md # docs/subsystems/tasks.zh.md # packages/host/apiproxy/README.i18n.yaml # packages/host/apiproxy/README.md # packages/host/apiproxy/README.zh.md # packages/host/apiproxy/src/api-proxy.ts # packages/tasks/tasks-local/src/index.ts # packages/tasks/tasks/README.i18n.yaml # packages/tasks/tasks/README.md # packages/tasks/tasks/README.zh.md # packages/tasks/tasks/src/index.ts
This commit is contained in:
@@ -14,24 +14,24 @@ The [slot system standard](../../.agents/notes/implemented/architecture/2026-07-
|
||||
4. **Hooks are framework-made only**: `useSession`, `useSessions`, `useWorkspaces`, `useStore`, `renderSlot` are the five standing seats, plus the `use<Name>` hooks the renderer binds from provide contributions and inject `hooks` compartments. 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 hand-made hooks, no ReactNode producers, no whole-service objects. A registrant-private reactive fact rides the reserved `hooks` compartment (bare observables the renderer binds to `use<Name>`; components never see the sources). Its capability boundary is the plugin's declared `inject` topology; there is no wider ctx to reach for.
|
||||
7. **inject returns plain data and callbacks** from the apply closure's own ctx — no hand-made hooks, no ReactNode producers, no whole-service objects. A registrant-private reactive fact uses the reserved `hooks` compartment (bare observables the renderer binds to `use<Name>`; components never see the sources). The plugin may use only the dependencies named by its `inject` declaration; there is no wider ctx to reach for.
|
||||
|
||||
## Reactive read and contract-currency discipline
|
||||
|
||||
How live data reaches render code, and what may cross a business boundary:
|
||||
How live data reaches render code, and what UI domains may share:
|
||||
|
||||
1. **Everything a render reads that can change outside React arrives through a framework hook** (rule 4 above). Event-handler code may read live snapshots (e.g. `keyboard.snapshot`); render code subscribes.
|
||||
2. **Business components contain no subscription machinery** — no `useSyncExternalStore`, no manual subscribe wiring, no mirroring an external snapshot into local state or a second store. Give each reactive fact its owning channel instead: registrant-private → the inject `hooks` compartment; cross-entry or remount-surviving → a declared store; per-session standard → `sessions.provide`.
|
||||
3. **Data-access ladder** — resolve needs in this order: framework hooks (standing seats + provide/inject-bound `use<Name>`) → a declared store (`useStore`/`actions`) → inject callbacks → anything else is a new framework extension point and needs main-thread arbitration.
|
||||
4. **Contract currency is JSON-able data and callbacks.** Everything crossing a business boundary (owner props, inject faces, store state, provide contributions) is plain serializable data or a callback over such data; the inject `hooks` compartment is the one sanctioned carrier of bare observables, and components never see those either. ReactNode is not a currency: route render content through a slot; no new ReactNode-valued owner props or inject members (the composer's existing `accessory`/`overlay`/`leftItems`/`rightItems` seats are exceptions pending migration to slots).
|
||||
4. **UI domains share only JSON-compatible data and callbacks.** Owner props, injected values, store state, and provide contributions are plain serializable data or callbacks over such data. The injected `hooks` compartment is the only place for bare observables, and components never receive those sources directly. Route ReactNode content through a slot; do not add ReactNode-valued owner props or injected members (the composer's existing `accessory`/`overlay`/`leftItems`/`rightItems` fields remain until they move to slots).
|
||||
5. **An observable source keeps two identities stable**: the source object itself (hook binding is cached per source), and its snapshot between changes (`getSnapshot` returns the same reference until the fact moves).
|
||||
6. **Whoever rebuilds a published value republishes it through the same source in the same step**, and a registration path that can run after consumers exist notifies the live consumers as part of registering.
|
||||
|
||||
## Export discipline (client plugin packages)
|
||||
|
||||
The `/client` surface of a UI plugin package is a contract face, not a convenience barrel. Three rules, enforced package-wide (do not restate them as per-file comments):
|
||||
The `/client` entrypoint of a UI plugin package is its public browser API, not a convenience barrel. Three rules apply package-wide (do not restate them as per-file comments):
|
||||
|
||||
1. **A UI plugin exports no values beyond what cordis loading needs** — `apply` / `inject` (and `Config` where present), plus store factories consumed type-only by components (`ReturnType<typeof createXXXStore>`). Types are the extra allowance: contract types (owner shares, injected shapes, composed props aliases) export freely. Implementation components, pure helpers, constants, and store handles stay internal. Adding any new value export requires user sign-off, not a matching consumer.
|
||||
1. **A UI plugin exports no values beyond what cordis loading needs** — `apply` / `inject` (and `Config` where present), plus store factories consumed type-only by components (`ReturnType<typeof createXXXStore>`). Shared types (owner data, injected values, composed prop aliases) may also be exported. Implementation components, pure helpers, constants, and store handles stay internal. Adding any new value export requires user sign-off, not a matching consumer.
|
||||
2. **Same-package tests import internals directly** — relative `../src/client/xxx.ts` from package tests, or the `./src/*` subpath where a spec lives outside the package. Never widen the public surface to make a test compile.
|
||||
3. **Cross-package imports of another plugin's symbols are in principle forbidden.** The sanctioned routes are the slot system (register/renderSlot) and ctx services. If neither fits, stop and escalate — do not add an export to unblock yourself.
|
||||
|
||||
@@ -44,7 +44,7 @@ The `/client` surface of a UI plugin package is a contract face, not a convenien
|
||||
The stack has one-way knowledge, settled in the [web client architecture note](../../.agents/notes/implemented/architecture/2026-07-19-gui-web-client-architecture.md):
|
||||
|
||||
1. **Data object layer** (`runtime`, React-free): `ConnectionController` → `SessionManager` → `Session` own all business state (event windows, streaming accumulation, reconnect machine), and the snapshot-store engine (zustand/immer, `defineStore`, `shallowEqual`) lives here too — store products are bare observable sources with no hook members. Zero React imports — grep-assertable.
|
||||
2. **Render machinery** (`web-react`, shell-only glue): the whole ctx↔React boundary — slot renderer/outlets, `SessionProvider`, the uSES bridge. Every hook is composed here at the binding site from bare sources; business plugin packages carry no web-react dependency at all.
|
||||
2. **Render machinery** (`web-react`, shell-only glue): all ctx-to-React integration — slot renderer/outlets, `SessionProvider`, and the uSES adapter. Every hook is composed here at the binding site from bare sources; business plugin packages carry no web-react dependency at all.
|
||||
3. **Presentation components** (plugin packages' `src/client/`, pure props): consumables, expected to be rewritten wholesale. Business logic must not leak into them; everything arrives through the four props shares.
|
||||
|
||||
Non-negotiables across the layers:
|
||||
@@ -62,7 +62,7 @@ Non-negotiables across the layers:
|
||||
|
||||
## Directory regime (plugin packages)
|
||||
|
||||
One UI feature = one plugin package (`src/client/` browser half). A multi-domain package splits by future package boundaries — ui-conversation is the exemplar: `contract/` (the only shared face), domain directories that never import a sibling domain, and `apply.ts` as the single cross-domain assembly point; `scripts/verify-client-domain-graph.ts` enforces the levels. Registration goes through `slots.register` in `apply` — never module-level side effects.
|
||||
One UI feature = one plugin package (`src/client/` browser half). A multi-domain package splits where its code could later become separate packages — ui-conversation is the example: `contract/` (the only shared API), domain directories that never import a sibling domain, and `apply.ts` as the single cross-domain assembly point; `scripts/verify-client-domain-graph.ts` enforces the levels. Registration goes through `slots.register` in `apply` — never module-level side effects.
|
||||
|
||||
## Styling
|
||||
|
||||
@@ -91,17 +91,17 @@ If `test:gui` is red on code you did not touch, neither silently fix nor ignore
|
||||
|
||||
Bringing up a new `packages/client/<name>` plugin package (ui-workspace is a complete example; ui-sidebar/ui-question are minimal skeletons):
|
||||
|
||||
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; a `dshClient` row in `packages/bundle/web-app/cordis.patch.yml`; a `packages/bundle/web-app/package.json` dependency (profile boots resolve bare row names through the healed `$DSH_HOME/profiles/node_modules` fallback, which mirrors the app's and each bundle's declared dependencies — a row whose package no manifest declares fails to import). `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.
|
||||
1. **Package skeleton**: `package.json` (`@deepseek-ai/dsh-client-<name>`, exports `.`/`./invariant`/`./client`/`./src/*`/`./package.json`, `dsh.client` 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; a `dsh.client` row in `packages/bundle/web-app/cordis.patch.yml`; a `packages/bundle/web-app/package.json` dependency (profile boots resolve bare row names through the healed `$DSH_HOME/profiles/node_modules` fallback, which mirrors the app's and each bundle's declared dependencies — a row whose package no manifest declares fails to import). `pnpm-workspace.yaml` already globs `packages/*/*`.
|
||||
3. **dsh.client 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**: apply order is unconstrained, and a business service is not a declaration barrier. Use `ctx.slots.inject(name, () => ctx.slots.register(...))`; it waits on the actual declaration, removes the contribution when that declaration collapses, reruns after redeclaration, and leaves with the caller's plugin fiber. Return a generator yielding each registration when several contributions must install and roll back atomically. A bare `slots.register` into an undeclared slot remains an error; keep service edges only for services the contribution actually reads.
|
||||
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.
|
||||
1. Compose through register: add the slot to `SlotMap`, declare it in its parent entry's `children`, and 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.
|
||||
2. Type the props as the four shares (`PropsRuntime` & `PropsRenderSlots` & `PropsStore` & inject face) — derive, don't hand-write. Shared/surviving state goes in a `createXXXStore()` factory declared at register; component-private state stays local.
|
||||
3. Component tests feed props directly (`createXXXStore().create()` for the store share; plain stubs for framework hooks) — behavior-shaped assertions, no render machinery.
|
||||
3. Component tests feed props directly (`createXXXStore().create()` for the store data; plain stubs for framework hooks) and assert behavior without render machinery.
|
||||
4. Tokens only in CSS; Chinese product copy; English comments.
|
||||
5. `pnpm run test:gui` green; if the component changes visible assembled output, also run `DSH_SNAPSHOT=replay pnpm run test:web`.
|
||||
6. Non-trivial change? It needs an Agent Note in the same PR (repo-wide rule) — the GUI notes above are the precedents to extend.
|
||||
|
||||
@@ -1,8 +1,15 @@
|
||||
{
|
||||
"name": "@deepseek-ai/dsh-client-connection",
|
||||
"description": "Wire consumer layer: HTTP-up/WebSocket-down client, ConnectionController dual streams with reconnect, and fixture api",
|
||||
"version": "0.0.1",
|
||||
"private": true,
|
||||
"version": "0.0.1-rc.1",
|
||||
"publishConfig": {
|
||||
"access": "restricted"
|
||||
},
|
||||
"repository": {
|
||||
"type": "git",
|
||||
"url": "git+https://github.com/deepseek-ai/deepseek-harness.git",
|
||||
"directory": "packages/client/connection"
|
||||
},
|
||||
"type": "module",
|
||||
"main": "lib/index.js",
|
||||
"types": "lib/types/index.d.ts",
|
||||
@@ -22,19 +29,22 @@
|
||||
"./src/*": "./src/*",
|
||||
"./package.json": "./package.json"
|
||||
},
|
||||
"dshClient": {
|
||||
"inject": [],
|
||||
"platform": "web",
|
||||
"immediately": true
|
||||
"dsh": {
|
||||
"client": {
|
||||
"inject": [],
|
||||
"platform": "web",
|
||||
"immediately": true
|
||||
}
|
||||
},
|
||||
"license": "BSD-3-Clause",
|
||||
"dependencies": {
|
||||
"@deepseek-ai/dsh-attachment": "workspace:^",
|
||||
"@deepseek-ai/dsh-host-apiproxy": "workspace:^",
|
||||
"@deepseek-ai/dsh-commands": "workspace:^",
|
||||
"@deepseek-ai/dsh-llm": "workspace:^",
|
||||
"@deepseek-ai/dsh-session": "workspace:^",
|
||||
"@deepseek-ai/dsh-tools": "workspace:^",
|
||||
"schemastery": "^3.18.0",
|
||||
"@deepseek-ai/schemastery": "workspace:^",
|
||||
"ws": "^8.21.0"
|
||||
},
|
||||
"files": [
|
||||
@@ -44,14 +54,14 @@
|
||||
"lib/types/**/*.d.ts"
|
||||
],
|
||||
"peerDependencies": {
|
||||
"@deepseek-ai/dsh-host-webserver": "^0.0.1",
|
||||
"@deepseek-ai/dsh-invariants": "^0.0.1",
|
||||
"cordis": "^4.0.0-rc.7"
|
||||
"@deepseek-ai/dsh-host-webserver": "workspace:^",
|
||||
"@deepseek-ai/dsh-invariants": "workspace:^",
|
||||
"@deepseek-ai/cordis": "workspace:^"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@deepseek-ai/dsh-host-webserver": "workspace:^",
|
||||
"@deepseek-ai/dsh-invariants": "workspace:^",
|
||||
"@types/ws": "^8.18.1",
|
||||
"cordis": "^4.0.0-rc.7"
|
||||
"@deepseek-ai/cordis": "workspace:^"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -6,10 +6,10 @@
|
||||
// The ./api and ./client subpath exports are the browser-safe channels.
|
||||
|
||||
export type {
|
||||
ApiProxy, SessionsApi, SessionSearchItem, SessionSummary, HostApi, EventsApi, MuxFrame, HostFrame,
|
||||
ApiProxy, SessionsApi, SessionSearchItem, SessionSummary, PromptContentPart, HostApi, EventsApi, MuxFrame, HostFrame,
|
||||
ApprovalResponsePayload, QuestionResponsePayload, HistoryEntry, ToolEventView,
|
||||
DirectoryEntry, DirectoryListing,
|
||||
WorkspaceApi, WorkspaceId, WorkspaceView,
|
||||
ResponseValue, WorkspaceApi, WorkspaceId, WorkspaceView,
|
||||
CommandsApi, CommandDescriptor, SkillsApi, SkillEntry,
|
||||
ModelCatalogFailure, ModelCatalogModel, ModelProviderGroup, ModelReasoning,
|
||||
ModelReasoningEffort, ModelSelection, QueueAction, QueuedInboxItem, SessionModels,
|
||||
@@ -38,6 +38,9 @@ export type { SessionId, SessionEvent } from '@deepseek-ai/dsh-session/types'
|
||||
export type { MessageId } from '@deepseek-ai/dsh-llm/brand'
|
||||
export type { ContentBlock, StreamChunk } from '@deepseek-ai/dsh-llm/types'
|
||||
|
||||
/** Successful value returned by the connection-generation host handshake. */
|
||||
export type HostDescription = import('@deepseek-ai/dsh-host-apiproxy/api').ResponseValue<'host.describe'>
|
||||
|
||||
import type { RpcResponse, RpcResult } from '@deepseek-ai/dsh-host-apiproxy/api'
|
||||
|
||||
/**
|
||||
|
||||
@@ -131,11 +131,15 @@ export class ConnectionController {
|
||||
// subscribed baseline. The timeout guards against a carrier that never fires onOpen
|
||||
// (see ConnectionConfig.streamOpenTimeoutMs).
|
||||
const timeout = new AbortController()
|
||||
await Promise.all([
|
||||
const [description] = await Promise.all([
|
||||
this.api.host.describe({}),
|
||||
Promise.race([streamsOpen, sleep(this.config.streamOpenTimeoutMs, timeout.signal)]),
|
||||
])
|
||||
timeout.abort()
|
||||
const descriptionResult = description.result
|
||||
if (!descriptionResult.ok) {
|
||||
throw new Error(`host.describe failed: ${descriptionResult.error.code}: ${descriptionResult.error.message}`)
|
||||
}
|
||||
if (ac.signal.aborted) throw new Error('generation aborted during readiness handshake')
|
||||
this.attempt = 0
|
||||
this.emitState('connected')
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
// FixtureApi: standalone UI development without a server. Real contract shape: unary takes
|
||||
// RpcRequest<P> and returns RpcResponse<T> (echoing the rpcId); streams yield RpcRequest<frame>
|
||||
// (the fixture IS the fake server, so it mints frame rpcIds); root respond takes ClientResponse
|
||||
// and returns RpcReceipt. fx-alpha carries a hand-built history script (60 turns, pageable);
|
||||
// and returns RpcReceipt. fx-alpha carries a hand-built history script (74 turns, pageable);
|
||||
// prompt triggers a chunked streaming replay; cancel stops the replay; resident pending
|
||||
// approval/question requests exercise replay and composer takeover with stable rpcIds.
|
||||
|
||||
@@ -19,6 +19,7 @@ import type {
|
||||
ToolResultMessage,
|
||||
UserMessage,
|
||||
} from '@deepseek-ai/dsh-llm'
|
||||
import type { AttachmentIdType, ImageAttachmentRef } from '@deepseek-ai/dsh-attachment'
|
||||
import type {
|
||||
SessionEvent,
|
||||
SessionId,
|
||||
@@ -51,10 +52,10 @@ function userMessage(content: ContentBlock[], source: MessageSource = { kind: 'u
|
||||
return createUserMessage({ content, source })
|
||||
}
|
||||
|
||||
function assistantMessage(content: ContentBlock[]): AssistantMessage {
|
||||
function assistantMessage(content: ContentBlock[], model = 'fx-1'): AssistantMessage {
|
||||
return createAssistantMessage({
|
||||
content,
|
||||
source: { provider: 'fixture', model: 'fx-1' },
|
||||
source: { provider: 'fixture', model },
|
||||
})
|
||||
}
|
||||
|
||||
@@ -330,6 +331,16 @@ function sid(id: string): SessionId {
|
||||
return id as SessionId
|
||||
}
|
||||
|
||||
const FIXTURE_IMAGE_DATA = 'iVBORw0KGgoAAAANSUhEUgAAAKAAAABaCAYAAAA/xl1SAAAAvklEQVR42u3SMQ0AAAjAMIyhELM4AAe8PD1qYFlk9cCXEAEDYkAwIAYEA2JAMCAGBANiQDAgBgQDYkAwIAYEA2JAMCAGBANiQDAgBgQDYkAwIAYEA2JAMCAGxIBCYEAMCAbEgGBADAgGxIBgQAwIBsSAYEAMCAbEgGBADAgGxIBgQAwIBsSAYEAMCAbEgGBADAgGxIAYEAyIAcGAGBAMiAHBgBgQDIgBwYAYEAyIAcGAGBAMiAHBgBgQDIgB4bYWLb6pnOb1xAAAAABJRU5ErkJggg=='
|
||||
const FIXTURE_IMAGE_REF: ImageAttachmentRef = {
|
||||
attachmentId: 'fixture:image' as AttachmentIdType,
|
||||
mediaType: 'image/png',
|
||||
bytes: 247,
|
||||
width: 160,
|
||||
height: 90,
|
||||
name: 'fixture-image.png',
|
||||
}
|
||||
|
||||
/** Deterministic provider billing attached to fixture assistant messages. */
|
||||
function fixtureUsage(turn: number, step: number): TokenUsage {
|
||||
return {
|
||||
@@ -340,7 +351,7 @@ function fixtureUsage(turn: number, step: number): TokenUsage {
|
||||
}
|
||||
}
|
||||
|
||||
/** fx-alpha history script: 60 turns (~130+ messages -> 3 pages at PAGE_MESSAGES=50),
|
||||
/** fx-alpha history script: 74 turns (~150+ messages -> 4 pages at PAGE_MESSAGES=50),
|
||||
* mixing reasoning blocks / tool call+result / context. */
|
||||
function buildAlphaLog(): SessionEvent[] {
|
||||
const events: Record<string, unknown>[] = []
|
||||
@@ -476,7 +487,7 @@ function buildAlphaLog(): SessionEvent[] {
|
||||
push({ type: 'step/end', data: { turn, step: 0 } })
|
||||
push({ type: 'turn/end', data: { turn, reason: { kind: 'completed' } } })
|
||||
}
|
||||
// Turn 72: todo_write sample — the TodoRow toolview in the flow plus the
|
||||
// Turn 73: todo_write sample — the TodoRow toolview in the flow plus the
|
||||
// todo/write snapshot event feeding the TodoPanel plan strip. Two items are
|
||||
// in_progress: this fixture chooses the parallel policy, so both surfaces
|
||||
// must render a parallel plan rather than the first active item alone.
|
||||
@@ -535,8 +546,32 @@ function buildAlphaLog(): SessionEvent[] {
|
||||
toolTurn(70, 'web_search', '{"query":"deepseek harness architecture"}', 'Search results for deepseek harness architecture.')
|
||||
toolTurn(71, 'web_fetch', '{"url":"https://www.deepseek.com/blog/harness-architecture"}', '# Harness architecture\n\nEverything is a plugin.')
|
||||
|
||||
// Turn 72: user and assistant images share one durable fixture object.
|
||||
// The todo turn remains last so its standing projection stays visible.
|
||||
push({ type: 'turn/start', data: { turn: 72 } })
|
||||
push({
|
||||
type: 'user/message',
|
||||
surfaceOp: 'append',
|
||||
data: userMessage([{ type: 'image', attachment: FIXTURE_IMAGE_REF }, ...text('历史用户图片')]),
|
||||
})
|
||||
push({ type: 'step/start', data: { turn: 72, step: 0 } })
|
||||
push({
|
||||
type: 'assistant/message',
|
||||
surfaceOp: 'append',
|
||||
data: {
|
||||
turn: 72,
|
||||
step: 0,
|
||||
message: assistantMessage(
|
||||
[...text('结构化模型图片:'), { type: 'image', attachment: FIXTURE_IMAGE_REF }],
|
||||
'fx-vision',
|
||||
),
|
||||
},
|
||||
})
|
||||
push({ type: 'step/end', data: { turn: 72, step: 0 } })
|
||||
push({ type: 'turn/end', data: { turn: 72, reason: { kind: 'completed' } } })
|
||||
|
||||
const todoArgs = JSON.stringify({ todos: fixtureTodos })
|
||||
toolTurn(72, 'todo_write', todoArgs, 'Updated todo list: 1 pending, 2 in progress, 1 completed.')
|
||||
toolTurn(73, 'todo_write', todoArgs, 'Updated todo list: 1 pending, 2 in progress, 1 completed.')
|
||||
// The real tool appends the snapshot mid-execution — between tool/call and
|
||||
// tool/result — so the fixture reproduces that exact ordering (the last
|
||||
// toolTurn events run ... tool/call, tool/result, step/end, turn/end).
|
||||
@@ -855,7 +890,6 @@ function estimateFixtureContent(blocks: readonly ContentBlock[]): number {
|
||||
// ContentBlockMap is merge-extensible: this client graph sees only the
|
||||
// base four members, but fixture turns do carry extended blocks at
|
||||
// runtime, so the structural JSON fallback below is live code.
|
||||
// oxlint-disable-next-line typescript/no-unnecessary-condition -- the type collapses without the out-of-graph merges (see above).
|
||||
if (block.type === 'tool-result') {
|
||||
return tokens + estimateFixtureContent(block.content) + BLOCK_OVERHEAD
|
||||
}
|
||||
@@ -1057,6 +1091,18 @@ function pageOf(
|
||||
return { events, hasMore: start > 0 }
|
||||
}
|
||||
|
||||
/** Fixture mirror of host session-scoped attachment authorization. */
|
||||
function logReferencesAttachment(log: readonly SessionEvent[], attachmentId: string): boolean {
|
||||
const visit = (value: unknown): boolean => {
|
||||
if (Array.isArray(value)) return value.some(visit)
|
||||
if (typeof value !== 'object' || value === null) return false
|
||||
const record = value as Record<string, unknown>
|
||||
if (record.attachmentId === attachmentId) return true
|
||||
return Object.values(record).some(visit)
|
||||
}
|
||||
return log.some(event => visit(event.data))
|
||||
}
|
||||
|
||||
/** Fixture mirror of first-party message extraction used by session-query. */
|
||||
function searchBlockText(block: ContentBlock): string[] {
|
||||
switch (block.type) {
|
||||
@@ -1351,6 +1397,10 @@ function createFixtureWorld(options: FixtureOptions): FixtureWorld {
|
||||
session.sessionId,
|
||||
{ provider: 'deepseek-official', model: 'deepseek-v4-flash' },
|
||||
]))
|
||||
const attachments = new Map<string, { attachment: ImageAttachmentRef; data: string }>([[
|
||||
String(FIXTURE_IMAGE_REF.attachmentId),
|
||||
{ attachment: FIXTURE_IMAGE_REF, data: FIXTURE_IMAGE_DATA },
|
||||
]])
|
||||
/** Credential store double: set/unset flip the describe badge, values never read back. */
|
||||
const fixtureCredentials = new Map<string, true>([
|
||||
// The assembled fixture represents an already-configured shipped
|
||||
@@ -1368,7 +1418,7 @@ function createFixtureWorld(options: FixtureOptions): FixtureWorld {
|
||||
['my-agent', { trust: 'user', content: "- id: tool-read\n name: '@deepseek-ai/dsh-tool-read'\n" }],
|
||||
])
|
||||
let fixtureDefaultPreset = 'standard'
|
||||
const nextTurn = new Map<SessionId, number>([[sid('fx-alpha'), 60]])
|
||||
const nextTurn = new Map<SessionId, number>([[sid('fx-alpha'), 74]])
|
||||
let nextSession = 1
|
||||
let nextRpc = 1
|
||||
let attachedSessions = options.empty ? 0 : 1
|
||||
@@ -2145,9 +2195,26 @@ function createFixtureWorld(options: FixtureOptions): FixtureWorld {
|
||||
// First accepted prompt appends events: the summary stops being blank.
|
||||
summary.blank = false
|
||||
const userText = content.map(b => (b.type === 'text' ? b.text : '')).join('')
|
||||
const durable: ContentBlock[] = content.map((block) => {
|
||||
if (block.type === 'text') return block
|
||||
const attachment: ImageAttachmentRef = {
|
||||
attachmentId: `fixture:${randomUuid()}` as AttachmentIdType,
|
||||
mediaType: block.mediaType,
|
||||
bytes: Math.max(
|
||||
1,
|
||||
Math.floor(block.data.length * 3 / 4)
|
||||
- (block.data.endsWith('==') ? 2 : block.data.endsWith('=') ? 1 : 0),
|
||||
),
|
||||
width: 160,
|
||||
height: 90,
|
||||
...block.name === undefined ? {} : { name: block.name },
|
||||
}
|
||||
attachments.set(String(attachment.attachmentId), { attachment, data: block.data })
|
||||
return { type: 'image', attachment }
|
||||
})
|
||||
if (mode === 'steer' && replays.has(id)) {
|
||||
// Steering: the durable user/message lands inside the current turn; the replay continues.
|
||||
append(id, { type: 'user/message', surfaceOp: 'append', data: userMessage(content) })
|
||||
append(id, { type: 'user/message', surfaceOp: 'append', data: userMessage(durable) })
|
||||
return ok(request, { accepted: true as const })
|
||||
}
|
||||
const turn = nextTurn.get(id) ?? 0
|
||||
@@ -2160,7 +2227,7 @@ function createFixtureWorld(options: FixtureOptions): FixtureWorld {
|
||||
if (plan.wanted !== null && plan.wanted !== plan.active) {
|
||||
append(id, { type: 'plan/mode', data: { active: plan.wanted } })
|
||||
}
|
||||
append(id, { type: 'user/message', surfaceOp: 'append', data: userMessage(content) })
|
||||
append(id, { type: 'user/message', surfaceOp: 'append', data: userMessage(durable) })
|
||||
// Capacity parallel of the host token-meter's request/context record:
|
||||
// log-only, appended inside the open turn, and deduplicated against the
|
||||
// route already recorded (the fixture never varies contextWindow).
|
||||
@@ -2186,6 +2253,27 @@ function createFixtureWorld(options: FixtureOptions): FixtureWorld {
|
||||
)
|
||||
return ok(request, { accepted: true as const })
|
||||
},
|
||||
attachment: (request) => {
|
||||
const stored = attachments.get(String(request.payload.attachmentId))
|
||||
if (stored === undefined) {
|
||||
return err(request, {
|
||||
code: 'attachment-error',
|
||||
message: 'fixture attachment missing',
|
||||
details: { reason: 'ATTACHMENT_NOT_FOUND' },
|
||||
})
|
||||
}
|
||||
if (!logReferencesAttachment(
|
||||
logs.get(request.payload.sessionId) ?? [],
|
||||
String(request.payload.attachmentId),
|
||||
)) {
|
||||
return err(request, {
|
||||
code: 'attachment-error',
|
||||
message: 'fixture attachment is not referenced by this session',
|
||||
details: { reason: 'ATTACHMENT_NOT_REFERENCED' },
|
||||
})
|
||||
}
|
||||
return ok(request, stored)
|
||||
},
|
||||
updateQueue: request => err(request, {
|
||||
code: 'queue-item-not-found',
|
||||
message: 'fixture has no pending queue item',
|
||||
@@ -2262,15 +2350,14 @@ function createFixtureWorld(options: FixtureOptions): FixtureWorld {
|
||||
archivedSessionIds: [...archivedSessionIds],
|
||||
}),
|
||||
create: (request) => {
|
||||
const { path, name } = request.payload
|
||||
const target = path ?? `/tmp/fixture-workspaces/${name ?? ''}`
|
||||
const existing = workspaces.find(w => w.path === target)
|
||||
const { path } = request.payload
|
||||
const existing = workspaces.find(w => w.path === path)
|
||||
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,
|
||||
path,
|
||||
title: path.split('/').filter(Boolean).at(-1) ?? path,
|
||||
sessionIds: [],
|
||||
createdAt: now,
|
||||
updatedAt: now,
|
||||
@@ -2837,6 +2924,7 @@ export class FixtureApiClient extends AbstractApiClient {
|
||||
case 'session.rename': return this.api.sessions.rename(request)
|
||||
case 'session.fork': return this.api.sessions.fork(request)
|
||||
case 'session.prompt': return this.api.sessions.prompt(request)
|
||||
case 'session.attachment': return this.api.sessions.attachment(request)
|
||||
case 'session.updateQueue': return this.api.sessions.updateQueue(request)
|
||||
case 'session.cancel': return this.api.sessions.cancel(request)
|
||||
case 'subagent.list': return this.api.subagents.list(request)
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
* the shared API client, and lets the runtime object layer start the stream
|
||||
* controller with its sinks.
|
||||
*/
|
||||
import type { Context } from 'cordis'
|
||||
import type { Context } from '@deepseek-ai/cordis'
|
||||
import type { IApiClient } from './api.ts'
|
||||
import { ConnectionController, type ConnectionConfig, type ConnectionSinks, type ConnectionState } from './connection.ts'
|
||||
import { FixtureApiClient } from './fixture.ts'
|
||||
@@ -14,7 +14,7 @@ import type { ClientConnectionRpc } from '../rpc.ts'
|
||||
|
||||
// ---- Contract re-exports (browser-safe apiproxy channels + core types) ----
|
||||
export type {
|
||||
ApiProxy, SessionsApi, SessionSearchItem, SessionSummary, HostApi, EventsApi, MuxFrame, HostFrame,
|
||||
ApiProxy, SessionsApi, SessionSearchItem, SessionSummary, PromptContentPart, HostApi, EventsApi, MuxFrame, HostFrame,
|
||||
ApprovalResponsePayload, QuestionResponsePayload, HistoryEntry, ToolEventView,
|
||||
DirectoryEntry, DirectoryListing,
|
||||
ToolCallView, ToolResultView, WorkspaceApi, WorkspaceId, WorkspaceView,
|
||||
@@ -25,7 +25,7 @@ export type {
|
||||
TaskView,
|
||||
RpcRequest, RpcResponse, RpcResult, RpcError, RpcErrorCode,
|
||||
ClientRequest, ServerResponse, ServerRequest, ClientResponse, RpcMessage, RpcReceipt,
|
||||
IApiClient, SessionId, SessionEvent, ContentBlock, StreamChunk,
|
||||
HostDescription, IApiClient, SessionId, SessionEvent, ContentBlock, StreamChunk,
|
||||
GoalsApi, GoalRef,
|
||||
SettingsApi, SettingsNamespaceView, SettingsPathOpView, SettingsSecretView,
|
||||
CredentialsApi, CredentialView, ConfigurableProviderView, DiscoveredModelView, LlmApi,
|
||||
|
||||
@@ -21,8 +21,14 @@ export interface FetchHandler {
|
||||
* @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.
|
||||
* @param maxRequestBodyBytes - maximum body bytes buffered before dispatch.
|
||||
*/
|
||||
export async function bridge(req: IncomingMessage, res: ServerResponse, apiHandler: FetchHandler): Promise<void> {
|
||||
export async function bridge(
|
||||
req: IncomingMessage,
|
||||
res: ServerResponse,
|
||||
apiHandler: FetchHandler,
|
||||
maxRequestBodyBytes = 32 * 1024 * 1024,
|
||||
): 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
|
||||
@@ -32,8 +38,26 @@ export async function bridge(req: IncomingMessage, res: ServerResponse, apiHandl
|
||||
res.on('close', () => {
|
||||
if (!res.writableEnded) abort.abort()
|
||||
})
|
||||
const declaredLength = req.headers['content-length']
|
||||
if (declaredLength !== undefined && Number(declaredLength) > maxRequestBodyBytes) {
|
||||
res.writeHead(413, { connection: 'close' })
|
||||
res.end()
|
||||
req.destroy()
|
||||
return
|
||||
}
|
||||
const chunks: Buffer[] = []
|
||||
for await (const chunk of req) chunks.push(chunk as Buffer)
|
||||
let received = 0
|
||||
for await (const chunk of req) {
|
||||
const buffer = chunk as Buffer
|
||||
received += buffer.byteLength
|
||||
if (received > maxRequestBodyBytes) {
|
||||
res.writeHead(413, { connection: 'close' })
|
||||
res.end()
|
||||
req.destroy()
|
||||
return
|
||||
}
|
||||
chunks.push(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'), {
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
/** Host HTTP bridge for browser-client RPC. */
|
||||
import type { Context } from 'cordis'
|
||||
import z from 'schemastery'
|
||||
import type { Context } from '@deepseek-ai/cordis'
|
||||
import z from '@deepseek-ai/schemastery'
|
||||
import type {} from '@deepseek-ai/dsh-attachment'
|
||||
// Activates the httpServer Context merge used below.
|
||||
import type { WebRoute, WebUpgradeRoute } from '@deepseek-ai/dsh-host-webserver'
|
||||
import { toFetchHandler } from '@deepseek-ai/dsh-host-apiproxy'
|
||||
@@ -25,6 +26,25 @@ export { API_PATH, HOST_EVENTS_PATH, MUX_EVENTS_PATH } from './api-path.ts'
|
||||
/** Stable Cordis plugin name. */
|
||||
export const name = 'client-connection'
|
||||
|
||||
/** Headroom for RPC JSON fields around aggregate base64 image payloads. */
|
||||
const REQUEST_ENVELOPE_HEADROOM_BYTES = 1024 * 1024
|
||||
|
||||
function assertImageBodyCapacity(ctx: Context, maxRequestBodyBytes: number): void {
|
||||
const attachments = ctx.get('attachments')
|
||||
if (attachments === undefined) return
|
||||
const requiredImageBodyBytes = Math.ceil(
|
||||
attachments.imageLimits.maxMessageImageBytes * 4 / 3,
|
||||
) + REQUEST_ENVELOPE_HEADROOM_BYTES
|
||||
if (maxRequestBodyBytes < requiredImageBodyBytes) {
|
||||
throw new Error(
|
||||
`client-connection maxRequestBodyBytes (${String(maxRequestBodyBytes)}) must be at least `
|
||||
+ `${String(requiredImageBodyBytes)} for the configured aggregate image limit`,
|
||||
)
|
||||
}
|
||||
}
|
||||
/** Default carrier cap for all HTTP RPC bodies. */
|
||||
const DEFAULT_MAX_REQUEST_BODY_BYTES = 32 * 1024 * 1024
|
||||
|
||||
/** Services required before providing Connection; API Proxy is an optional `/api` fallback. */
|
||||
export const inject = ['httpServer']
|
||||
|
||||
@@ -39,10 +59,13 @@ export interface ConnectionConfig {
|
||||
* that is not a bare, canonical authority fails the plugin load.
|
||||
*/
|
||||
trustedHosts?: string[]
|
||||
/** Maximum buffered JSON body for every `/api` request. */
|
||||
maxRequestBodyBytes?: number
|
||||
}
|
||||
|
||||
export const Config: z<ConnectionConfig> = z.object({
|
||||
trustedHosts: z.array(String).default([]),
|
||||
maxRequestBodyBytes: z.natural().min(1).default(DEFAULT_MAX_REQUEST_BODY_BYTES),
|
||||
})
|
||||
|
||||
/**
|
||||
@@ -109,9 +132,11 @@ const PRIVILEGED_METHODS = new Set([
|
||||
export function apply(ctx: Context, config?: ConnectionConfig): void {
|
||||
// The Loader resolves schema defaults; hand-built test contexts may pass none.
|
||||
const trustedHosts = config?.trustedHosts ?? []
|
||||
const maxRequestBodyBytes = config?.maxRequestBodyBytes ?? DEFAULT_MAX_REQUEST_BODY_BYTES
|
||||
// Config boundary: a malformed entry fails the load loudly here rather than
|
||||
// silently authorizing its hostname prefix at request time.
|
||||
for (const entry of trustedHosts) assertTrustedAuthority(entry)
|
||||
if (ctx.get('apiProxy') !== undefined) assertImageBodyCapacity(ctx, maxRequestBodyBytes)
|
||||
const connection = new HostConnectionService(ctx, trustedHosts)
|
||||
const fetchHandler = connection.createSharedFetchHandler(API_PATH, {
|
||||
async fetch(request) {
|
||||
@@ -144,11 +169,12 @@ export function apply(ctx: Context, config?: ConnectionConfig): void {
|
||||
res.end('forbidden')
|
||||
return
|
||||
}
|
||||
await bridge(req, res, fetchHandler)
|
||||
await bridge(req, res, fetchHandler, maxRequestBodyBytes)
|
||||
},
|
||||
}
|
||||
ctx.effect(() => ctx.httpServer.register(route), 'client-connection: /api route')
|
||||
ctx.inject(['apiProxy'], (apiCtx) => {
|
||||
assertImageBodyCapacity(apiCtx, maxRequestBodyBytes)
|
||||
const downlinks = new WebSocketDownlinks(apiCtx.apiProxy)
|
||||
const registerDownlink = (
|
||||
path: string,
|
||||
|
||||
@@ -4,7 +4,7 @@
|
||||
*/
|
||||
|
||||
/* jscpd:ignore-start */
|
||||
import type { Context } from 'cordis'
|
||||
import type { Context } from '@deepseek-ai/cordis'
|
||||
import type { InvariantInstaller } from '@deepseek-ai/dsh-invariants'
|
||||
|
||||
const PACKAGE_NAME = '@deepseek-ai/dsh-client-connection'
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
/** Host registry and HTTP adapter for generic Connection RPC channels. */
|
||||
|
||||
import { Context, Service } from 'cordis'
|
||||
import { Context, Service } from '@deepseek-ai/cordis'
|
||||
import type { WebRoute } from '@deepseek-ai/dsh-host-webserver'
|
||||
import {
|
||||
clientRequestSchema,
|
||||
@@ -32,7 +32,7 @@ interface ConnectionRpcInterceptor {
|
||||
readonly options: ConnectionRpcHandlerOptions
|
||||
}
|
||||
|
||||
declare module 'cordis' {
|
||||
declare module '@deepseek-ai/cordis' {
|
||||
interface Context {
|
||||
/** Host Connection transport and RPC registrations. */
|
||||
connection: HostConnectionHandle
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
* Connection plugin browser-half apply: ctx.connection handle mounting, mode
|
||||
* selection off the page URL, and the single-consumer stream-loop ownership.
|
||||
*/
|
||||
import { Context } from 'cordis'
|
||||
import { Context } from '@deepseek-ai/cordis'
|
||||
import { afterEach, describe, expect, it, vi } from 'vitest'
|
||||
import { apply, type ConnectionHandle } from '../src/client/index.ts'
|
||||
import type { RpcMessage } from '../src/client/api.ts'
|
||||
|
||||
@@ -83,6 +83,35 @@ describe('connection lifecycle', () => {
|
||||
}
|
||||
})
|
||||
|
||||
it('treats a host.describe business error as generation failure', async () => {
|
||||
const api = new FakeApiClient()
|
||||
let describeCalls = 0
|
||||
api.onDescribe = () => {
|
||||
describeCalls += 1
|
||||
if (describeCalls === 1) {
|
||||
return Promise.resolve({
|
||||
rpcId: 'bad-describe' as never,
|
||||
result: {
|
||||
ok: false as const,
|
||||
error: { code: 'internal' as const, message: 'not ready', details: {} },
|
||||
},
|
||||
})
|
||||
}
|
||||
return Promise.resolve(ok({ version: '0', cwd: '/f', attachedSessions: 0 }))
|
||||
}
|
||||
let connected = 0
|
||||
const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => undefined)
|
||||
const controller = new ConnectionController(api, { onConnected: () => { connected++ } }, FAST)
|
||||
controller.start()
|
||||
try {
|
||||
await vi.waitFor(() => { expect(describeCalls).toBe(2) })
|
||||
await vi.waitFor(() => { expect(connected).toBe(1) })
|
||||
} finally {
|
||||
controller.stop()
|
||||
warnSpy.mockRestore()
|
||||
}
|
||||
})
|
||||
|
||||
it('converges stream/error frames into reconnect instead of dispatching them', async () => {
|
||||
const api = new FakeApiClient()
|
||||
const muxSeen: string[] = []
|
||||
|
||||
@@ -67,6 +67,8 @@ export class FakeApiClient implements IApiClient {
|
||||
=> Promise<RpcResponse<{ selected: ModelSelection }>> =
|
||||
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 }))
|
||||
onAttachment: (payload: unknown) => Promise<RpcResponse<{ attachment: { attachmentId: never; mediaType: 'image/png'; bytes: number; width: number; height: number }; data: string }>> =
|
||||
() => Promise.resolve(ok({ attachment: { attachmentId: 'a' as never, mediaType: 'image/png', bytes: 1, width: 1, height: 1 }, data: 'AA==' }))
|
||||
onUpdateQueue: (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 }>> =
|
||||
@@ -110,6 +112,7 @@ export class FakeApiClient implements IApiClient {
|
||||
rename: (payload: unknown) => this.record('session.rename', payload, this.onRename(payload)),
|
||||
fork: (payload: unknown) => this.record('session.fork', payload, this.onFork(payload)),
|
||||
prompt: (payload: unknown) => this.record('session.prompt', payload, this.onPrompt(payload)),
|
||||
attachment: (payload: unknown) => this.record('session.attachment', payload, this.onAttachment(payload)),
|
||||
updateQueue: (payload: unknown) => this.record('session.updateQueue', payload, this.onUpdateQueue(payload)),
|
||||
cancel: (payload: unknown) => this.record('session.cancel', payload, this.onCancel(payload)),
|
||||
}
|
||||
|
||||
@@ -535,7 +535,7 @@ describe('createFixtureApi', () => {
|
||||
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 () => {
|
||||
it('workspace.create on a fresh path mints a new entity and pushes host/workspace-changed', async () => {
|
||||
const api = createFixtureApi()
|
||||
const abort = new AbortController()
|
||||
const seen: HostFrame[] = []
|
||||
@@ -546,7 +546,7 @@ describe('createFixtureApi', () => {
|
||||
}
|
||||
})()
|
||||
await new Promise(resolve => setTimeout(resolve, 10))
|
||||
const created = await api.workspace.create(req({ name: 'nova' }))
|
||||
const created = await api.workspace.create(req({ path: '/tmp/fixture-workspaces/nova' }))
|
||||
if (!created.result.ok) throw new Error('create failed')
|
||||
expect(created.result.value.created).toBe(true)
|
||||
expect(created.result.value.workspace).toMatchObject({
|
||||
@@ -554,16 +554,7 @@ describe('createFixtureApi', () => {
|
||||
})
|
||||
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' })
|
||||
// A basename-less path serves as its own title.
|
||||
const rootPath = await api.workspace.create(req({ path: '/' }))
|
||||
if (!rootPath.result.ok) throw new Error('rootPath failed')
|
||||
expect(rootPath.result.value.workspace.title).toBe('/')
|
||||
@@ -584,7 +575,7 @@ describe('createFixtureApi', () => {
|
||||
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' }))
|
||||
await api.workspace.create(req({ path: '/tmp/fixture-workspaces/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' } } })
|
||||
|
||||
@@ -722,7 +713,7 @@ describe('createFixtureApi', () => {
|
||||
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' }))
|
||||
const made = await api.workspace.create(req({ path: '/tmp/fixture-workspaces/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)
|
||||
@@ -991,7 +982,7 @@ describe('FixtureApiClient (protocol-level fake carrier)', () => {
|
||||
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' })
|
||||
const workspace = await client.workspace.create({ path: '/tmp/fixture-workspaces/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
|
||||
@@ -1049,7 +1040,7 @@ describe('FixtureApiClient (protocol-level fake carrier)', () => {
|
||||
})
|
||||
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' })
|
||||
const made = await client.workspace.create({ path: '/tmp/fixture-workspaces/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)
|
||||
|
||||
@@ -5,6 +5,34 @@ import { describe, expect, it } from 'vitest'
|
||||
import { bridge } from '../src/http-bridge.ts'
|
||||
|
||||
describe('HTTP bridge abort', () => {
|
||||
it('destroys a declared-oversize request instead of draining it', async () => {
|
||||
const destroyed: true[] = []
|
||||
const request = Readable.from([]) as unknown as IncomingMessage
|
||||
Object.assign(request, {
|
||||
url: '/api/session.prompt',
|
||||
method: 'POST',
|
||||
headers: { 'content-type': 'application/json', 'content-length': '999999' },
|
||||
destroy: () => { destroyed.push(true) },
|
||||
})
|
||||
let status: number | undefined
|
||||
let headers: unknown
|
||||
const response = Object.assign(new EventEmitter(), {
|
||||
writableEnded: false,
|
||||
writeHead(code: number, values?: unknown) { status = code; headers = values; return this },
|
||||
write() { return true },
|
||||
end(this: { writableEnded: boolean }) { this.writableEnded = true; return this },
|
||||
}) as unknown as ServerResponse
|
||||
|
||||
await bridge(request, response, {
|
||||
fetch: () => { throw new Error('a rejected request must never reach the handler') },
|
||||
}, 1000)
|
||||
// The socket must not stay parked draining a body the client can trickle
|
||||
// at will after the rejection — same discipline as the chunked overrun.
|
||||
expect(status).toBe(413)
|
||||
expect(headers).toMatchObject({ connection: 'close' })
|
||||
expect(destroyed).toHaveLength(1)
|
||||
})
|
||||
|
||||
it('aborts a pending native picker request when the browser disconnects', async () => {
|
||||
const body = JSON.stringify({
|
||||
type: 'client-request', rpcId: 'picker-1', method: 'host.pickDirectory', payload: {},
|
||||
@@ -38,7 +66,7 @@ describe('HTTP bridge abort', () => {
|
||||
}
|
||||
return Response.json({ aborted: fetchRequest.signal.aborted })
|
||||
},
|
||||
})
|
||||
}, Number.MAX_SAFE_INTEGER)
|
||||
await started
|
||||
response.emit('close')
|
||||
await pending
|
||||
|
||||
@@ -2,11 +2,12 @@
|
||||
import { EventEmitter, once } from 'node:events'
|
||||
import { createServer, request as httpRequest } from 'node:http'
|
||||
import { PassThrough, Readable } from 'node:stream'
|
||||
import { Context } from 'cordis'
|
||||
import { Context } from '@deepseek-ai/cordis'
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import type { AddressInfo } from 'node:net'
|
||||
import type { IncomingMessage, ServerResponse } from 'node:http'
|
||||
import type { ApiProxy } from '@deepseek-ai/dsh-host-apiproxy/api'
|
||||
import type { AttachmentStore } from '@deepseek-ai/dsh-attachment'
|
||||
import { RpcId, type ClientRequest } from '@deepseek-ai/dsh-host-apiproxy/api'
|
||||
import type { HttpServerService, WebRoute, WebUpgradeRoute } from '@deepseek-ai/dsh-host-webserver'
|
||||
import { API_PATH, apply, HOST_EVENTS_PATH, inject, MUX_EVENTS_PATH, type HostConnectionHandle } from '../src/index.ts'
|
||||
@@ -89,6 +90,19 @@ async function mounted(config?: { trustedHosts?: string[] }): Promise<{
|
||||
}
|
||||
|
||||
describe('connection node half', () => {
|
||||
it('fails loud when the carrier cap cannot hold the configured image batch', () => {
|
||||
const ctx = new Context()
|
||||
const routes: WebRoute[] = []
|
||||
ctx.provide('httpServer', fakeHttpServer(routes, []) as HttpServerService)
|
||||
ctx.provide('attachments', {
|
||||
imageLimits: { maxMessageImageBytes: 20 * 1024 * 1024 },
|
||||
} as AttachmentStore)
|
||||
ctx.provide('apiProxy', {} as ApiProxy)
|
||||
expect(() => { apply(ctx, { maxRequestBodyBytes: 1024 }) })
|
||||
.toThrow(/must be at least .* aggregate image limit/)
|
||||
expect(routes).toHaveLength(0)
|
||||
})
|
||||
|
||||
it('fails the load on a trustedHosts entry that is not a bare authority', async () => {
|
||||
const routes: WebRoute[] = []
|
||||
const upgrades: WebUpgradeRoute[] = []
|
||||
|
||||
@@ -9,6 +9,9 @@
|
||||
"src"
|
||||
],
|
||||
"references": [
|
||||
{
|
||||
"path": "../../attachment/attachment"
|
||||
},
|
||||
{
|
||||
"path": "../../llm/llm"
|
||||
},
|
||||
|
||||
@@ -2,5 +2,5 @@
|
||||
# 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 packages/client/hmr/README.md
|
||||
README.md: 454c03cc3cd11722943efd025d164d9ca8233d25
|
||||
README.md: 9228292547376d3fbb0ea5ce56b9e0a35ced17b2
|
||||
README.zh.md: ea62600911458556a3dcc7c46854e97db751c3ef
|
||||
|
||||
@@ -18,4 +18,4 @@ None; this package neither assembles nor sends a provider request.
|
||||
|
||||
- **Reload is coarse by design** — a fresh fiber and fresh components; React state inside the reloaded plugin is lost while the data layer (connection/runtime fibers, Session objects) is untouched. react-refresh-grade state preservation conflicts with "re-executing the bundle re-runs the factory" and is deliberately out.
|
||||
- **No failure rollback** — a reload that fails leaves the entry FAILED and visible in the loader status projection; the previous bundle is not restored automatically.
|
||||
- **Graph rev is not refreshed by rebuilt frames** — the stale rev is harmless because the bundle endpoint serves no-cache; reconnect is the only refresh boundary.
|
||||
- **Graph rev is not refreshed by rebuilt frames** — the stale rev is harmless because the bundle endpoint serves no-cache; only reconnect refreshes it.
|
||||
|
||||
@@ -1,8 +1,15 @@
|
||||
{
|
||||
"name": "@deepseek-ai/dsh-client-hmr",
|
||||
"description": "Dev-only hot-reload driver for script-loaded client entries: SSE rebuilt frames → invalidate/prefetch → fiber swap through the vendored Loader entry",
|
||||
"version": "0.0.1",
|
||||
"private": true,
|
||||
"version": "0.0.1-rc.1",
|
||||
"publishConfig": {
|
||||
"access": "restricted"
|
||||
},
|
||||
"repository": {
|
||||
"type": "git",
|
||||
"url": "git+https://github.com/deepseek-ai/deepseek-harness.git",
|
||||
"directory": "packages/client/hmr"
|
||||
},
|
||||
"type": "module",
|
||||
"main": "lib/index.js",
|
||||
"types": "lib/types/index.d.ts",
|
||||
@@ -22,28 +29,30 @@
|
||||
"./src/*": "./src/*",
|
||||
"./package.json": "./package.json"
|
||||
},
|
||||
"dshClient": {
|
||||
"inject": [],
|
||||
"platform": "web",
|
||||
"immediately": true
|
||||
"dsh": {
|
||||
"client": {
|
||||
"inject": [],
|
||||
"platform": "web",
|
||||
"immediately": true
|
||||
}
|
||||
},
|
||||
"license": "BSD-3-Clause",
|
||||
"dependencies": {
|
||||
"schemastery": "^3.18.0"
|
||||
"@deepseek-ai/schemastery": "workspace:^"
|
||||
},
|
||||
"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/cordis-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"
|
||||
"@deepseek-ai/cordis": "workspace:^"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@deepseek-ai/cordis-plugin-loader": "workspace:^",
|
||||
"@deepseek-ai/dsh-client-modules": "workspace:^",
|
||||
"@deepseek-ai/dsh-host-webserver": "workspace:^",
|
||||
"@deepseek-ai/dsh-invariants": "workspace:^",
|
||||
"@deepseek-ai/cordis": "workspace:^"
|
||||
},
|
||||
"files": [
|
||||
"lib/index.js",
|
||||
|
||||
@@ -61,8 +61,8 @@
|
||||
* fiberless (the next rebuilt frame retries from scratch); an apply failure
|
||||
* leaves a FAILED fiber for the shell's status projection. Both log loudly.
|
||||
*/
|
||||
import type { Context } from 'cordis'
|
||||
import type { Entry, Loader } from '@cordisjs/plugin-loader'
|
||||
import type { Context } from '@deepseek-ai/cordis'
|
||||
import type { Entry, Loader } from '@deepseek-ai/cordis-plugin-loader'
|
||||
import type { PluginsEventFrame } from '../events.ts'
|
||||
import { EVENTS_ENDPOINT } from '../events.ts'
|
||||
|
||||
|
||||
@@ -8,8 +8,8 @@
|
||||
*/
|
||||
import { statSync } from 'node:fs'
|
||||
import type { ServerResponse } from 'node:http'
|
||||
import type { Context } from 'cordis'
|
||||
import z from 'schemastery'
|
||||
import type { Context } from '@deepseek-ai/cordis'
|
||||
import z from '@deepseek-ai/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'
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
* @module @deepseek-ai/dsh-client-hmr/invariant
|
||||
*/
|
||||
|
||||
import type { Context, Fiber } from 'cordis'
|
||||
import type { Context, Fiber } from '@deepseek-ai/cordis'
|
||||
import type { InvariantInstaller } from '@deepseek-ai/dsh-invariants'
|
||||
|
||||
const PACKAGE_NAME = '@deepseek-ai/dsh-client-hmr'
|
||||
|
||||
@@ -5,7 +5,7 @@
|
||||
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 { Context } from '@deepseek-ai/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'
|
||||
|
||||
@@ -2,5 +2,5 @@
|
||||
# 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 packages/client/locale/README.md
|
||||
README.md: f1efefde4557e1c29c0556f8b670f1534430ab79
|
||||
README.zh.md: a8b5704d28ea121e668cbd500dd3d217d4f96291
|
||||
README.md: 5bea46cd4e3ace61bd2251610abdf0812ded9604
|
||||
README.zh.md: 2333bc7c2b2f5918c35286064c50131153ee8711
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
|
||||
English | [中文](README.zh.md)
|
||||
|
||||
Locale plugin: LocaleService — the browser locale preference (`zh`/`en`, persisted under `dsh.locale`; with nothing persisted a fresh browser opens in the language `navigator` asks for — matched on the primary subtag, `zh` when it asks for none this app ships; `locale/change` fires on switches only) plus the ns×locale dictionary registry (typed `register(ns, {zh, en})` checked against `LocaleNamespaceMap`, `bind(ns)`→`TranslateNS<ns>`; lookup chain ns → common → zh → key). The service implements the slot system's `LocaleFace` and installs itself through `ctx.slots.installLocale`, backing the framework-injected `t` standard seat (`Translate`/`TranslateNS` are ui-slots types; import them from there — this package only re-exports for dictionary owners' convenience).
|
||||
Locale plugin: LocaleService — the `zh`/`en` preference stored as `locale.preference` in `$DSH_HOME/settings.yaml`; when that explicit Host value is absent, a fresh browser starts provisionally in the language `navigator` asks for (primary-subtag matching, with `zh` when it asks for no language this app ships). The Host read runs after plugin activation so an unavailable settings service cannot block the page; its result replaces the provisional browser value live. Remote browsers retain only a process-local selection because the settings API is loopback-only. `locale/change` fires on switches. The service also owns the ns×locale dictionary registry (typed `register(ns, {zh, en})` checked against `LocaleNamespaceMap`, `bind(ns)`→`TranslateNS<ns>`; lookup chain ns → common → zh → key), implements the slot system's `LocaleFace`, and installs itself through `ctx.slots.installLocale`, backing the framework-injected `t` standard seat (`Translate`/`TranslateNS` are ui-slots types; import them from there — this package only re-exports for dictionary owners' convenience). The [Host-backed preferences decision](../../../.agents/notes/implemented/bug-fix/2026-08-06-host-backed-web-preferences.md) owns the persistence boundary.
|
||||
|
||||
## Model Experience
|
||||
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
|
||||
[English](README.md) | 中文
|
||||
|
||||
locale 插件:LocaleService——浏览器 locale 偏好(`zh`/`en`,以 `dsh.locale` 持久化;未持久化偏好时,全新浏览器以 `navigator` 请求的语言开场——按主子标签匹配,若其请求的语言本应用都不提供则为 `zh`;`locale/change` 仅在切换语言时触发),加上 ns×locale 字典注册表(类型化 `register(ns, {zh, en})` 按 `LocaleNamespaceMap` 校验,`bind(ns)`→`TranslateNS<ns>`;查找链 ns → common → zh → key)。该服务实现 slot 系统的 `LocaleFace` 并经 `ctx.slots.installLocale` 自行安装,支撑框架注入的 `t` 标准席位(`Translate`/`TranslateNS` 是 ui-slots 的类型;请从那里导入——本包的再导出仅为字典所有者提供便利)。
|
||||
locale 插件:LocaleService——`zh`/`en` 偏好以 `locale.preference` 存储在 `$DSH_HOME/settings.yaml` 中;若没有显式 Host 值,全新浏览器会暂时使用 `navigator` 请求的语言(按主子标签匹配;若其请求的语言本应用都不提供,则使用 `zh`)。Host 读取在插件激活后执行,因此 settings 服务不可用不会阻塞页面;读取结果会实时替换浏览器暂定值。settings API 仅限回环请求,因此远程浏览器的选择仅保留在进程内。`locale/change` 仅在切换语言时触发。该服务还拥有 ns×locale 字典注册表(类型化 `register(ns, {zh, en})` 按 `LocaleNamespaceMap` 校验,`bind(ns)`→`TranslateNS<ns>`;查找链 ns → common → zh → key),实现 slot 系统的 `LocaleFace`,并经 `ctx.slots.installLocale` 自行安装,支撑框架注入的 `t` 标准席位(`Translate`/`TranslateNS` 是 ui-slots 的类型;请从那里导入——本包的再导出仅为字典所有者提供便利)。该持久化边界由[Host settings 支撑的偏好决策](../../../.agents/notes/implemented/bug-fix/2026-08-06-host-backed-web-preferences.md)拥有。
|
||||
|
||||
## 模型体验
|
||||
|
||||
|
||||
@@ -1,8 +1,15 @@
|
||||
{
|
||||
"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,
|
||||
"description": "Locale plugin: Host-backed zh/en preference, browser-derived fallback, locale snapshots, and typed namespace dictionaries",
|
||||
"version": "0.0.1-rc.1",
|
||||
"publishConfig": {
|
||||
"access": "restricted"
|
||||
},
|
||||
"repository": {
|
||||
"type": "git",
|
||||
"url": "git+https://github.com/deepseek-ai/deepseek-harness.git",
|
||||
"directory": "packages/client/locale"
|
||||
},
|
||||
"type": "module",
|
||||
"main": "lib/index.js",
|
||||
"types": "lib/types/index.d.ts",
|
||||
@@ -22,20 +29,24 @@
|
||||
"./src/*": "./src/*",
|
||||
"./package.json": "./package.json"
|
||||
},
|
||||
"dshClient": {
|
||||
"inject": [
|
||||
"@deepseek-ai/dsh-client-runtime"
|
||||
],
|
||||
"platform": "web",
|
||||
"immediately": true
|
||||
"dsh": {
|
||||
"client": {
|
||||
"inject": [
|
||||
"@deepseek-ai/dsh-client-connection",
|
||||
"@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",
|
||||
"@deepseek-ai/dsh-client-connection": "workspace:^",
|
||||
"@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:^",
|
||||
"@deepseek-ai/cordis": "workspace:^",
|
||||
"react": "^18.2.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
@@ -44,9 +55,13 @@
|
||||
"@deepseek-ai/dsh-client-ui-slots": "workspace:^",
|
||||
"@deepseek-ai/dsh-invariants": "workspace:^",
|
||||
"@types/react": "~18.3.1",
|
||||
"cordis": "^4.0.0-rc.7",
|
||||
"@deepseek-ai/cordis": "workspace:^",
|
||||
"react": "^18.2.0"
|
||||
},
|
||||
"dependencies": {
|
||||
"@deepseek-ai/dsh-settings": "workspace:^",
|
||||
"@deepseek-ai/schemastery": "workspace:^"
|
||||
},
|
||||
"files": [
|
||||
"lib/index.js",
|
||||
"lib/invariant.js",
|
||||
|
||||
@@ -9,11 +9,16 @@
|
||||
* ui-slots): in THIS unit the map holds only this package's own merges, but
|
||||
* consumers merge more namespaces in and the intersection keeps them
|
||||
* string-typed. The rule fires on the narrow-map view, not real redundancy. */
|
||||
import type { Context } from 'cordis'
|
||||
import type { Context } from '@deepseek-ai/cordis'
|
||||
import {
|
||||
type BoundActions, type LocaleDictOf, type LocaleNamespaceMap, type Translate, type TranslateNS,
|
||||
} from '@deepseek-ai/dsh-client-ui-slots'
|
||||
import type { ClientContext } from '@deepseek-ai/dsh-client-runtime/client'
|
||||
import {
|
||||
bindSettingsScope, type ClientContext, type SettingsScope,
|
||||
} from '@deepseek-ai/dsh-client-runtime/client'
|
||||
import {
|
||||
LOCALE_PREFERENCE_FIELD, LOCALE_SETTINGS_NAMESPACE, type LocaleId, type LocaleSettings,
|
||||
} from '../locale-settings.ts'
|
||||
import { en, zh, type CommonKey } from '../locales/index.ts'
|
||||
import {
|
||||
en as settingsEn, zh as settingsZh, type SettingsLocaleKey,
|
||||
@@ -26,6 +31,7 @@ export type { LanguageRowComponentProps, LanguageRowInjected } from './LanguageR
|
||||
export type { LanguageOptionRow, LanguageRowState } from './settings-store.ts'
|
||||
export type { SettingsGeneralItemOwnerProps } from './settings-contract.ts'
|
||||
export type { CommonKey } from '../locales/index.ts'
|
||||
export type { LocaleId, LocaleSettings } from '../locale-settings.ts'
|
||||
|
||||
// The translate currency lives in ui-slots (the render machinery synthesizes
|
||||
// the seat); re-exported here so dictionary owners import one package.
|
||||
@@ -44,9 +50,6 @@ declare module '@deepseek-ai/dsh-client-ui-slots' {
|
||||
/** 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). */
|
||||
@@ -65,7 +68,7 @@ export interface LocaleSnapshot {
|
||||
revision: number
|
||||
}
|
||||
|
||||
declare module 'cordis' {
|
||||
declare module '@deepseek-ai/cordis' {
|
||||
interface Context {
|
||||
locale: LocaleService
|
||||
}
|
||||
@@ -91,9 +94,6 @@ 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: '中文' },
|
||||
@@ -116,13 +116,25 @@ export class LocaleService {
|
||||
private snapshot: LocaleSnapshot
|
||||
private listeners = new Set<() => void>()
|
||||
private readonly ctx: Context
|
||||
private readonly host: SettingsScope<LocaleSettings> | undefined
|
||||
/** Browser-derived locale standing wherever no explicit Host selection does. */
|
||||
private readonly provisional: LocaleId
|
||||
|
||||
/**
|
||||
* @param ctx - owning context (change events are emitted on it).
|
||||
* @param ctx - owning context (change events are emitted on it; the scope
|
||||
* listener is released through ctx.effect on dispose).
|
||||
* @param host - durable preference scope owned by the providing plugin;
|
||||
* absent compositions (standalone dictionary registries) stay process-local.
|
||||
*/
|
||||
constructor(ctx: Context) {
|
||||
constructor(ctx: Context, host?: SettingsScope<LocaleSettings>) {
|
||||
this.ctx = ctx
|
||||
this.snapshot = Object.freeze({ active: resolveInitialLocale(), locales: LOCALES, revision: 0 })
|
||||
this.host = host
|
||||
this.provisional = resolveInitialLocale()
|
||||
this.snapshot = Object.freeze({ active: this.provisional, locales: LOCALES, revision: 0 })
|
||||
if (host !== undefined) {
|
||||
ctx.effect(() => host.subscribe(() => { this.adopt(host) }), 'locale: settings scope adoption')
|
||||
this.adopt(host)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -155,16 +167,28 @@ export class LocaleService {
|
||||
}
|
||||
|
||||
/**
|
||||
* Switch the active locale — the only preference write entry. Persists the
|
||||
* id and emits `locale/change`.
|
||||
* Switch the active locale — the only user preference write entry.
|
||||
* @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
|
||||
persistPreference(match.id)
|
||||
this.publish(match.id, true)
|
||||
void this.host?.set(LOCALE_PREFERENCE_FIELD, match.id)
|
||||
}
|
||||
|
||||
/**
|
||||
* Adopt the scope's accepted durable selection without writing it back; an
|
||||
* absent selection returns to the browser-derived locale.
|
||||
* @param host - the constructor-narrowed scope driving this adoption.
|
||||
*/
|
||||
private adopt(host: SettingsScope<LocaleSettings>): void {
|
||||
const section = host.getSnapshot().value
|
||||
if (section === undefined) return
|
||||
const target = section.preference ?? this.provisional
|
||||
if (this.snapshot.active === target) return
|
||||
this.publish(target, true)
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -288,27 +312,11 @@ export class LocaleService {
|
||||
}
|
||||
|
||||
/**
|
||||
* The locale a fresh service opens with: an explicit preference the user
|
||||
* already chose wins over the browser's own language, which in turn wins over
|
||||
* {@link FALLBACK_LOCALE} (non-browser boots and browsers set to a language
|
||||
* this app does not ship).
|
||||
* The browser's own language wins over {@link FALLBACK_LOCALE}; an explicit
|
||||
* Host preference may replace this provisional value after plugin activation.
|
||||
*/
|
||||
function resolveInitialLocale(): LocaleId {
|
||||
return restorePreference() ?? detectBrowserLocale() ?? FALLBACK_LOCALE
|
||||
}
|
||||
|
||||
/** Read the persisted locale id; unknown or unreadable values read as no preference. */
|
||||
function restorePreference(): LocaleId | undefined {
|
||||
// Non-browser runs (node e2e booting the client tree) have no localStorage.
|
||||
if (typeof localStorage === 'undefined') return undefined
|
||||
try {
|
||||
const stored = localStorage.getItem(STORAGE_KEY)
|
||||
if (stored === 'zh' || stored === 'en') return stored
|
||||
} catch {
|
||||
// Storage access can throw (privacy mode); an unreadable store simply
|
||||
// records no preference, and the browser language decides instead.
|
||||
}
|
||||
return undefined
|
||||
return detectBrowserLocale() ?? FALLBACK_LOCALE
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -325,8 +333,7 @@ function detectBrowserLocale(): LocaleId | undefined {
|
||||
/* oxlint-disable-next-line typescript/no-unnecessary-condition --
|
||||
* The DOM lib types `languages` as always present; embedders and older
|
||||
* WebViews ship a Navigator without it, and spreading undefined would
|
||||
* throw at boot. Same environment-boundary distrust as the localStorage
|
||||
* guards below. */
|
||||
* throw at boot. */
|
||||
for (const tag of [...(navigator.languages ?? []), navigator.language]) {
|
||||
const primary = tag.toLowerCase().split('-')[0]
|
||||
const match = LOCALES.find(locale => locale.id === primary)
|
||||
@@ -335,19 +342,8 @@ function detectBrowserLocale(): LocaleId | undefined {
|
||||
return undefined
|
||||
}
|
||||
|
||||
/** 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']
|
||||
/** Required services: slot registration plus the settings transport. */
|
||||
export const inject = ['slots', 'connection']
|
||||
|
||||
/**
|
||||
* Client plugin body: provide the locale service with base dictionaries and
|
||||
@@ -356,7 +352,8 @@ export const inject = ['slots']
|
||||
* @param ctx - client cordis context.
|
||||
*/
|
||||
export function apply(ctx: ClientContext): void {
|
||||
const locale = new LocaleService(ctx)
|
||||
const host = bindSettingsScope<LocaleSettings>(ctx, { namespace: LOCALE_SETTINGS_NAMESPACE })
|
||||
const locale = new LocaleService(ctx, host)
|
||||
locale.register(COMMON_NS, { zh, en })
|
||||
locale.register(SETTINGS_NS, { zh: settingsZh, en: settingsEn })
|
||||
ctx.provide('locale', locale)
|
||||
|
||||
@@ -1,4 +1,23 @@
|
||||
/** Host loader entry for the browser implementation exported from `./client`. */
|
||||
/** Host registration for the browser locale preference. */
|
||||
|
||||
/** Host plugin body — no host-side behavior for the locale plugin. */
|
||||
export function apply(): void {}
|
||||
import type { Context } from '@deepseek-ai/cordis'
|
||||
import { settingsNamespace } from '@deepseek-ai/dsh-settings'
|
||||
import { LOCALE_SETTINGS_NAMESPACE, LocaleSettingsSchema } from './locale-settings.ts'
|
||||
|
||||
export {
|
||||
LOCALE_IDS, LOCALE_PREFERENCE_FIELD, LOCALE_SETTINGS_NAMESPACE,
|
||||
type LocaleId, type LocaleSettings,
|
||||
} from './locale-settings.ts'
|
||||
|
||||
/**
|
||||
* Register the durable locale section when a settings provider exists.
|
||||
* @param ctx - Host context whose optional settings service owns the section.
|
||||
*/
|
||||
export function apply(ctx: Context): void {
|
||||
ctx.inject(['settings'], (settingsCtx) => {
|
||||
settingsCtx.settings.register(
|
||||
settingsNamespace(LOCALE_SETTINGS_NAMESPACE),
|
||||
LocaleSettingsSchema,
|
||||
)
|
||||
})
|
||||
}
|
||||
|
||||
@@ -4,7 +4,7 @@
|
||||
*/
|
||||
|
||||
/* jscpd:ignore-start */
|
||||
import type { Context } from 'cordis'
|
||||
import type { Context } from '@deepseek-ai/cordis'
|
||||
import type { InvariantInstaller } from '@deepseek-ai/dsh-invariants'
|
||||
|
||||
const PACKAGE_NAME = '@deepseek-ai/dsh-client-locale'
|
||||
|
||||
26
packages/client/locale/src/locale-settings.ts
Normal file
26
packages/client/locale/src/locale-settings.ts
Normal file
@@ -0,0 +1,26 @@
|
||||
/** Locale preference stored in the Host user-settings document. */
|
||||
|
||||
import z from '@deepseek-ai/schemastery'
|
||||
|
||||
/** Settings namespace owned by the locale plugin. */
|
||||
export const LOCALE_SETTINGS_NAMESPACE = 'locale'
|
||||
|
||||
/** Field carrying an explicit locale selection; absence delegates to the browser. */
|
||||
export const LOCALE_PREFERENCE_FIELD = 'preference'
|
||||
|
||||
/** Locale identifiers shipped by the browser client. */
|
||||
export const LOCALE_IDS = ['zh', 'en'] as const
|
||||
|
||||
/** Shipped locale identifier. */
|
||||
export type LocaleId = typeof LOCALE_IDS[number]
|
||||
|
||||
/** Durable locale section shared by the Host schema and the browser scope. */
|
||||
export interface LocaleSettings {
|
||||
/** Explicit locale selection; absence delegates to the browser. */
|
||||
preference?: LocaleId
|
||||
}
|
||||
|
||||
/** Durable locale schema; also the wire envelope the browser scope validates against. */
|
||||
export const LocaleSettingsSchema: z<LocaleSettings> = z.object({
|
||||
[LOCALE_PREFERENCE_FIELD]: z.union([...LOCALE_IDS]).required(false),
|
||||
})
|
||||
@@ -1,11 +1,14 @@
|
||||
/** 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 { Context } from '@deepseek-ai/cordis'
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { SlotsService } from '@deepseek-ai/dsh-client-runtime/client'
|
||||
import { apply, inject, SETTINGS_NS } from '@deepseek-ai/dsh-client-locale/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 { LOCALE_SETTINGS_NAMESPACE, LocaleSettingsSchema } from '../src/locale-settings.ts'
|
||||
import { LanguageRow } from '../src/client/LanguageRow.tsx'
|
||||
import type { createLanguageRowStore } from '../src/client/settings-store.ts'
|
||||
|
||||
@@ -14,7 +17,36 @@ 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 }
|
||||
let preference: string | undefined
|
||||
let revision = 0
|
||||
const namespace = () => ({
|
||||
ns: LOCALE_SETTINGS_NAMESPACE,
|
||||
schema: LocaleSettingsSchema.toJSON(),
|
||||
value: preference === undefined ? {} : { preference },
|
||||
applies: 'live' as const,
|
||||
secrets: [],
|
||||
revision,
|
||||
})
|
||||
const describe = vi.fn(async () => ({
|
||||
rpcId: 'locale-describe' as never,
|
||||
result: {
|
||||
ok: true as const,
|
||||
value: { writable: true, hasDocument: true, namespaces: [namespace()] },
|
||||
},
|
||||
}))
|
||||
const mutate = vi.fn(async (request: { ops: { value: string }[] }) => {
|
||||
preference = request.ops[0]!.value
|
||||
revision += 1
|
||||
return {
|
||||
rpcId: 'locale-mutate' as never,
|
||||
result: { ok: true as const, value: namespace() },
|
||||
}
|
||||
})
|
||||
ctx.provide('connection', { api: { settings: { describe, mutate } }, isLoopback: true } as never)
|
||||
return {
|
||||
ctx, slots: ctx.get('slots') as SlotsService, describe, mutate,
|
||||
setHostPreference: (next: string | undefined) => { preference = next; revision += 1 },
|
||||
}
|
||||
}
|
||||
|
||||
/** Stand in for the settings shell: declare the General item slot from root. */
|
||||
@@ -47,7 +79,7 @@ describe('locale apply', () => {
|
||||
})
|
||||
|
||||
it('declares the slot service', () => {
|
||||
expect(inject).toEqual(['slots'])
|
||||
expect(inject).toEqual(['slots', 'connection'])
|
||||
})
|
||||
|
||||
it('provides the service with base + settings dictionaries and registers the row (declaration before or after apply)', async () => {
|
||||
@@ -91,6 +123,23 @@ describe('locale apply', () => {
|
||||
expect(locale.getLocale().active).toBe('zh')
|
||||
expect(instance.getSnapshot().active).toBe('zh')
|
||||
expect(locale.bind(SETTINGS_NS)('language.title')).toBe('语言')
|
||||
await vi.waitFor(() => { expect(b.mutate).toHaveBeenCalledTimes(2) })
|
||||
})
|
||||
|
||||
it('loads and refreshes the explicit Host preference after nonblocking activation', async () => {
|
||||
const b = await bench()
|
||||
b.setHostPreference('en')
|
||||
declareItems(b.slots)
|
||||
await b.ctx.plugin({ inject: [...inject], apply }).await()
|
||||
const locale = b.ctx.get('locale') as LocaleService
|
||||
await vi.waitFor(() => { expect(locale.getLocale().active).toBe('en') })
|
||||
b.setHostPreference(undefined)
|
||||
b.ctx.emit('settings/changed', LOCALE_SETTINGS_NAMESPACE)
|
||||
await vi.waitFor(() => { expect(locale.getLocale().active).toBe('zh') })
|
||||
b.setHostPreference('en')
|
||||
b.ctx.emit('settings/changed', LOCALE_SETTINGS_NAMESPACE)
|
||||
await vi.waitFor(() => { expect(locale.getLocale().active).toBe('en') })
|
||||
expect(b.describe).toHaveBeenCalledTimes(3)
|
||||
})
|
||||
|
||||
it('recovers after an HMR collapse of the declaring entry (stale disposer must not block)', async () => {
|
||||
|
||||
30
packages/client/locale/tests/host.spec.ts
Normal file
30
packages/client/locale/tests/host.spec.ts
Normal file
@@ -0,0 +1,30 @@
|
||||
import { Context } from '@deepseek-ai/cordis'
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { Settings, settingsNamespace, type SettingsNamespace } from '@deepseek-ai/dsh-settings'
|
||||
import {
|
||||
LOCALE_SETTINGS_NAMESPACE, apply,
|
||||
} from '@deepseek-ai/dsh-client-locale'
|
||||
|
||||
class MemorySettings extends Settings {
|
||||
readonly writable = true
|
||||
protected load(): Promise<Record<string, unknown>> { return Promise.resolve({}) }
|
||||
protected persist(_ns: SettingsNamespace, _section: Record<string, unknown>): Promise<void> {
|
||||
return Promise.resolve()
|
||||
}
|
||||
}
|
||||
|
||||
describe('locale host', () => {
|
||||
it('registers an optional explicit locale preference with the Host settings lifecycle', async () => {
|
||||
const ctx = new Context()
|
||||
await ctx.plugin(MemorySettings).await()
|
||||
const fiber = ctx.plugin({ apply })
|
||||
await fiber.await()
|
||||
const ns = settingsNamespace(LOCALE_SETTINGS_NAMESPACE)
|
||||
expect(ctx.settings.get(ns)).toEqual({})
|
||||
await ctx.settings.update(ns, { preference: 'en' })
|
||||
expect(ctx.settings.get(ns)).toEqual({ preference: 'en' })
|
||||
await expect(ctx.settings.update(ns, { preference: 'fr' })).rejects.toThrow()
|
||||
await fiber.dispose()
|
||||
expect(ctx.settings.describe().map(row => row.ns)).not.toContain(ns)
|
||||
})
|
||||
})
|
||||
@@ -1,6 +1,6 @@
|
||||
// @vitest-environment jsdom
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { Context } from 'cordis'
|
||||
import { Context } from '@deepseek-ai/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'
|
||||
@@ -14,16 +14,16 @@ describe('invariant companion', () => {
|
||||
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('node-half apply tolerates a Host without settings', () => {
|
||||
nodeApply(new Context())
|
||||
})
|
||||
|
||||
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'])
|
||||
expect(inject).toEqual(['slots', 'connection'])
|
||||
const ctx = new Context()
|
||||
new SlotsService(ctx)
|
||||
ctx.provide('connection', { api: { settings: {} }, isLoopback: false } as never)
|
||||
await ctx.plugin({ inject, apply: clientApply }).await()
|
||||
const locale = ctx.get('locale')
|
||||
expect(locale).toBeInstanceOf(LocaleService)
|
||||
|
||||
@@ -1,14 +1,19 @@
|
||||
// @vitest-environment jsdom
|
||||
import { afterEach, 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'
|
||||
import { Context } from '@deepseek-ai/cordis'
|
||||
import { stubSettingsScope, type StubSettingsScope } from '@deepseek-ai/dsh-client-test-runtime'
|
||||
import type { LocaleSettings, LocaleSnapshot } from '@deepseek-ai/dsh-client-locale/client'
|
||||
import { LocaleService } from '@deepseek-ai/dsh-client-locale/client'
|
||||
|
||||
const make = (): { ctx: Context; svc: LocaleService; events: LocaleSnapshot[] } => {
|
||||
const make = (host?: StubSettingsScope<LocaleSettings>): {
|
||||
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 }
|
||||
return { ctx, svc: new LocaleService(ctx, host?.scope), events }
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -24,7 +29,6 @@ const stubLanguages = (...tags: string[]): void => {
|
||||
|
||||
describe('LocaleService', () => {
|
||||
beforeEach(() => {
|
||||
localStorage.clear()
|
||||
// A Chinese browser is the baseline these specs assert their zh state on.
|
||||
stubLanguages('zh-CN')
|
||||
})
|
||||
@@ -132,16 +136,25 @@ describe('LocaleService', () => {
|
||||
expect(svc.getSnapshot().revision).toBe(before + 1)
|
||||
})
|
||||
|
||||
it('setLocale persists, republishes an immutable snapshot, and no-ops on same value', () => {
|
||||
const { svc, events } = make()
|
||||
it('setLocale writes through the scope, republishes an immutable snapshot, and no-ops on same value', () => {
|
||||
const host = stubSettingsScope<LocaleSettings>()
|
||||
const { svc, events } = make(host)
|
||||
svc.setLocale('en')
|
||||
expect(svc.getLocale().active).toBe('en')
|
||||
expect(localStorage.getItem(STORAGE_KEY)).toBe('en')
|
||||
expect(host.set).toHaveBeenCalledWith('preference', 'en')
|
||||
expect(events).toHaveLength(1)
|
||||
expect(events[0]).toBe(svc.getLocale())
|
||||
expect(events[0]!.revision).toBe(1)
|
||||
svc.setLocale('en')
|
||||
expect(events).toHaveLength(1)
|
||||
expect(host.set).toHaveBeenCalledOnce()
|
||||
})
|
||||
|
||||
it('setLocale without a host scope stays process-local', () => {
|
||||
const { svc, events } = make()
|
||||
svc.setLocale('en')
|
||||
expect(svc.getLocale().active).toBe('en')
|
||||
expect(events).toHaveLength(1)
|
||||
})
|
||||
|
||||
it('throws on unknown locale ids', () => {
|
||||
@@ -149,14 +162,37 @@ describe('LocaleService', () => {
|
||||
expect(() => { svc.setLocale('fr') }).toThrow('not registered')
|
||||
})
|
||||
|
||||
it('restores a persisted locale over the browser language, and garbage reads as no preference', () => {
|
||||
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('adopts a Host preference over the browser language without writing it back', () => {
|
||||
const host = stubSettingsScope<LocaleSettings>()
|
||||
const { svc, events } = make(host)
|
||||
host.publish({ status: 'ready', value: { preference: 'en' }, revision: 1, writable: true })
|
||||
expect(svc.getLocale().active).toBe('en')
|
||||
expect(events).toHaveLength(1)
|
||||
expect(host.set).not.toHaveBeenCalled()
|
||||
host.publish({ value: { preference: 'en' }, revision: 2 })
|
||||
expect(events).toHaveLength(1)
|
||||
})
|
||||
|
||||
it('opens in the browser language when nothing is persisted, matching regional variants on their primary subtag', () => {
|
||||
it('an absent Host preference returns to the browser-derived locale', () => {
|
||||
const host = stubSettingsScope<LocaleSettings>()
|
||||
const { svc } = make(host)
|
||||
host.publish({ status: 'ready', value: { preference: 'en' }, revision: 1, writable: true })
|
||||
expect(svc.getLocale().active).toBe('en')
|
||||
host.publish({ value: {}, revision: 2 })
|
||||
expect(svc.getLocale().active).toBe('zh')
|
||||
})
|
||||
|
||||
it('adopts a section already standing at construction and releases its subscription on dispose', async () => {
|
||||
const host = stubSettingsScope<LocaleSettings>()
|
||||
host.publish({ status: 'ready', value: { preference: 'en' }, revision: 1, writable: true })
|
||||
const { ctx, svc } = make(host)
|
||||
expect(svc.getLocale().active).toBe('en')
|
||||
expect(host.listenerCount()).toBe(1)
|
||||
await ctx.fiber.dispose()
|
||||
expect(host.listenerCount()).toBe(0)
|
||||
})
|
||||
|
||||
it('opens provisionally in the browser language, matching regional variants on their primary subtag', () => {
|
||||
stubLanguages('en-GB', 'zh-CN')
|
||||
expect(make().svc.getLocale().active).toBe('en')
|
||||
stubLanguages('zh-Hant-TW')
|
||||
@@ -176,8 +212,7 @@ describe('LocaleService', () => {
|
||||
expect(make().svc.getLocale().active).toBe('zh')
|
||||
})
|
||||
|
||||
it('runs outside a browser (node boots): the fallback decides, the machine language does not, writes no-op', () => {
|
||||
vi.stubGlobal('localStorage', undefined)
|
||||
it('runs outside a browser (node boots): the fallback decides and the machine language does not', () => {
|
||||
vi.stubGlobal('window', undefined)
|
||||
// Node exposes its own global navigator; without a window it must not
|
||||
// reach the resolution at all.
|
||||
@@ -188,12 +223,11 @@ describe('LocaleService', () => {
|
||||
expect(svc.getLocale().active).toBe('en')
|
||||
})
|
||||
|
||||
it('keeps the browser language out of the way once a preference exists', () => {
|
||||
it('lets an explicit in-process preference replace the browser-derived value', () => {
|
||||
stubLanguages('en-US')
|
||||
const { svc } = make()
|
||||
svc.setLocale('zh')
|
||||
expect(localStorage.getItem(STORAGE_KEY)).toBe('zh')
|
||||
expect(make().svc.getLocale().active).toBe('zh')
|
||||
expect(svc.getLocale().active).toBe('zh')
|
||||
})
|
||||
|
||||
it('exposes the two shipped locales with self-described labels', () => {
|
||||
|
||||
@@ -20,6 +20,9 @@
|
||||
{
|
||||
"path": "../../../vendor/cordis"
|
||||
},
|
||||
{
|
||||
"path": "../../settings/settings"
|
||||
},
|
||||
{
|
||||
"path": "../../support/invariants"
|
||||
}
|
||||
|
||||
@@ -2,5 +2,5 @@
|
||||
# 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 packages/client/modules/README.md
|
||||
README.md: 1d327c7252f4b3001ad758b7a4db01e9907c3060
|
||||
README.zh.md: a97672b909c98367e8c1287e3b341fb612f2d110
|
||||
README.md: a1d578850c2518a85dc32f048768b78caf5ffec4
|
||||
README.zh.md: 772a4870f7ef6730d9d3d4db434ed771d97984f0
|
||||
|
||||
@@ -8,7 +8,7 @@ Lazy CJS model (web2): executing a plugin bundle only REGISTERS its factory (`wi
|
||||
|
||||
Resolution branch order (`import(specifier)`): platform seed word → shell instance; memoized record → surface; shell-own static registry (`registerStatic`, app-shell) → module; registered factory → materialize; graph row (`window.__DSH_BOOT__`) → load its external classic script + materialize; anything else throws — the runtime mirror of the build-time bundle purity gate. The synchronous `require` handed to factories walks the same order minus the asynchronous load branch and records observed edges into the module record. `prefetch` is the stage-one arrival hook (script load and factory registration only; concurrent calls share one in-flight task); `invalidate` drops the factory and materialized record so the next prefetch/import reloads the script (the HMR hook).
|
||||
|
||||
The Node half scans enabled Loader entries for web `dshClient` packages, resolves each `exports["./client"]`, hashes the built bundle into the boot graph, and serves it with its source map under `/plugins`. Source launch maps host imports to TypeScript source but still consumes this built client export; missing files share one build instruction followed by a package/path list, while unrelated filesystem errors remain separate failures.
|
||||
The Node half scans enabled Loader entries for web `dsh.client` packages, resolves each `exports["./client"]`, hashes the built bundle into the boot graph, and serves it with its source map under `/plugins`. Source launch maps host imports to TypeScript source but still consumes this built client export; missing files share one build instruction followed by a package/path list, while unrelated filesystem errors remain separate failures.
|
||||
|
||||
## Model Experience
|
||||
|
||||
@@ -20,5 +20,5 @@ None; this package neither assembles nor sends a provider request.
|
||||
|
||||
## Known Limitations and Deferred Work
|
||||
|
||||
- **Flat module graph by design** — every bundle is one module node whose edges point only at table leaves; the interface (loadCache/edges/invalidate) is shaped for a general module graph so the externalization granularity can change without an interface change.
|
||||
- **Flat module graph by design** — every bundle is one module node whose edges point only at table leaves; the interface (`loadCache`/`edges`/`invalidate`) already supports a general module graph, so the externalization granularity can change without an interface change.
|
||||
- **No unload bookkeeping of its own** — style removal and fiber teardown ordering live with the HMR driver (`@deepseek-ai/dsh-client-hmr`); the loader only inventories owned style tag ids per record.
|
||||
|
||||
@@ -8,7 +8,7 @@
|
||||
|
||||
解析分支顺序(`import(specifier)`):平台种子词 → 外壳实例;记忆化记录 → 表层;外壳自身的静态注册表(`registerStatic`,app-shell)→ 模块;已注册 factory → 物化;模块图记录(`window.__DSH_BOOT__`)→ 加载外部 classic script + 物化;其他情况一律抛出异常。这是构建时组合包纯度门禁的运行时镜像。交给 factory 的同步 `require` 采用相同顺序,但不含异步加载分支,并把观察到的边记录到模块记录中。`prefetch` 是第一阶段到达钩子(只加载脚本并注册 factory;并发调用共享一个进行中的任务);`invalidate` 会丢弃 factory 与物化记录,使下一次 prefetch/import 重新加载脚本;它是 HMR(热模块替换)钩子。
|
||||
|
||||
Node 侧会扫描已启用的 Loader 配置项以发现 web `dshClient` 包,解析每个 `exports["./client"]`,把构建后的组合包哈希写入启动图,并通过 `/plugins` 提供该文件及其 sourcemap。源码启动会把宿主侧导入映射到 TypeScript 源码,但仍消费这一构建后的客户端导出;缺失文件共享一条构建说明,随后以 package/path list 列出各项,而无关的文件系统错误仍是独立故障。
|
||||
Node 侧会扫描已启用的 Loader 配置项以发现 web `dsh.client` 包,解析每个 `exports["./client"]`,把构建后的组合包哈希写入启动图,并通过 `/plugins` 提供该文件及其 sourcemap。源码启动会把宿主侧导入映射到 TypeScript 源码,但仍消费这一构建后的客户端导出;缺失文件共享一条构建说明,随后以 package/path list 列出各项,而无关的文件系统错误仍是独立故障。
|
||||
|
||||
## 模型体验
|
||||
|
||||
@@ -20,5 +20,5 @@ Node 侧会扫描已启用的 Loader 配置项以发现 web `dshClient` 包,
|
||||
|
||||
## 已知限制与暂缓事项
|
||||
|
||||
- **有意采用扁平模块图**:每个组合包是一个模块节点,其边只指向表中的叶节点;接口(loadCache/edges/invalidate)按通用模块图塑形,因此可以改变 externalization 粒度而不更改接口。
|
||||
- **有意采用扁平模块图**:每个组合包是一个模块节点,其边只指向表中的叶节点;接口(`loadCache`/`edges`/`invalidate`)已经支持通用模块图,因此可以改变 externalization 粒度而不更改接口。
|
||||
- **自身不记录卸载账目**:样式移除与 fiber 拆卸顺序属于 HMR 驱动器(`@deepseek-ai/dsh-client-hmr`);loader 只在每条记录中登记其拥有的样式标签 id。
|
||||
|
||||
@@ -1,8 +1,15 @@
|
||||
{
|
||||
"name": "@deepseek-ai/dsh-client-modules",
|
||||
"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,
|
||||
"description": "Client module system, dual-face: node half composes the __DSH_BOOT__ entry graph (incremental dsh.client 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-rc.1",
|
||||
"publishConfig": {
|
||||
"access": "restricted"
|
||||
},
|
||||
"repository": {
|
||||
"type": "git",
|
||||
"url": "git+https://github.com/deepseek-ai/deepseek-harness.git",
|
||||
"directory": "packages/client/modules"
|
||||
},
|
||||
"type": "module",
|
||||
"main": "lib/index.js",
|
||||
"types": "lib/types/index.d.ts",
|
||||
@@ -22,10 +29,12 @@
|
||||
"./src/*": "./src/*",
|
||||
"./package.json": "./package.json"
|
||||
},
|
||||
"dshClient": {
|
||||
"platform": "web",
|
||||
"inject": [],
|
||||
"immediately": true
|
||||
"dsh": {
|
||||
"client": {
|
||||
"platform": "web",
|
||||
"inject": [],
|
||||
"immediately": true
|
||||
}
|
||||
},
|
||||
"scripts": {
|
||||
"bundle": "tsdown",
|
||||
@@ -33,10 +42,10 @@
|
||||
},
|
||||
"license": "BSD-3-Clause",
|
||||
"devDependencies": {
|
||||
"@cordisjs/plugin-loader": "workspace:^",
|
||||
"@deepseek-ai/cordis-plugin-loader": "workspace:^",
|
||||
"@deepseek-ai/dsh-host-webserver": "workspace:^",
|
||||
"@deepseek-ai/dsh-invariants": "workspace:^",
|
||||
"cordis": "^4.0.0-rc.7"
|
||||
"@deepseek-ai/cordis": "workspace:^"
|
||||
},
|
||||
"files": [
|
||||
"lib/index.js",
|
||||
@@ -45,7 +54,7 @@
|
||||
"lib/types/**/*.d.ts"
|
||||
],
|
||||
"peerDependencies": {
|
||||
"@deepseek-ai/dsh-invariants": "^0.0.1",
|
||||
"cordis": "^4.0.0-rc.7"
|
||||
"@deepseek-ai/dsh-invariants": "workspace:^",
|
||||
"@deepseek-ai/cordis": "workspace:^"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -9,7 +9,7 @@
|
||||
* a no-op against the already-registered entry.
|
||||
* @module @deepseek-ai/dsh-client-modules/client
|
||||
*/
|
||||
import type { Context } from 'cordis'
|
||||
import type { Context } from '@deepseek-ai/cordis'
|
||||
import type { DshWindow } from './manifest.ts'
|
||||
|
||||
export { ClientModuleSystem } from './system.ts'
|
||||
|
||||
@@ -30,10 +30,10 @@
|
||||
* composes the wire.
|
||||
*/
|
||||
|
||||
import type {} from 'cordis'
|
||||
import type {} from '@deepseek-ai/cordis'
|
||||
import type { ClientModuleSystem } from './system.ts'
|
||||
|
||||
declare module 'cordis' {
|
||||
declare module '@deepseek-ai/cordis' {
|
||||
interface Context {
|
||||
/** The client module system the web shell builds at boot (provided by the `./client` wrapper plugin). */
|
||||
modules: ClientModuleLoader
|
||||
@@ -44,7 +44,7 @@ declare module 'cordis' {
|
||||
* One composed client entry pushed by the host (a 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
|
||||
* metadata (the authoritative edges live in each package's `dsh.client`
|
||||
* declaration and reach fibers through entry creation).
|
||||
*/
|
||||
export interface WebBootEntry {
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
/**
|
||||
* Node half of the client module system (dshClient dual-face package): scans
|
||||
* the host Loader's entries for `dshClient` packages, composes the
|
||||
* Node half of the client module system (`dsh.client` dual-face package): scans
|
||||
* the host Loader's entries for packages declaring `dsh.client`, composes the
|
||||
* `window.__DSH_BOOT__` entry graph (wire single source: {@link WebBootEntry}
|
||||
* in `./client/manifest.ts`), serves `/plugins/<id>/client.js` and its source
|
||||
* map, taps the index render to inject the boot manifest, and provides the
|
||||
@@ -26,9 +26,9 @@ 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 { Service } from '@deepseek-ai/cordis'
|
||||
import type { Context } from '@deepseek-ai/cordis'
|
||||
import type {} from '@deepseek-ai/cordis-plugin-loader'
|
||||
import type {} from '@deepseek-ai/dsh-host-webserver'
|
||||
import type { WebBootEntry, WebBootGraph } from './client/manifest.ts'
|
||||
|
||||
@@ -36,14 +36,14 @@ export type {
|
||||
BootManifest, BootModuleRow, BootPluginRow, WebBootEntry, WebBootGraph,
|
||||
} from './client/manifest.ts'
|
||||
|
||||
declare module 'cordis' {
|
||||
declare module '@deepseek-ai/cordis' {
|
||||
interface Context {
|
||||
/** The web plugin table (provided by the client-modules node half). */
|
||||
clientModuleHost: ClientModuleHostService
|
||||
}
|
||||
}
|
||||
|
||||
/** package.json `dshClient` declaration shape (file boundary — validated field by field). */
|
||||
/** package.json `dsh.client` declaration fields, validated one by one after reading the file. */
|
||||
interface DshClientDeclaration {
|
||||
inject?: string[]
|
||||
platform: string
|
||||
@@ -51,7 +51,7 @@ interface DshClientDeclaration {
|
||||
immediately?: boolean
|
||||
}
|
||||
|
||||
/** Resolved package metadata for one dshClient package (cached per name, never expires). */
|
||||
/** Resolved package metadata for one `dsh.client` package (cached per name, never expires). */
|
||||
interface PkgMeta {
|
||||
clientPath: string
|
||||
inject?: string[]
|
||||
@@ -105,21 +105,21 @@ interface WebPluginRecord {
|
||||
clientPath: string
|
||||
}
|
||||
|
||||
/** Narrow an unknown parsed JSON value to the dshClient declaration, throwing on malformed fields. */
|
||||
/** Narrow an unknown parsed JSON value to the `dsh.client` 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`)
|
||||
throw new Error(`client-modules: ${pkgName} has a non-object dsh.client 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`)
|
||||
throw new Error(`client-modules: ${pkgName} dsh.client.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`)
|
||||
throw new Error(`client-modules: ${pkgName} dsh.client.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`)
|
||||
throw new Error(`client-modules: ${pkgName} dsh.client.immediately must be a boolean`)
|
||||
}
|
||||
return {
|
||||
platform: decl.platform,
|
||||
@@ -138,7 +138,7 @@ function clientExportOf(pkgName: string, exportsField: unknown): string | undefi
|
||||
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`)
|
||||
throw new Error(`client-modules: ${pkgName} exports["./client"] must be a string or an object with a string default`)
|
||||
}
|
||||
|
||||
/** sha1 content hash shortened to 12 hex chars (bundle rev / graph rev). */
|
||||
@@ -175,7 +175,7 @@ export function injectBootManifest(html: string, graph: WebBootGraph): string {
|
||||
}
|
||||
|
||||
/**
|
||||
* The web plugin table service: incremental dshClient scan + wire composition
|
||||
* The web plugin table service: incremental `dsh.client` 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
|
||||
@@ -186,7 +186,7 @@ export class ClientModuleHostService extends Service {
|
||||
|
||||
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
|
||||
// subpath rows — or a package without a web `dsh.client` 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>()
|
||||
@@ -342,14 +342,18 @@ export class ClientModuleHostService extends Service {
|
||||
return null
|
||||
}
|
||||
const pkg = JSON.parse(readFileSync(pkgPath, 'utf8')) as Record<string, unknown>
|
||||
const decl = parseDshClient(pkgName, pkg.dshClient)
|
||||
const dsh = pkg.dsh
|
||||
const decl = parseDshClient(
|
||||
pkgName,
|
||||
dsh !== null && typeof dsh === 'object' ? (dsh as Record<string, unknown>).client : undefined,
|
||||
)
|
||||
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`)
|
||||
throw new Error(`client-modules: ${pkgName} declares dsh.client but exports no "./client" bundle`)
|
||||
}
|
||||
const meta: PkgMeta = {
|
||||
clientPath: join(dirname(pkgPath), clientRel),
|
||||
|
||||
@@ -4,7 +4,7 @@
|
||||
*/
|
||||
|
||||
/* jscpd:ignore-start */
|
||||
import type { Context } from 'cordis'
|
||||
import type { Context } from '@deepseek-ai/cordis'
|
||||
import type { InvariantInstaller } from '@deepseek-ai/dsh-invariants'
|
||||
|
||||
const PACKAGE_NAME = '@deepseek-ai/dsh-client-modules'
|
||||
|
||||
@@ -5,7 +5,7 @@ import type { IncomingMessage, ServerResponse } from 'node:http'
|
||||
import { tmpdir } from 'node:os'
|
||||
import { dirname, join } from 'node:path'
|
||||
import { pathToFileURL } from 'node:url'
|
||||
import { Context } from 'cordis'
|
||||
import { Context } from '@deepseek-ai/cordis'
|
||||
import { afterEach, describe, expect, it } from 'vitest'
|
||||
import type { HttpServerService, WebRoute } from '@deepseek-ai/dsh-host-webserver'
|
||||
import { ClientModuleHostService } from '../src/index.ts'
|
||||
@@ -17,8 +17,11 @@ afterEach(() => {
|
||||
root = undefined
|
||||
})
|
||||
|
||||
/** Create a resolvable dshClient package whose client export points at the returned path. */
|
||||
function writePackage(packageName: string): string {
|
||||
/** Create a resolvable package whose client export points at the returned path. */
|
||||
function writePackage(
|
||||
packageName: string,
|
||||
metadata: Record<string, unknown> = { dsh: { client: { platform: 'web' } } },
|
||||
): string {
|
||||
root ??= realpathSync(mkdtempSync(join(tmpdir(), 'dsh-client-modules-')))
|
||||
const pkgRoot = join(root, 'node_modules', ...packageName.split('/'))
|
||||
const clientPath = join(pkgRoot, 'lib', 'client.js')
|
||||
@@ -29,7 +32,7 @@ function writePackage(packageName: string): string {
|
||||
'./client': './lib/client.js',
|
||||
'./package.json': './package.json',
|
||||
},
|
||||
dshClient: { platform: 'web' },
|
||||
...metadata,
|
||||
}))
|
||||
return clientPath
|
||||
}
|
||||
@@ -66,6 +69,20 @@ function construct(packageNames: string[]): ClientModuleHostService {
|
||||
}
|
||||
|
||||
describe('client bundle activation', () => {
|
||||
it('allows sibling dsh roles', () => {
|
||||
const currentName = '@fixture/current-client-field'
|
||||
const clientPath = writePackage(currentName, {
|
||||
dsh: {
|
||||
bundle: { patch: './cordis.patch.yml' },
|
||||
client: { platform: 'web' },
|
||||
profile: { bundles: [] },
|
||||
},
|
||||
})
|
||||
mkdirSync(dirname(clientPath), { recursive: true })
|
||||
writeFileSync(clientPath, 'module.exports = {}\n')
|
||||
expect(construct([currentName]).graph().entries.map(entry => entry.id)).toEqual([currentName])
|
||||
})
|
||||
|
||||
it('groups missing bundles under one source-build instruction with a package/path list', () => {
|
||||
const firstName = '@fixture/missing-first'
|
||||
const secondName = '@fixture/missing-second'
|
||||
|
||||
@@ -2,5 +2,5 @@
|
||||
# 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 packages/client/runtime/README.md
|
||||
README.md: 6297464353ce10efca8fe0c973d6d1605324df0a
|
||||
README.zh.md: 0542b263df947716f6b636b04e134b2677a126b8
|
||||
README.md: 7c835deb58db149710495f97a2553c3de58d99da
|
||||
README.zh.md: edf4473bec7df2253c032c3da86da878cdeade09
|
||||
|
||||
@@ -2,8 +2,9 @@
|
||||
|
||||
English | [中文](README.zh.md)
|
||||
|
||||
Client cordis boot and React-free object services: SlotsService wraps SlotCore and supplies renderer data sources; SessionsService owns Session objects and the Chat-facing list, scope, and event-window state; SessionHistoryService lazily owns independent raw-history ledgers for inspection consumers, loading the current tail first and prepending one older page only when its consumer requests it. Each history snapshot exposes the raw window's absolute base sequence so a consumer detects a prepend even when the page adds no surface-visible node. 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 the Session, Workspace, and activated history owners without routing inspection state through Session or SessionManager, and bridges the registry-invalidation frames to typed ctx events (`commands/changed`, `settings/changed`, `credentials/changed`, `models/changed`) so surface caches refetch without touching the stream. 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. Each `Session` holds a generic `ProjectionValueStore` seeded from the history-tail `projections` block and updated by `session/projection` frames under higher-seq-wins; domain keys (including `todos`) are read via `projections.faceOf` / `useProjection`, not via `ConversationSnapshot`. The store also publishes one reference-stable whole-value map through `SessionSummary.projectionValues`, allowing global list consumers to reuse the same projections without creating per-session subscriptions.
|
||||
Client cordis boot and React-free object services: SlotsService wraps SlotCore and supplies renderer data sources; SessionsService owns Session objects, list and scope state, and the shared event window and history paging used by registered conversation view targets. 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 Session and Workspace owners and bridges the registry-invalidation frames to typed ctx events (`commands/changed`, `session/preset-changed`, `settings/changed`, `credentials/changed`, `models/changed`) so surface caches refetch without touching the stream. `host/session-preset-changed` also folds its preset into the session row, because the switch's RPC echo reaches only the client that issued it. 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. Each `Session` holds a generic `ProjectionValueStore` seeded from the history-tail `projections` block and updated by `session/projection` frames under higher-seq-wins; domain keys (including `todos`) are read via `projections.faceOf` / `useProjection`, not via `ConversationSnapshot`. The store also publishes one reference-stable whole-value map through `SessionSummary.projectionValues`, allowing global list consumers to reuse the same projections without creating per-session subscriptions.
|
||||
|
||||
`bindSettingsScope` is the browser mirror of the Host-side settings owner seam for one domain-owned namespace. It subscribes before starting a nonblocking initial read, publishes a uSES snapshot (status, section value, revision, writability, host/memory mode), serializes `set` writes with the latest known namespace revision, suppresses stale publications, recovers a rejected latest write from Host state, and reaches quiescence on plugin disposal. The default decoder validates each section against the namespace's own serialized wire schema (rehydrated through dsh-client-schema-form), so a domain adds a decoder only to narrow beyond that schema. Loopback pages use the Host settings API; remote pages stay in memory mode. Domain packages own the namespace schema, default, and live service rather than putting product policy in runtime.
|
||||
## Slot declaration injection
|
||||
|
||||
`ctx.slots.inject(name, callback)` makes a full `SlotMap` key the dependency for a contribution whose plugin can activate independently from the declaring entry. It runs `callback` synchronously when the declaration exists, otherwise waits; declaration collapse disposes the callback effect, and redeclaration reruns it. The controller belongs to the caller's plugin fiber, so unloading the contributor cancels either the wait or its active registrations. A direct `slots.register()` into an undeclared slot still throws.
|
||||
@@ -42,17 +43,17 @@ Each `Session` gives its contiguous event window to a `ConversationNodeAssembler
|
||||
|
||||
Definition authors keep matching local to the current event, give every correlated event a stable business id, and make updates replayable by log `seq`; renderers consume final Node data and constrained Location values rather than scanning Session or Chat collections. The [Conversation Node cookbook](../../../docs/cookbook/adding-a-conversation-node.md) gives the complete registration and pagination path.
|
||||
|
||||
`ui-conversation` registers the built-in Chat Definitions and the keyed Chat snapshot builder. Append-origin user, assistant, and Tool results remain the human record; model-only replacement copies stay out, except that a compaction checkpoint becomes its own marker and resolves missing summary provenance when an older page supplies it. Durable inbox splice Contexts classify next-step user messages as steering without making inbox state a Session special case. Context messages retain producer provenance and form. StatsLine reads `ConversationSnapshot.chat.legacy.nodes`, while Session mirrors that legacy slice into the top-level `nodes`, `partial`, and `runningCalls` public compatibility fields without running a second business fold. Trajectory consumes neither compatibility surface; its activated `session-history` inspection keeps an independent fold until it gains its own registered target.
|
||||
`ui-conversation` registers the built-in Chat Definitions and the keyed Chat snapshot builder. Append-origin user, assistant, and Tool results remain the human record; model-only replacement copies stay out, except that a compaction checkpoint becomes its own marker and resolves missing summary provenance when an older page supplies it. Durable inbox splice Contexts classify next-step user messages as steering without making inbox state a Session special case. Context messages retain producer provenance and form. StatsLine reads `ConversationSnapshot.chat.legacy.nodes`, while Session mirrors that legacy slice into the top-level `nodes`, `partial`, and `runningCalls` public compatibility fields without running a second business fold. `ui-trajectory` registers independent Definitions and a target builder over the same Session window; it preserves the existing stage-oriented view model without consuming the Chat compatibility fields or running another history fold.
|
||||
|
||||
The Chat builder keeps one mutable keyed store per Session. Content updates notify only the affected node key, structural changes rebuild order and Location membership, and a prepend adds rows without replacing existing keyed values. Assistant chunks update Definition State for every event but request at most one materialization per animation frame; final messages and Turn/Step closure publish immediately. See the [client Tool presentation decision](../../../.agents/notes/implemented/architecture/2026-08-08-client-tool-presentation-ownership.md).
|
||||
|
||||
## Request inspection
|
||||
## Trajectory request data
|
||||
|
||||
`SessionHistoryInspection.requests` is one chronological, purpose-discriminated provider-request stream. Assistant requests always carry their numeric `turn` and `step`; compaction requests carry `step: 0` and a `turn` owner that may be `null`. That null owner means a manual compaction ran standalone between turns, not that it belongs to either adjacent turn. A `session/end-seed` boundary closes an unmatched compaction request as an error at the boundary time with `Compaction was interrupted before completion.`; a later start projects as an independent request instead of overwriting the orphan.
|
||||
Trajectory Definitions assemble one chronological, purpose-discriminated provider-request stream. Assistant requests always carry their numeric `turn` and `step`; compaction requests carry `step: 0` and a `turn` owner that may be `null`. That null owner means a manual compaction ran standalone between turns, not that it belongs to either adjacent turn. A `session/end-seed` boundary closes an unmatched compaction request as an error at the boundary time with `Compaction was interrupted before completion.`; a later start projects as an independent request instead of overwriting the orphan.
|
||||
|
||||
## Code Mode child-call tree
|
||||
|
||||
Every `ToolCallBlock` recursively owns its children through `subCalls`, in start order. Chat's Tool Definition correlates root calls and results by call id, folds Code Dispatch start/settlement records into that root Context, and projects one keyed recursive tree; child calls never become independent Chat roots. When a start falls outside the loaded window, its settlement remains renderable with `callTime: null`. A child update copies only its ancestor path, so unchanged siblings retain object identity. Edges that introduce a cycle or exceed the fixed 256-call depth limit are consumed without mutating the tree. The separate Trajectory history fold still uses Runtime's `ToolCallTree` over the same nested data contract.
|
||||
Every `ToolCallBlock` recursively owns its children through `subCalls`, in start order. Chat's Tool Definition correlates root calls and results by call id, folds Code Dispatch start/settlement records into that root Context, and projects one keyed recursive tree; child calls never become independent Chat roots. When a start falls outside the loaded window, its settlement remains renderable with `callTime: null`. A child update copies only its ancestor path, so unchanged siblings retain object identity. Edges that introduce a cycle or exceed the fixed 256-call depth limit are consumed without mutating the tree. Trajectory's Tool Definition independently assembles the same nested data contract for its target.
|
||||
|
||||
## Session title projection
|
||||
|
||||
|
||||
@@ -2,8 +2,9 @@
|
||||
|
||||
[English](README.md) | 中文
|
||||
|
||||
客户端 cordis 启动与不依赖 React 的对象服务:SlotsService 包装 SlotCore 并提供 renderer 数据源;SessionsService 拥有 Session 对象以及 Chat 所需的列表、scope 和事件窗口状态;SessionHistoryService 为检查类消费方惰性拥有彼此独立的原始历史账本,先加载当前尾部,并仅在消费方请求时向前补入一页更早历史。每份历史快照都会公开原始窗口的绝对基准序号,因此即使该页没有新增任何 surface 可见节点,消费方仍能检测到向前补页。WorkspacesService 依赖 SessionsService,拥有 Workspace 对象、列表/操作、默认目标派生,以及 New Session 空会话复用入口(`connectWorkspace`)。运行时把共享 Host 流分发给 Session、Workspace 和已激活的历史数据所有者,不让检查状态经过 Session 或 SessionManager,并把注册表失效帧桥接为类型化 ctx 事件(`commands/changed`、`settings/changed`、`credentials/changed`、`models/changed`),使各表面缓存无需触碰流即可重拉。客户端会话一律由 Host 创建(一次 `session.create` 同时产生 Session、agent(智能体)和 cwd);客户端不持有任何实体化之前的会话状态——agent scope(host dsh-scope 的客户端镜像,以 agent/session 共用 id 为键)在会话行进入列表镜像时创建,并随 prune 销毁。每个 `Session` 持有一个通用的 `ProjectionValueStore`,由历史记录尾部的 `projections` 块播种,并经 `session/projection` 帧按 seq 高者胜更新;领域键(含 `todos`)经 `projections.faceOf`/`useProjection` 读取,不经 `ConversationSnapshot`。该 store 还会通过 `SessionSummary.projectionValues` 发布一份引用稳定的完整值映射,使全局列表消费方无需为每个会话创建订阅,即可复用同一组投影。
|
||||
客户端 cordis 启动与不依赖 React 的对象服务:SlotsService 包装 SlotCore 并提供 renderer 数据源;SessionsService 拥有 Session 对象、列表与 scope 状态,以及供已注册 conversation view target 共用的事件窗口与历史分页。WorkspacesService 依赖 SessionsService,拥有 Workspace 对象、列表/操作、默认目标派生,以及 New Session 空会话复用入口(`connectWorkspace`)。运行时把共享 Host 流分发给 Session 与 Workspace 所有者,并把注册表失效帧桥接为类型化 ctx 事件(`commands/changed`、`session/preset-changed`、`settings/changed`、`credentials/changed`、`models/changed`),使各表面缓存无需触碰流即可重拉。`host/session-preset-changed` 还会把其中的 preset 折进会话行,因为这次切换的 RPC 回执只会到达发起它的那个客户端。客户端会话一律由 Host 创建(一次 `session.create` 同时产生 Session、agent(智能体)和 cwd);客户端不持有任何实体化之前的会话状态——agent scope(host dsh-scope 的客户端镜像,以 agent/session 共用 id 为键)在会话行进入列表镜像时创建,并随 prune 销毁。约定:api-contracts v3 §4。每个 `Session` 持有一个通用的 `ProjectionValueStore`,由历史记录尾部的 `projections` 块播种,并经 `session/projection` 帧按 seq 高者胜更新;领域键(含 `todos`)经 `projections.faceOf`/`useProjection` 读取,不经 `ConversationSnapshot`。该 store 还会通过 `SessionSummary.projectionValues` 发布一份引用稳定的完整值映射,使全局列表消费方无需为每个会话创建订阅,即可复用同一组投影。
|
||||
|
||||
`bindSettingsScope` 面向单个由领域持有的 namespace,是 Host 侧 settings owner seam 的浏览器镜像。它在开始非阻塞初始读取前建立订阅,发布 uSES 快照(状态、分节值、revision、可写性、host/内存模式),使用已知最新 namespace revision 串行执行 `set` 写入,抑制陈旧发布,并在最新写入被拒时从 Host 状态恢复;插件释放时,它会达到完全停稳。默认解码器会对照该 namespace 自身的序列化 wire schema(经 dsh-client-schema-form 还原)校验每个分节,因此领域只有在需要比该 schema 进一步收窄时才添加解码器。回环页面使用 Host settings API,远程页面则停留在内存模式。namespace schema、默认值与实时服务归领域包所有,而非把产品政策放入运行时。
|
||||
## Slot 声明注入
|
||||
|
||||
`ctx.slots.inject(name, callback)` 将完整的 `SlotMap` key 作为贡献项的依赖,适用于贡献方插件可独立于声明条目激活的情形。声明存在时,它会同步运行 `callback`,否则等待;声明折叠会 dispose(资源释放)回调 effect,重新声明则会再次运行回调。控制器归调用方的插件 fiber 所有,因此卸载贡献方会取消等待或移除其活跃注册项。直接调用 `slots.register()` 向未声明 slot 注册仍会抛出异常。
|
||||
@@ -42,17 +43,17 @@ SlotsService 分别为 renderer 提供 `useSessions` 与 `useWorkspaces` 的裸
|
||||
|
||||
Definition 作者只根据当前事件完成匹配,为每条关联事件提供稳定业务 id,并保证 update 能按日志 `seq` 回放;renderer 只消费最终 Node data 与受限 Location value,不扫描 Session 或 Chat 集合。完整注册和分页路径见 [Conversation Node 实操手册](../../../docs/cookbook/adding-a-conversation-node.md)。
|
||||
|
||||
`ui-conversation` 注册内建 Chat Definition 与 keyed Chat snapshot builder。append 来源的 user、assistant 和 Tool result 构成人类可见记录;仅供模型使用的 replacement 副本不进入 Chat,compaction 检查点除外,它会成为独立标记,并在更早分页补齐 summary 溯源后更新。持久 inbox splice Context 能把 next-step 用户消息判定为 steering,无须让 inbox 状态成为 Session 特例。上下文消息保留生产者 provenance 与 form。StatsLine 读取 `ConversationSnapshot.chat.legacy.nodes`;Session 则把该 legacy slice 镜像到顶层 `nodes`、`partial` 和 `runningCalls` 公共兼容字段,无须运行第二套业务 fold。Trajectory 不消费这两种兼容表面;在它获得独立注册 target 之前,已激活的 `session-history` inspection 继续维护独立 fold。
|
||||
`ui-conversation` 注册内建 Chat Definition 与 keyed Chat snapshot builder。append 来源的 user、assistant 和 Tool result 构成人类可见记录;仅供模型使用的 replacement 副本不进入 Chat,compaction 检查点除外,它会成为独立标记,并在更早分页补齐 summary 溯源后更新。持久 inbox splice Context 能把 next-step 用户消息判定为 steering,无须让 inbox 状态成为 Session 特例。上下文消息保留生产者 provenance 与 form。StatsLine 读取 `ConversationSnapshot.chat.legacy.nodes`;Session 则把该 legacy slice 镜像到顶层 `nodes`、`partial` 和 `runningCalls` 公共兼容字段,无须运行第二套业务 fold。`ui-trajectory` 在同一个 Session 窗口上注册独立 Definition 与 target builder;它保留现有的 stage-oriented view model,既不消费 Chat 兼容字段,也不运行另一套 history fold。
|
||||
|
||||
Chat builder 为每个 Session 保留一个 mutable keyed store。内容更新只通知受影响的 node key;结构变化才重建顺序和 Location 成员关系;prepend 只增加行,不替换既有 keyed value。每个 Assistant chunk 都会更新 Definition State,但最多每个 animation frame 请求一次物化;final message 与 Turn/Step 关闭会立即发布。参见 [Client Tool 展示所有权决策](../../../.agents/notes/implemented/architecture/2026-08-08-client-tool-presentation-ownership.md)。
|
||||
|
||||
## 请求检查
|
||||
## Trajectory 请求数据
|
||||
|
||||
`SessionHistoryInspection.requests` 是一条按时间顺序排列、以用途为判别字段的提供方请求流。助手请求始终携带数值型 `turn` 与 `step`;压缩请求携带 `step: 0`,其 `turn` 所有者可以是 `null`。这个 null 所有者表示手动压缩独立运行在两个轮次之间,并不表示它属于任一相邻轮次。`session/end-seed` 边界会在边界时刻将未匹配的压缩请求以错误状态结束,错误固定为 `Compaction was interrupted before completion.`;后续 start 会投影为独立请求,而不会覆盖这项遗留的未匹配请求。
|
||||
Trajectory Definition 组装出一条按时间顺序排列、以用途为判别字段的提供方请求流。助手请求始终携带数值型 `turn` 与 `step`;压缩请求携带 `step: 0`,其 `turn` 所有者可以是 `null`。这个 null 所有者表示手动压缩独立运行在两个轮次之间,并不表示它属于任一相邻轮次。`session/end-seed` 边界会在边界时刻将未匹配的压缩请求以错误状态结束,错误固定为 `Compaction was interrupted before completion.`;后续 start 会投影为独立请求,而不会覆盖这项遗留的未匹配请求。
|
||||
|
||||
## Code Mode 子调用树
|
||||
|
||||
每个 `ToolCallBlock` 都通过 `subCalls` 按启动顺序递归拥有自己的子调用。Chat 的 Tool Definition 按 call id 关联 root call 与 result,把 Code Dispatch 的 start/settlement 记录折叠进该 root Context,并投影为一棵 keyed 递归树;child call 不会成为独立 Chat root。start 落在已加载窗口之外时,其 settlement 仍以 `callTime: null` 渲染。一次 child 更新只复制其祖先链,因此未变化的 sibling 保持对象身份。会引入环或超过固定 256 层深度上限的边会被消费,但不会修改树。独立的 Trajectory history fold 仍通过 Runtime 的 `ToolCallTree` 生成同一种嵌套数据契约。
|
||||
每个 `ToolCallBlock` 都通过 `subCalls` 按启动顺序递归拥有自己的子调用。Chat 的 Tool Definition 按 call id 关联 root call 与 result,把 Code Dispatch 的 start/settlement 记录折叠进该 root Context,并投影为一棵 keyed 递归树;child call 不会成为独立 Chat root。start 落在已加载窗口之外时,其 settlement 仍以 `callTime: null` 渲染。一次 child 更新只复制其祖先链,因此未变化的 sibling 保持对象身份。会引入环或超过固定 256 层深度上限的边会被消费,但不会修改树。Trajectory 的 Tool Definition 为自己的 target 独立组装同一种嵌套数据契约。
|
||||
|
||||
## Session 标题投影
|
||||
|
||||
|
||||
@@ -1,8 +1,15 @@
|
||||
{
|
||||
"name": "@deepseek-ai/dsh-client-runtime",
|
||||
"description": "Client core services: SlotsService, SessionsService (scope tree + object layer)",
|
||||
"version": "0.0.1",
|
||||
"private": true,
|
||||
"version": "0.0.1-rc.1",
|
||||
"publishConfig": {
|
||||
"access": "restricted"
|
||||
},
|
||||
"repository": {
|
||||
"type": "git",
|
||||
"url": "git+https://github.com/deepseek-ai/deepseek-harness.git",
|
||||
"directory": "packages/client/runtime"
|
||||
},
|
||||
"type": "module",
|
||||
"main": "lib/index.js",
|
||||
"types": "lib/types/index.d.ts",
|
||||
@@ -22,20 +29,23 @@
|
||||
"./src/*": "./src/*",
|
||||
"./package.json": "./package.json"
|
||||
},
|
||||
"dshClient": {
|
||||
"inject": [
|
||||
"@deepseek-ai/dsh-client-connection",
|
||||
"@deepseek-ai/dsh-typert-registry"
|
||||
],
|
||||
"platform": "web",
|
||||
"immediately": true
|
||||
"dsh": {
|
||||
"client": {
|
||||
"inject": [
|
||||
"@deepseek-ai/dsh-client-connection",
|
||||
"@deepseek-ai/dsh-typert-registry"
|
||||
],
|
||||
"platform": "web",
|
||||
"immediately": true
|
||||
}
|
||||
},
|
||||
"license": "BSD-3-Clause",
|
||||
"dependencies": {
|
||||
"@deepseek-ai/dsh-agent": "workspace:^",
|
||||
"@deepseek-ai/dsh-attachment": "workspace:^",
|
||||
"@deepseek-ai/dsh-client-connection": "workspace:^",
|
||||
"@deepseek-ai/dsh-client-schema-form": "workspace:^",
|
||||
"@deepseek-ai/dsh-commands": "workspace:^",
|
||||
"@deepseek-ai/dsh-compact": "workspace:^",
|
||||
"@deepseek-ai/dsh-client-ui-slots": "workspace:^",
|
||||
"@deepseek-ai/dsh-host-apiproxy": "workspace:^",
|
||||
"@deepseek-ai/dsh-llm": "workspace:^",
|
||||
@@ -49,10 +59,10 @@
|
||||
"zustand": "~4.4.7"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@deepseek-ai/dsh-invariants": "^0.0.1",
|
||||
"@deepseek-ai/dsh-type-meta": "^0.0.1",
|
||||
"@deepseek-ai/dsh-typert-registry": "^0.0.1",
|
||||
"cordis": "^4.0.0-rc.7"
|
||||
"@deepseek-ai/dsh-invariants": "workspace:^",
|
||||
"@deepseek-ai/dsh-type-meta": "workspace:^",
|
||||
"@deepseek-ai/dsh-typert-registry": "workspace:^",
|
||||
"@deepseek-ai/cordis": "workspace:^"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@deepseek-ai/dsh-invariants": "workspace:^",
|
||||
@@ -60,7 +70,8 @@
|
||||
"@deepseek-ai/dsh-type-meta": "workspace:^",
|
||||
"@deepseek-ai/dsh-typert-registry": "workspace:^",
|
||||
"@types/react": "~18.3.1",
|
||||
"cordis": "^4.0.0-rc.7"
|
||||
"@deepseek-ai/cordis": "workspace:^",
|
||||
"@deepseek-ai/schemastery": "workspace:^"
|
||||
},
|
||||
"files": [
|
||||
"lib/index.js",
|
||||
|
||||
@@ -15,8 +15,8 @@
|
||||
* — a cold session's host Agent is already disposed while its client actx
|
||||
* stays alive for history viewing.
|
||||
*/
|
||||
import { Context as CordisContext } from 'cordis'
|
||||
import type { Context, Fiber } from 'cordis'
|
||||
import { Context as CordisContext } from '@deepseek-ai/cordis'
|
||||
import type { Context, Fiber } from '@deepseek-ai/cordis'
|
||||
import type { SessionId } from '@deepseek-ai/dsh-client-connection/client'
|
||||
import type { TypeRTClientRemote, TypeRTRemoteScopeApi } from '@deepseek-ai/dsh-type-meta'
|
||||
|
||||
|
||||
@@ -110,6 +110,17 @@ export interface ConversationViewNode {
|
||||
readonly data: unknown
|
||||
}
|
||||
|
||||
/** Merge-extensible immutable snapshots published by registered view targets. */
|
||||
export interface ConversationViewSnapshotMap {}
|
||||
|
||||
/** Stable reader over the latest snapshot of every registered view target. */
|
||||
export interface ConversationViewSnapshotStore {
|
||||
/** @param target - registered view target. @returns its current snapshot. */
|
||||
get<Target extends Extract<keyof ConversationViewSnapshotMap, string>>(
|
||||
target: Target,
|
||||
): ConversationViewSnapshotMap[Target] | undefined
|
||||
}
|
||||
|
||||
/** Final Chat render unit produced directly by a business Definition. */
|
||||
export interface ChatConversationViewNode extends ConversationViewNode {
|
||||
readonly target: 'chat'
|
||||
@@ -159,6 +170,8 @@ export type ConversationLocationDataScope = 'step' | 'turn'
|
||||
/** One independently registered business Event-to-Node state machine. */
|
||||
export interface ConversationNodeDefinition<State = unknown> {
|
||||
readonly kind: string
|
||||
/** Sole view target owned by this Definition; omitted for state-only Contexts. */
|
||||
readonly target?: string
|
||||
/**
|
||||
* Extract this Definition's stable business identity from one event.
|
||||
* @param event - raw Session event; no Context or history access is available.
|
||||
@@ -207,15 +220,11 @@ export interface ConversationNodeDefinition<State = unknown> {
|
||||
scope: ConversationLocationDataScope,
|
||||
): ConversationLocationData | null
|
||||
/**
|
||||
* Materialize one final Node for a registered view target.
|
||||
* Materialize one final Node for this Definition's declared view target.
|
||||
* @param context - latest complete Context.
|
||||
* @param target - registered view target such as `chat`.
|
||||
* @returns final Node, or null when this Context is not currently visible.
|
||||
*/
|
||||
buildViewNode(
|
||||
context: ConversationNodeContext<State>,
|
||||
target: string,
|
||||
): ConversationViewNode | null
|
||||
buildViewNode?(context: ConversationNodeContext<State>): ConversationViewNode | null
|
||||
}
|
||||
|
||||
/** Reference-stable Turn/Step facts published beside view Nodes. */
|
||||
|
||||
@@ -1,43 +0,0 @@
|
||||
import type {
|
||||
RpcError, SessionId,
|
||||
} from '@deepseek-ai/dsh-client-connection/client'
|
||||
import type { SessionHistoryInspection } from '../sessions/history.ts'
|
||||
import type { ObservableSnapshot } from './store.ts'
|
||||
|
||||
/** Observable state of one independently loaded session history ledger. */
|
||||
export interface SessionHistorySnapshot {
|
||||
state: 'cold' | 'loading' | 'ready' | 'error'
|
||||
error: RpcError | null
|
||||
hasMore: boolean
|
||||
/** Absolute sequence of the first loaded raw event, or zero for an empty window. */
|
||||
baseSeq: number
|
||||
inspection: SessionHistoryInspection
|
||||
}
|
||||
|
||||
/** Read-only history source addressed by session id. */
|
||||
export interface SessionHistoryFace
|
||||
extends ObservableSnapshot<SessionHistorySnapshot> {
|
||||
readonly sessionId: SessionId
|
||||
/**
|
||||
* Load the current tail without reading older pages.
|
||||
* @param signal - Consumer lifetime.
|
||||
* @returns When the tail is ready or loading fails.
|
||||
*/
|
||||
loadTail(signal?: AbortSignal): Promise<void>
|
||||
/**
|
||||
* Prepend one older page when the current window has a predecessor.
|
||||
* @param signal - Consumer lifetime.
|
||||
* @returns Whether the loaded window advanced.
|
||||
*/
|
||||
loadOlder(signal?: AbortSignal): Promise<boolean>
|
||||
}
|
||||
|
||||
/** Runtime service resolving independent history sources. */
|
||||
export interface ISessionHistory {
|
||||
/**
|
||||
* Resolve the identity-stable source for a session.
|
||||
* @param sessionId - Host session identity.
|
||||
* @returns The source owned outside Session and SessionManager.
|
||||
*/
|
||||
source(sessionId: SessionId): SessionHistoryFace
|
||||
}
|
||||
@@ -7,9 +7,9 @@
|
||||
* must stub); runtime-internal entry points (history staging, wire-frame
|
||||
* dispatch) stay on the class, invisible out here.
|
||||
*/
|
||||
import type { ContentBlock } from '@deepseek-ai/dsh-llm/types'
|
||||
import type { AttachmentIdType, ImageAttachmentRef } from '@deepseek-ai/dsh-attachment'
|
||||
import type {
|
||||
MessageId, QueueAction, RpcResult, SessionId,
|
||||
MessageId, PromptContentPart, QueueAction, RpcResult, SessionId,
|
||||
} from '@deepseek-ai/dsh-client-connection/client'
|
||||
import type { ConversationSnapshot } from '../sessions/conversation.ts'
|
||||
import type { ObservableSnapshot } from './store.ts'
|
||||
@@ -33,11 +33,19 @@ export interface ISession {
|
||||
readonly projections: ProjectionsFace
|
||||
/**
|
||||
* Send a prompt into the session.
|
||||
* @param content - model-facing content blocks.
|
||||
* @param content - text plus browser-owned temporary image uploads.
|
||||
* @param mode - 'queue' appends a turn; 'steer' interrupts the running one.
|
||||
* @returns acceptance, or the business error (also mirrored into snapshot.promptError).
|
||||
*/
|
||||
prompt(content: ContentBlock[], mode: 'queue' | 'steer'): Promise<RpcResult<{ accepted: true }>>
|
||||
prompt(content: PromptContentPart[], mode: 'queue' | 'steer'): Promise<RpcResult<{ accepted: true }>>
|
||||
/**
|
||||
* Resolve one durable image referenced by this session.
|
||||
* @param attachmentId - opaque id found in the folded session log.
|
||||
* @returns the authenticated reference and decoded bytes.
|
||||
*/
|
||||
readAttachment(
|
||||
attachmentId: AttachmentIdType,
|
||||
): Promise<RpcResult<{ attachment: ImageAttachmentRef; data: Uint8Array }>>
|
||||
/**
|
||||
* Apply one edit, remove, or strict steer action to a still-pending queue occurrence.
|
||||
* @param itemId - agent-owned inbox occurrence identity.
|
||||
|
||||
@@ -7,7 +7,7 @@
|
||||
* [SessionsPort](./sessions-port.ts). Widening this interface is the
|
||||
* explicit act of widening what features may do to the sessions domain.
|
||||
*/
|
||||
import type { Context } from 'cordis'
|
||||
import type { Context } from '@deepseek-ai/cordis'
|
||||
import type {
|
||||
RpcResult, SessionId, SubagentAddress,
|
||||
} from '@deepseek-ai/dsh-client-connection/client'
|
||||
|
||||
@@ -27,11 +27,11 @@ export interface IWorkspaces {
|
||||
*/
|
||||
startSession(workspaceId?: WorkspaceId): void
|
||||
/**
|
||||
* Create a Workspace by name or register an existing path.
|
||||
* @param input - exactly one Host create spelling.
|
||||
* Register an existing path as a Workspace.
|
||||
* @param input - the Host create payload.
|
||||
* @returns the created or idempotently resolved Workspace.
|
||||
*/
|
||||
create(input: { name: string } | { path: string }): Promise<WorkspaceView>
|
||||
create(input: { path: string }): Promise<WorkspaceView>
|
||||
/**
|
||||
* Open the Host's native directory picker.
|
||||
* @returns the selected path, or null when the user cancelled.
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { Service } from 'cordis'
|
||||
import { Service } from '@deepseek-ai/cordis'
|
||||
|
||||
/** Shared lifecycle and stable-entry storage for one Conversation Definition registry. */
|
||||
export abstract class ConversationDefinitionRegistry<Definition> extends Service {
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import type { Context } from 'cordis'
|
||||
import type { Context } from '@deepseek-ai/cordis'
|
||||
import type { ConversationNodeDefinition } from '../contract/conversation.ts'
|
||||
import { ConversationDefinitionRegistry } from './definition-registry.ts'
|
||||
|
||||
@@ -17,6 +17,7 @@ export class ConversationEventRegistry extends ConversationDefinitionRegistry<Co
|
||||
* @returns idempotent disposer.
|
||||
*/
|
||||
register(definition: ConversationNodeDefinition): () => void {
|
||||
assertDefinitionTarget(definition)
|
||||
return this.registerDefinition(
|
||||
definition.kind,
|
||||
definition,
|
||||
@@ -31,6 +32,9 @@ export class ConversationEventRegistry extends ConversationDefinitionRegistry<Co
|
||||
* @returns idempotent disposer.
|
||||
*/
|
||||
registerFallback(definition: ConversationNodeDefinition): () => void {
|
||||
assertDefinitionTarget(definition)
|
||||
const target = definition.target
|
||||
if (target === undefined) throw new Error('conversation fallback Definition must declare a target')
|
||||
if (this.fallback !== undefined) throw new Error('conversation fallback Definition is already registered')
|
||||
const owner = this.ctx
|
||||
const dispose = owner.effect(() => {
|
||||
@@ -52,5 +56,12 @@ export class ConversationEventRegistry extends ConversationDefinitionRegistry<Co
|
||||
fallbackEntry(): ConversationNodeDefinition | undefined {
|
||||
return this.fallback
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
function assertDefinitionTarget(definition: ConversationNodeDefinition): void {
|
||||
if ((definition.target === undefined) !== (definition.buildViewNode === undefined)) {
|
||||
throw new Error(
|
||||
`conversation Definition "${definition.kind}" must declare target and buildViewNode together`,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import type { Context } from 'cordis'
|
||||
import type { Context } from '@deepseek-ai/cordis'
|
||||
import type { ConversationViewDefinition } from '../contract/conversation.ts'
|
||||
import { ConversationDefinitionRegistry } from './definition-registry.ts'
|
||||
|
||||
|
||||
@@ -1,12 +1,11 @@
|
||||
/** Browser runtime services for slots, sessions, workspaces, and connection-stream delivery. */
|
||||
import type { Context } from 'cordis'
|
||||
import type { Context } from '@deepseek-ai/cordis'
|
||||
import type { ConnectionHandle, SessionId } from '@deepseek-ai/dsh-client-connection/client'
|
||||
import type { TypeRTContext } from '@deepseek-ai/dsh-type-meta'
|
||||
import type { MaybeSnapshotSelectorHook, SnapshotSelectorHook } from '@deepseek-ai/dsh-client-ui-slots'
|
||||
import { SlotsService } from './slots.ts'
|
||||
import { SessionsService } from './sessions/service.ts'
|
||||
import type { SessionListState } from './sessions/service.ts'
|
||||
import { SessionHistoryService } from './session-history/service.ts'
|
||||
import { WorkspacesService } from './workspaces/service.ts'
|
||||
import type { ConversationSnapshot } from './sessions/conversation.ts'
|
||||
import type { UseProjection } from './sessions/projection-store.ts'
|
||||
@@ -28,12 +27,12 @@ export type {
|
||||
ConversationLocation, ConversationMatch, ConversationMatchResult,
|
||||
ConversationNodeContext, ConversationNodeDefinition, ConversationPreviousContext,
|
||||
ConversationPublication, ConversationTimelineSnapshot, ConversationTurnDataMap, ConversationViewBuilder,
|
||||
ConversationViewDefinition, ConversationViewNode, StepLocation, TurnLocation,
|
||||
ConversationViewDefinition, ConversationViewNode, ConversationViewSnapshotMap,
|
||||
ConversationViewSnapshotStore, StepLocation, TurnLocation,
|
||||
} from './contract/conversation.ts'
|
||||
export type { ConversationRuntime } from './sessions/conversation-assembler.ts'
|
||||
export type { RootOwnerProps } from './slots.ts'
|
||||
export { SessionCreateError, SessionsService, scopeOf, workspaceTitleOf } from './sessions/service.ts'
|
||||
export { SessionHistoryService } from './session-history/service.ts'
|
||||
export { indexSubagentDescendants } from './sessions/subagent-lineage.ts'
|
||||
export type { SubagentDescendantSummary } from './sessions/subagent-lineage.ts'
|
||||
// The provide channel is shared with the client test runtime (one
|
||||
@@ -43,12 +42,11 @@ export type { SessionProvideChannelHost } from './sessions/provide.ts'
|
||||
export { createScope } from './agents/scope.ts'
|
||||
export type { AgentScopeHandle } from './agents/scope.ts'
|
||||
export { DirectoryBrowseError, WorkspaceCreateError, WorkspacesService } from './workspaces/service.ts'
|
||||
export { bindSettingsScope, SettingsScopeController } from './settings-scope.ts'
|
||||
export type { SettingsScope, SettingsScopeSnapshot, SettingsScopeSpec } from './settings-scope.ts'
|
||||
export { resolveWorkspacePath } from './workspaces/path.ts'
|
||||
export type { Session } from './sessions/session.ts'
|
||||
export type { ISession, ProjectionsFace, SessionFace } from './contract/session.ts'
|
||||
export type {
|
||||
ISessionHistory, SessionHistoryFace, SessionHistorySnapshot,
|
||||
} from './contract/session-history.ts'
|
||||
export type { AgentContext, ISessions } from './contract/sessions.ts'
|
||||
export type { IWorkspaces } from './contract/workspaces.ts'
|
||||
export type {
|
||||
@@ -74,7 +72,9 @@ export type {
|
||||
LegacyConversationSlice, PartialAssistant, RunningToolCall,
|
||||
SteeringMessageNode, TodoItem, ToolCallBlock, ToolResultNode, TurnErrorNode, UnknownSurfaceNode, UserMessageNode,
|
||||
} from './sessions/conversation.ts'
|
||||
export { EMPTY_CHAT_SNAPSHOT, toAssistantBlock, toAssistantBlocks } from './sessions/conversation.ts'
|
||||
export {
|
||||
EMPTY_CHAT_SNAPSHOT, EMPTY_CONVERSATION_VIEWS, toAssistantBlock, toAssistantBlocks,
|
||||
} from './sessions/conversation.ts'
|
||||
export { emptyAssistantBlock } from './sessions/partial.ts'
|
||||
export { isTokenDelta } from './sessions/assistant-timing.ts'
|
||||
export { contextForm, contextProvenance } from './sessions/context-provenance.ts'
|
||||
@@ -88,8 +88,6 @@ export type {
|
||||
export type {
|
||||
ConversationPromptSnapshot, RequestInspectionSnapshot, RequestPromptChange, RequestView,
|
||||
} from './sessions/request-inspection.ts'
|
||||
export type { ConversationHistoryProjection } from './session-history/history-fold.ts'
|
||||
export type { SessionHistoryInspection } from './sessions/history.ts'
|
||||
export { PendingWait } from './sessions/pending.ts'
|
||||
export type {
|
||||
PendingInteraction, PendingInteractionStatus, PendingKind, PendingPayloads,
|
||||
@@ -144,7 +142,7 @@ declare module '@deepseek-ai/dsh-client-ui-slots' {
|
||||
}
|
||||
}
|
||||
|
||||
declare module 'cordis' {
|
||||
declare module '@deepseek-ai/cordis' {
|
||||
interface Events {
|
||||
/**
|
||||
* A slot's definition or registration set changed.
|
||||
@@ -181,6 +179,18 @@ declare module 'cordis' {
|
||||
* @mode emit
|
||||
*/
|
||||
'models/changed'(): void
|
||||
/**
|
||||
* One session's agent preset changed (host/session-preset-changed
|
||||
* passthrough), so everything its composition decides — the command
|
||||
* catalog, the skill catalog — is stale for that session and no other.
|
||||
* Every connected client observes it, not only the one that issued the
|
||||
* switch. Subscribers refetch their own session-keyed caches; the frame
|
||||
* carries no catalog.
|
||||
* @mode emit
|
||||
* @param sessionId - the session whose composition changed.
|
||||
* @param agentPreset - the preset it now runs.
|
||||
*/
|
||||
'session/preset-changed'(sessionId: SessionId, agentPreset: string): void
|
||||
/**
|
||||
* A connection generation was (re-)established. Wire-derived caches must
|
||||
* treat their state as stale and repull (commands directory; the queue
|
||||
@@ -197,8 +207,6 @@ declare module 'cordis' {
|
||||
conversationViews: import('./conversation/view-registry.ts').ConversationViewRegistry
|
||||
/** The outward face only; the concrete service stays inside the runtime. */
|
||||
sessions: import('./contract/sessions.ts').ISessions
|
||||
/** Read-only history sources isolated from Chat sessions and workspace state. */
|
||||
sessionHistory: import('./contract/session-history.ts').ISessionHistory
|
||||
/** The outward face only; the concrete service stays inside the runtime. */
|
||||
workspaces: import('./contract/workspaces.ts').IWorkspaces
|
||||
}
|
||||
@@ -221,7 +229,6 @@ export function apply(ctx: Context): void {
|
||||
ctx.typert.contexts.registerClient('agent', {
|
||||
identity: candidate => sessions.scopeOf(candidate),
|
||||
})
|
||||
const sessionHistory = new SessionHistoryService(ctx, connection.api)
|
||||
const workspaces = new WorkspacesService(ctx, connection.api, sessions)
|
||||
ctx.effect(
|
||||
() => workspaces.startInitialSelection(),
|
||||
@@ -230,11 +237,6 @@ export function apply(ctx: Context): void {
|
||||
const loop = connection.start({
|
||||
onMuxEnvelope: (envelope) => {
|
||||
sessions.handleMuxEnvelope(envelope)
|
||||
try {
|
||||
sessionHistory.handleMuxEnvelope(envelope)
|
||||
} catch (error) {
|
||||
console.error('[web-runtime] history frame routing failed:', error)
|
||||
}
|
||||
},
|
||||
onHostEnvelope: (envelope) => {
|
||||
sessions.handleHostEnvelope(envelope)
|
||||
@@ -244,24 +246,17 @@ export function apply(ctx: Context): void {
|
||||
// and model surfaces) subscribe on ctx.
|
||||
const frame = envelope.payload
|
||||
if (frame.type === 'host/commands-changed') ctx.emit('commands/changed')
|
||||
else if (frame.type === 'host/session-preset-changed') {
|
||||
ctx.emit('session/preset-changed', frame.sessionId, frame.agentPreset)
|
||||
}
|
||||
else if (frame.type === 'host/settings-changed') ctx.emit('settings/changed', frame.ns)
|
||||
else if (frame.type === 'host/credentials-changed') ctx.emit('credentials/changed', frame.ref)
|
||||
else if (frame.type === 'host/models-changed') ctx.emit('models/changed')
|
||||
try {
|
||||
sessionHistory.handleHostEnvelope(envelope)
|
||||
} catch (error) {
|
||||
console.error('[web-runtime] history host-frame routing failed:', error)
|
||||
}
|
||||
},
|
||||
onConnected: () => {
|
||||
sessions.handleConnected()
|
||||
workspaces.handleConnected()
|
||||
ctx.emit('connection/reset')
|
||||
try {
|
||||
sessionHistory.handleConnected()
|
||||
} catch (error) {
|
||||
console.error('[web-runtime] history reconnect failed:', error)
|
||||
}
|
||||
},
|
||||
onStateChange: (state) => {
|
||||
// Generation death fires before any next-generation frame can arrive
|
||||
@@ -269,11 +264,6 @@ export function apply(ctx: Context): void {
|
||||
// the only safe moment to drop generation-scoped interaction state.
|
||||
if (state === 'reconnecting') {
|
||||
sessions.handleDisconnected()
|
||||
try {
|
||||
sessionHistory.handleDisconnected()
|
||||
} catch (error) {
|
||||
console.error('[web-runtime] history disconnect failed:', error)
|
||||
}
|
||||
}
|
||||
},
|
||||
})
|
||||
|
||||
@@ -1,428 +0,0 @@
|
||||
import type { SessionEvent } from '@deepseek-ai/dsh-session/types'
|
||||
import {
|
||||
SurfaceManager, isSurfaceEligibleType, isSurfaceEvent,
|
||||
} from '@deepseek-ai/dsh-session/surface'
|
||||
import type {
|
||||
HistoryEntry, ToolCallView, ToolResultView,
|
||||
} from '@deepseek-ai/dsh-client-connection/client'
|
||||
import type {
|
||||
AssistantRequestConfig, AssistantTiming, ConversationNode,
|
||||
PartialAssistant, RunningToolCall,
|
||||
} from '../sessions/conversation.ts'
|
||||
import { toAssistantBlocks } from '../sessions/conversation.ts'
|
||||
import { contextForm, contextProvenance } from '../sessions/context-provenance.ts'
|
||||
import { SteeringHistory } from '../sessions/steering-history.ts'
|
||||
import type {
|
||||
ConversationContext, ConversationContextOriginKind,
|
||||
} from '../sessions/conversation-context.ts'
|
||||
import type { ConversationPromptSnapshot } from '../sessions/request-inspection.ts'
|
||||
import { PartialAccumulator } from '../sessions/partial.ts'
|
||||
import type { AssistantStepMetadata } from '../sessions/assistant-timing.ts'
|
||||
import { indexAssistantStepTiming, settledAssistantTiming } from '../sessions/assistant-timing.ts'
|
||||
import { ToolCallTree } from '../sessions/tool-call-tree.ts'
|
||||
|
||||
interface CallIndexEntry {
|
||||
name: string
|
||||
argsRaw: string
|
||||
time: number
|
||||
callView: ToolCallView | null
|
||||
}
|
||||
|
||||
interface FoldedContext {
|
||||
generation: number
|
||||
nodes: readonly number[]
|
||||
originSeq?: number
|
||||
}
|
||||
|
||||
/** Immutable conversation projections derived only from the history source. */
|
||||
export interface ConversationHistoryProjection {
|
||||
eventNodes: readonly ConversationNode[]
|
||||
contexts: readonly ConversationContext[]
|
||||
interruptedNodes: readonly ConversationNode[]
|
||||
partial: PartialAssistant | null
|
||||
runningCalls: readonly RunningToolCall[]
|
||||
}
|
||||
|
||||
function replacementCrossesWindowHead(event: SessionEvent, baseSeq: number): boolean {
|
||||
if (!isSurfaceEvent(event) || event.surfaceOp === 'append') return false
|
||||
return event.surfaceOp.start < baseSeq || event.surfaceOp.end < baseSeq
|
||||
}
|
||||
|
||||
function contextOriginKind(event: SessionEvent | undefined): ConversationContextOriginKind {
|
||||
if (event?.type !== 'user/message') return 'rewrite'
|
||||
const source = event.data.source
|
||||
if (typeof source === 'object' && 'kind' in source && 'plugin' in source) {
|
||||
if (source.plugin === 'compact') return 'compaction'
|
||||
if (source.plugin === 'rewind') return 'rewind'
|
||||
}
|
||||
return 'rewrite'
|
||||
}
|
||||
|
||||
function foldContexts(events: readonly SessionEvent[]): readonly FoldedContext[] {
|
||||
const replay: SessionEvent[] = []
|
||||
const originalSeqs: number[] = []
|
||||
const rebasedSeqByOriginal = new Map<number, number>()
|
||||
const surface = new SurfaceManager(replay)
|
||||
const contexts: FoldedContext[] = []
|
||||
let generation = 0
|
||||
let originSeq: number | undefined
|
||||
const originalNodes = () => surface.nodes.map((seq) => {
|
||||
const original = originalSeqs[seq]
|
||||
if (original === undefined) throw new Error(`rebased surface seq ${seq} has no origin`)
|
||||
return original
|
||||
})
|
||||
for (const event of events) {
|
||||
if (!isSurfaceEvent(event)) continue
|
||||
if (event.surfaceOp !== 'append') {
|
||||
contexts.push({
|
||||
generation,
|
||||
nodes: originalNodes(),
|
||||
...(originSeq === undefined ? {} : { originSeq }),
|
||||
})
|
||||
generation++
|
||||
originSeq = event.seq
|
||||
}
|
||||
const rebasedSeq = replay.length
|
||||
const {
|
||||
sourceEventSeqs: rawSources,
|
||||
...eventWithoutSources
|
||||
} = event as SessionEvent & { sourceEventSeqs?: readonly number[] }
|
||||
const mappedSourceEventSeqs = rawSources?.flatMap((seq) => {
|
||||
const rebased = rebasedSeqByOriginal.get(seq)
|
||||
return rebased === undefined ? [] : [rebased]
|
||||
})
|
||||
const sourceEventSeqs = mappedSourceEventSeqs?.length === 0
|
||||
? undefined
|
||||
: mappedSourceEventSeqs
|
||||
const surfaceOp = event.surfaceOp === 'append'
|
||||
? event.surfaceOp
|
||||
: {
|
||||
...event.surfaceOp,
|
||||
start: rebasedSeqByOriginal.get(event.surfaceOp.start) ?? event.surfaceOp.start,
|
||||
end: rebasedSeqByOriginal.get(event.surfaceOp.end) ?? event.surfaceOp.end,
|
||||
}
|
||||
originalSeqs.push(event.seq)
|
||||
rebasedSeqByOriginal.set(event.seq, rebasedSeq)
|
||||
replay.push({
|
||||
...eventWithoutSources,
|
||||
seq: rebasedSeq,
|
||||
surfaceOp,
|
||||
...(sourceEventSeqs === undefined ? {} : { sourceEventSeqs }),
|
||||
} as SessionEvent)
|
||||
}
|
||||
contexts.push({
|
||||
generation,
|
||||
nodes: originalNodes(),
|
||||
...(originSeq === undefined ? {} : { originSeq }),
|
||||
})
|
||||
return contexts
|
||||
}
|
||||
|
||||
// History projection owns its node mapping so Chat's live adapter remains free
|
||||
// of inspection metadata and lifecycle coupling.
|
||||
/* jscpd:ignore-start */
|
||||
function materializeNode(
|
||||
event: SessionEvent,
|
||||
callIndex: ReadonlyMap<string, CallIndexEntry>,
|
||||
resultView: ToolResultView | null,
|
||||
assistantTiming: AssistantTiming | undefined,
|
||||
requestConfig: AssistantRequestConfig | undefined,
|
||||
steering: boolean,
|
||||
): ConversationNode {
|
||||
switch (event.type) {
|
||||
case 'user/message':
|
||||
if (event.data.source.kind !== 'user') {
|
||||
return {
|
||||
kind: 'context', seq: event.seq, time: event.time,
|
||||
content: event.data.content, source: event.data.source,
|
||||
provenance: contextProvenance(event.data.source),
|
||||
form: contextForm(event.data.source),
|
||||
}
|
||||
}
|
||||
if (steering) {
|
||||
return {
|
||||
kind: 'steering', messageId: event.data.id,
|
||||
seq: event.seq, time: event.time,
|
||||
content: event.data.content, source: event.data.source,
|
||||
}
|
||||
}
|
||||
return {
|
||||
kind: 'user', seq: event.seq, time: event.time,
|
||||
content: event.data.content, source: event.data.source,
|
||||
}
|
||||
case 'assistant/message':
|
||||
return {
|
||||
kind: 'assistant', seq: event.seq, time: event.time,
|
||||
turn: event.data.turn, step: event.data.step,
|
||||
blocks: toAssistantBlocks(event.data.message.content), usage: event.data.usage,
|
||||
provenance: {
|
||||
provider: event.data.message.source.provider,
|
||||
model: event.data.message.source.model,
|
||||
},
|
||||
...(requestConfig === undefined ? {} : { requestConfig }),
|
||||
...(assistantTiming === undefined ? {} : { timing: assistantTiming }),
|
||||
}
|
||||
case 'tool/result': {
|
||||
const result = event.data.message.content[0]
|
||||
const callId = String(event.data.message.source.callId)
|
||||
const call = callIndex.get(callId)
|
||||
return {
|
||||
kind: 'tool-result', seq: event.seq, time: event.time,
|
||||
callId,
|
||||
call: call === undefined ? null : { name: call.name, argsRaw: call.argsRaw },
|
||||
callTime: call?.time ?? null,
|
||||
content: result.content, isError: result.isError === true,
|
||||
...(event.data.error === undefined ? {} : { error: event.data.error }),
|
||||
meta: event.data.meta,
|
||||
callView: call?.callView ?? null,
|
||||
resultView,
|
||||
subCalls: [],
|
||||
}
|
||||
}
|
||||
default:
|
||||
return {
|
||||
kind: 'unknown', seq: event.seq, time: event.time,
|
||||
type: event.type, data: (event as { data?: unknown }).data,
|
||||
}
|
||||
}
|
||||
}
|
||||
/* jscpd:ignore-end */
|
||||
|
||||
interface TransientProjection extends Pick<
|
||||
ConversationHistoryProjection,
|
||||
'interruptedNodes' | 'partial' | 'runningCalls'
|
||||
> {
|
||||
toolCallTree: ToolCallTree
|
||||
}
|
||||
|
||||
function projectTransient(entries: readonly HistoryEntry[]): TransientProjection {
|
||||
let partial: PartialAccumulator | null = null
|
||||
const openCalls = new Map<string, RunningToolCall>()
|
||||
const interruptedNodes: ConversationNode[] = []
|
||||
const toolCallTree = new ToolCallTree()
|
||||
|
||||
for (const entry of entries) {
|
||||
const { event } = entry
|
||||
if (toolCallTree.apply(event)) continue
|
||||
switch (event.type) {
|
||||
case 'assistant/chunk': {
|
||||
const { turn, step, chunk } = event.data
|
||||
if (partial === null || partial.turn !== turn || partial.step !== step) {
|
||||
partial = new PartialAccumulator(turn, step)
|
||||
}
|
||||
partial.push(chunk)
|
||||
break
|
||||
}
|
||||
case 'assistant/message':
|
||||
if (partial?.turn === event.data.turn && partial.step === event.data.step) partial = null
|
||||
break
|
||||
case 'tool/call':
|
||||
// History reconstructs its own in-flight index; this intentionally
|
||||
// mirrors the published Chat node shape, not Chat's mutable state.
|
||||
/* jscpd:ignore-start */
|
||||
openCalls.set(String(event.data.callId), {
|
||||
callId: String(event.data.callId),
|
||||
name: event.data.name,
|
||||
argsRaw: event.data.arguments,
|
||||
turn: event.data.turn,
|
||||
step: event.data.step,
|
||||
time: event.time,
|
||||
callView: entry.view?.for === 'call' ? entry.view.view : null,
|
||||
subCalls: [],
|
||||
})
|
||||
/* jscpd:ignore-end */
|
||||
break
|
||||
case 'tool/result':
|
||||
openCalls.delete(String(event.data.message.source.callId))
|
||||
break
|
||||
case 'turn/end': {
|
||||
if (partial !== null && partial.turn === event.data.turn) {
|
||||
const { blocks } = partial.toPartial()
|
||||
const visible = blocks.some(block =>
|
||||
block.kind === 'text' || block.kind === 'reasoning' ? block.text !== '' : true)
|
||||
if (visible) {
|
||||
interruptedNodes.push({
|
||||
kind: 'assistant', seq: event.seq - 0.9, time: event.time,
|
||||
turn: partial.turn, step: partial.step, blocks, interrupted: true,
|
||||
})
|
||||
}
|
||||
partial = null
|
||||
}
|
||||
let callOffset = 0
|
||||
for (const [callId, call] of openCalls) {
|
||||
if (call.turn !== event.data.turn) continue
|
||||
openCalls.delete(callId)
|
||||
// Interrupted terminal nodes are reconstructed independently so a
|
||||
// Trajectory replay cannot observe Session's frozen-node lifecycle.
|
||||
/* jscpd:ignore-start */
|
||||
interruptedNodes.push({
|
||||
kind: 'tool-result', seq: event.seq - 0.8 + callOffset++ * 0.01,
|
||||
time: event.time,
|
||||
callId,
|
||||
call: { name: call.name, argsRaw: call.argsRaw },
|
||||
callTime: call.time,
|
||||
content: [],
|
||||
isError: true,
|
||||
error: { name: 'Interrupted', code: 'interrupted' },
|
||||
callView: call.callView,
|
||||
resultView: null,
|
||||
subCalls: [],
|
||||
})
|
||||
/* jscpd:ignore-end */
|
||||
}
|
||||
break
|
||||
}
|
||||
default:
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
interruptedNodes,
|
||||
partial: partial?.toPartial() ?? null,
|
||||
runningCalls: [...openCalls.values()],
|
||||
toolCallTree,
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Project one immutable history ledger without reading or mutating Chat state.
|
||||
* @param entries - Contiguous history entries in sequence order.
|
||||
* @returns Event order, context lineage, and transient tail state.
|
||||
*/
|
||||
export function projectConversationHistory(
|
||||
entries: readonly HistoryEntry[],
|
||||
): ConversationHistoryProjection {
|
||||
const events = entries.map(entry => entry.event)
|
||||
const steeringHistory = new SteeringHistory()
|
||||
const steeringSeqs = new Set<number>()
|
||||
for (const event of events) {
|
||||
if (steeringHistory.apply(event)) steeringSeqs.add(event.seq)
|
||||
}
|
||||
const baseSeq = events[0]?.seq ?? 0
|
||||
const eventsBySeq = new Map(events.map(event => [event.seq, event]))
|
||||
const callIndex = new Map<string, CallIndexEntry>()
|
||||
const resultViews = new Map<number, ToolResultView>()
|
||||
const assistantSteps = new Map<string, AssistantStepMetadata>()
|
||||
const assistantTimings = new Map<number, AssistantTiming>()
|
||||
const assistantRequestConfigs = new Map<number, AssistantRequestConfig>()
|
||||
const promptsByContext = new Map<number, ConversationPromptSnapshot>()
|
||||
let activeRequestConfig: AssistantRequestConfig | undefined
|
||||
let activePrompt: ConversationPromptSnapshot | undefined
|
||||
let contextGeneration = 0
|
||||
|
||||
for (const [index, event] of events.entries()) {
|
||||
const view = entries[index]?.view
|
||||
if (event.type === 'tool/call') {
|
||||
callIndex.set(String(event.data.callId), {
|
||||
name: event.data.name,
|
||||
argsRaw: event.data.arguments,
|
||||
time: event.time,
|
||||
callView: view?.for === 'call' ? view.view : null,
|
||||
})
|
||||
} else if (event.type === 'tool/result' && view?.for === 'result') {
|
||||
resultViews.set(event.seq, view.view)
|
||||
}
|
||||
if (isSurfaceEvent(event) && event.surfaceOp !== 'append') {
|
||||
contextGeneration++
|
||||
if (activePrompt !== undefined) promptsByContext.set(contextGeneration, activePrompt)
|
||||
}
|
||||
indexAssistantStepTiming(assistantSteps, event)
|
||||
if (event.type === 'request/header') {
|
||||
activeRequestConfig = event.data.header.config
|
||||
activePrompt = {
|
||||
config: event.data.header.config,
|
||||
system: event.data.header.system ?? '',
|
||||
tools: event.data.header.tools ?? [],
|
||||
}
|
||||
promptsByContext.set(contextGeneration, activePrompt)
|
||||
} else if (event.type === 'assistant/message') {
|
||||
assistantTimings.set(
|
||||
event.seq,
|
||||
settledAssistantTiming(assistantSteps, event.data.turn, event.data.step, event.time),
|
||||
)
|
||||
if (activeRequestConfig !== undefined) {
|
||||
assistantRequestConfigs.set(event.seq, activeRequestConfig)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const nodeCache = new Map<number, ConversationNode>()
|
||||
const materialize = (seq: number): ConversationNode | undefined => {
|
||||
const cached = nodeCache.get(seq)
|
||||
if (cached !== undefined) return cached
|
||||
const event = eventsBySeq.get(seq)
|
||||
if (event === undefined || !isSurfaceEligibleType(event.type)) return
|
||||
const node = materializeNode(
|
||||
event,
|
||||
callIndex,
|
||||
resultViews.get(seq) ?? null,
|
||||
assistantTimings.get(seq),
|
||||
assistantRequestConfigs.get(seq),
|
||||
steeringSeqs.has(seq),
|
||||
)
|
||||
nodeCache.set(seq, node)
|
||||
return node
|
||||
}
|
||||
const eventNodes = events.flatMap((event) => {
|
||||
const node = materialize(event.seq)
|
||||
return node === undefined ? [] : [node]
|
||||
})
|
||||
|
||||
let contexts: readonly ConversationContext[]
|
||||
if (events.some(event => replacementCrossesWindowHead(event, baseSeq))) {
|
||||
contexts = [{
|
||||
id: 0,
|
||||
...(activePrompt === undefined ? {} : { prompt: activePrompt }),
|
||||
nodes: eventNodes,
|
||||
}]
|
||||
} else {
|
||||
try {
|
||||
contexts = foldContexts(events).map((context): ConversationContext => {
|
||||
const nodes = context.nodes.flatMap((seq) => {
|
||||
const node = materialize(seq)
|
||||
return node === undefined ? [] : [node]
|
||||
})
|
||||
const prompt = promptsByContext.get(context.generation)
|
||||
if (context.originSeq === undefined) {
|
||||
return {
|
||||
id: context.generation,
|
||||
...(prompt === undefined ? {} : { prompt }),
|
||||
nodes,
|
||||
}
|
||||
}
|
||||
const originEvent = eventsBySeq.get(context.originSeq)
|
||||
return {
|
||||
id: context.generation,
|
||||
parentId: context.generation - 1,
|
||||
origin: contextOriginKind(originEvent),
|
||||
originSeq: context.originSeq,
|
||||
...(originEvent === undefined ? {} : { createdAt: originEvent.time }),
|
||||
...(prompt === undefined ? {} : { prompt }),
|
||||
nodes,
|
||||
}
|
||||
})
|
||||
} catch (error) {
|
||||
console.error('[web-runtime] history surface fold failed, using event order:', error)
|
||||
contexts = [{
|
||||
id: 0,
|
||||
...(activePrompt === undefined ? {} : { prompt: activePrompt }),
|
||||
nodes: eventNodes,
|
||||
}]
|
||||
}
|
||||
}
|
||||
|
||||
const transient = projectTransient(entries)
|
||||
const projectedEventNodes = transient.toolCallTree.projectNodes(eventNodes)
|
||||
const projectedContexts = contexts.map((context): ConversationContext => {
|
||||
const nodes = transient.toolCallTree.projectNodes(context.nodes)
|
||||
return nodes === context.nodes ? context : { ...context, nodes }
|
||||
})
|
||||
return {
|
||||
eventNodes: projectedEventNodes,
|
||||
contexts: projectedContexts,
|
||||
interruptedNodes: transient.toolCallTree.projectNodes(transient.interruptedNodes),
|
||||
partial: transient.partial,
|
||||
runningCalls: transient.toolCallTree.projectRunningCalls(transient.runningCalls),
|
||||
}
|
||||
}
|
||||
@@ -1,66 +0,0 @@
|
||||
import type { Context } from 'cordis'
|
||||
import type {
|
||||
HostFrame, IApiClient, MuxFrame, RpcRequest, SessionId,
|
||||
} from '@deepseek-ai/dsh-client-connection/client'
|
||||
import type {
|
||||
ISessionHistory, SessionHistoryFace,
|
||||
} from '../contract/session-history.ts'
|
||||
import { SessionHistorySource } from './source.ts'
|
||||
|
||||
/** Root registry and frame router for independent inspection histories. */
|
||||
export class SessionHistoryService implements ISessionHistory {
|
||||
private readonly sources = new Map<SessionId, SessionHistorySource>()
|
||||
|
||||
/**
|
||||
* @param ctx - Client root context.
|
||||
* @param api - Shared wire client.
|
||||
*/
|
||||
constructor(ctx: Context, private readonly api: IApiClient) {
|
||||
ctx.reflect.provide('sessionHistory', this, undefined)
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolve one identity-stable history source.
|
||||
* @param sessionId - Host session identity.
|
||||
* @returns Source independent from SessionManager.
|
||||
*/
|
||||
source(sessionId: SessionId): SessionHistoryFace {
|
||||
let source = this.sources.get(sessionId)
|
||||
if (source === undefined) {
|
||||
source = new SessionHistorySource(sessionId, this.api)
|
||||
this.sources.set(sessionId, source)
|
||||
}
|
||||
return source
|
||||
}
|
||||
|
||||
/**
|
||||
* Route history-relevant mux frames only to an existing source.
|
||||
* @param envelope - Validated mux envelope.
|
||||
*/
|
||||
handleMuxEnvelope(envelope: RpcRequest<MuxFrame>): void {
|
||||
const frame = envelope.payload
|
||||
if (frame.type === 'stream/error') return
|
||||
this.sources.get(frame.sessionId)?.handleMuxFrame(frame)
|
||||
}
|
||||
|
||||
/**
|
||||
* Drop a removed session's independent history source.
|
||||
* @param envelope - Validated host envelope.
|
||||
*/
|
||||
handleHostEnvelope(envelope: RpcRequest<HostFrame>): void {
|
||||
const frame = envelope.payload
|
||||
if (frame.type !== 'host/session-removed') return
|
||||
this.sources.get(frame.sessionId)?.dispose()
|
||||
this.sources.delete(frame.sessionId)
|
||||
}
|
||||
|
||||
/** Invalidate requests from the dead connection generation. */
|
||||
handleDisconnected(): void {
|
||||
for (const source of this.sources.values()) source.handleDisconnected()
|
||||
}
|
||||
|
||||
/** Rebuild every previously activated source from the new generation. */
|
||||
handleConnected(): void {
|
||||
for (const source of this.sources.values()) source.resync()
|
||||
}
|
||||
}
|
||||
@@ -1,432 +0,0 @@
|
||||
import type {
|
||||
HistoryEntry, IApiClient, MuxFrame, RpcError, SessionId,
|
||||
} from '@deepseek-ai/dsh-client-connection/client'
|
||||
import type { SessionEvent } from '@deepseek-ai/dsh-session/types'
|
||||
import { transportError } from '@deepseek-ai/dsh-host-apiproxy/api'
|
||||
import type {
|
||||
SessionHistoryFace, SessionHistorySnapshot,
|
||||
} from '../contract/session-history.ts'
|
||||
import {
|
||||
compactHistoryInspectionEntries, createHistoryInspection,
|
||||
} from '../sessions/history.ts'
|
||||
import { Notifier } from '../sessions/notifier.ts'
|
||||
import { isVisibleAssistantChunk, PartialAccumulator } from '../sessions/partial.ts'
|
||||
|
||||
const HISTORY_PAGE_MESSAGES = 50
|
||||
|
||||
function isAborted(signal: AbortSignal | undefined): boolean {
|
||||
return signal?.aborted === true
|
||||
}
|
||||
|
||||
/** Independent raw-history owner used only by inspection consumers. */
|
||||
export class SessionHistorySource implements SessionHistoryFace {
|
||||
private entries: HistoryEntry[] = []
|
||||
private inspectionEntries: readonly HistoryEntry[] = []
|
||||
private baseSeq = 0
|
||||
private hasMore = false
|
||||
private state: SessionHistorySnapshot['state'] = 'cold'
|
||||
private error: RpcError | null = null
|
||||
private generation = 0
|
||||
private persistentConsumer = false
|
||||
private readonly consumerSignals = new Set<AbortSignal>()
|
||||
private openPromise: Promise<void> | null = null
|
||||
private olderPromise: Promise<void> | null = null
|
||||
private stitching = false
|
||||
private liveBuffer: HistoryEntry[] = []
|
||||
private subscribedLastSeq: number | null = null
|
||||
private inspectionCache: {
|
||||
entries: readonly HistoryEntry[]
|
||||
value: SessionHistorySnapshot['inspection']
|
||||
} | null = null
|
||||
private streamPublishToken: object | null = null
|
||||
private streamPartial: PartialAccumulator | null = null
|
||||
private snapshotCache: SessionHistorySnapshot
|
||||
private readonly notifier = new Notifier(() => {
|
||||
this.snapshotCache = this.buildSnapshot()
|
||||
})
|
||||
|
||||
/**
|
||||
* @param sessionId - Host session identity.
|
||||
* @param api - Shared wire client.
|
||||
*/
|
||||
constructor(
|
||||
readonly sessionId: SessionId,
|
||||
private readonly api: IApiClient,
|
||||
) {
|
||||
this.snapshotCache = this.buildSnapshot()
|
||||
}
|
||||
|
||||
/**
|
||||
* Subscribe to ledger changes.
|
||||
* @param listener - Change callback.
|
||||
* @returns Unsubscribe function.
|
||||
*/
|
||||
subscribe(listener: () => void): () => void {
|
||||
return this.notifier.subscribe(listener)
|
||||
}
|
||||
|
||||
/**
|
||||
* Read the cached ledger snapshot.
|
||||
* @returns Stable snapshot until the source changes.
|
||||
*/
|
||||
getSnapshot(): SessionHistorySnapshot {
|
||||
this.notifier.ensureFresh()
|
||||
return this.snapshotCache
|
||||
}
|
||||
|
||||
/**
|
||||
* Load the current tail without reading older pages.
|
||||
* @param signal - Consumer lifetime.
|
||||
* @returns When the tail is ready or loading fails.
|
||||
*/
|
||||
async loadTail(signal?: AbortSignal): Promise<void> {
|
||||
if (isAborted(signal)) return
|
||||
this.trackConsumer(signal)
|
||||
await this.open()
|
||||
}
|
||||
|
||||
/**
|
||||
* Prepend one older page when the current window has a predecessor.
|
||||
* @param signal - Consumer lifetime.
|
||||
* @returns Whether the loaded window advanced.
|
||||
*/
|
||||
async loadOlder(signal?: AbortSignal): Promise<boolean> {
|
||||
if (isAborted(signal)) return false
|
||||
this.trackConsumer(signal)
|
||||
await this.open()
|
||||
if (isAborted(signal)) return false
|
||||
const previousBaseSeq = this.baseSeq
|
||||
await this.loadOlderPage()
|
||||
return this.baseSeq !== previousBaseSeq
|
||||
}
|
||||
|
||||
/**
|
||||
* Route a relevant mux frame without involving the Chat session.
|
||||
* @param frame - Session-addressed frame.
|
||||
*/
|
||||
handleMuxFrame(frame: MuxFrame): void {
|
||||
if (frame.type === 'session/subscribed') {
|
||||
this.subscribedLastSeq = frame.lastSeq
|
||||
return
|
||||
}
|
||||
if (frame.type !== 'session/event') return
|
||||
this.acceptLive({ event: frame.event, ...(frame.view === undefined ? {} : { view: frame.view }) })
|
||||
}
|
||||
|
||||
/** Invalidate dead-generation requests while retaining the last readable snapshot. */
|
||||
handleDisconnected(): void {
|
||||
this.generation++
|
||||
this.openPromise = null
|
||||
this.olderPromise = null
|
||||
this.stitching = false
|
||||
this.liveBuffer = []
|
||||
this.subscribedLastSeq = null
|
||||
if (this.state !== 'cold') {
|
||||
this.state = 'cold'
|
||||
this.error = null
|
||||
this.publishDirtyNow()
|
||||
}
|
||||
}
|
||||
|
||||
/** Rebuild an activated ledger from the new connection generation. */
|
||||
resync(): void {
|
||||
if (!this.hasConsumer()) return
|
||||
this.generation++
|
||||
this.openPromise = null
|
||||
this.olderPromise = null
|
||||
this.stitching = false
|
||||
this.liveBuffer = []
|
||||
this.subscribedLastSeq = null
|
||||
this.entries = []
|
||||
this.inspectionEntries = []
|
||||
this.baseSeq = 0
|
||||
this.hasMore = false
|
||||
this.state = 'cold'
|
||||
this.error = null
|
||||
this.publishDirtyNow()
|
||||
void this.open()
|
||||
}
|
||||
|
||||
/** Stop future refresh work after the host removes the session. */
|
||||
dispose(): void {
|
||||
this.persistentConsumer = false
|
||||
this.consumerSignals.clear()
|
||||
this.generation++
|
||||
this.openPromise = null
|
||||
this.olderPromise = null
|
||||
this.liveBuffer = []
|
||||
this.streamPublishToken = null
|
||||
this.streamPartial = null
|
||||
}
|
||||
|
||||
private open(): Promise<void> {
|
||||
if (this.state === 'ready') return Promise.resolve()
|
||||
if (this.openPromise !== null) return this.openPromise
|
||||
const generation = this.generation
|
||||
const operation = this.doOpen(generation)
|
||||
const settled = operation.finally(() => {
|
||||
if (this.openPromise === settled) this.openPromise = null
|
||||
})
|
||||
this.openPromise = settled
|
||||
return settled
|
||||
}
|
||||
|
||||
private trackConsumer(signal: AbortSignal | undefined): void {
|
||||
if (signal === undefined) {
|
||||
this.persistentConsumer = true
|
||||
return
|
||||
}
|
||||
if (this.consumerSignals.has(signal)) return
|
||||
this.consumerSignals.add(signal)
|
||||
signal.addEventListener('abort', () => {
|
||||
this.consumerSignals.delete(signal)
|
||||
}, { once: true })
|
||||
}
|
||||
|
||||
private hasConsumer(): boolean {
|
||||
return this.persistentConsumer || this.consumerSignals.size > 0
|
||||
}
|
||||
|
||||
private async doOpen(generation: number): Promise<void> {
|
||||
this.state = 'loading'
|
||||
this.error = null
|
||||
this.publishDirtyNow()
|
||||
try {
|
||||
let { result } = await this.api.sessions.history({
|
||||
sessionId: this.sessionId,
|
||||
maxMessages: HISTORY_PAGE_MESSAGES,
|
||||
})
|
||||
if (generation !== this.generation) return
|
||||
if (!result.ok) {
|
||||
this.state = 'error'
|
||||
this.error = result.error
|
||||
return
|
||||
}
|
||||
this.installTail(result.value.events, result.value.hasMore, true)
|
||||
const tailSeq = this.tailSeq()
|
||||
if (
|
||||
this.subscribedLastSeq !== null
|
||||
&& tailSeq !== null
|
||||
&& this.subscribedLastSeq > tailSeq
|
||||
) {
|
||||
result = (await this.api.sessions.history({
|
||||
sessionId: this.sessionId,
|
||||
maxMessages: HISTORY_PAGE_MESSAGES,
|
||||
})).result
|
||||
if (generation !== this.generation) return
|
||||
if (result.ok) this.installTail(result.value.events, result.value.hasMore, true)
|
||||
}
|
||||
this.state = 'ready'
|
||||
} catch (error) {
|
||||
if (generation !== this.generation) return
|
||||
this.state = 'error'
|
||||
const folded = transportError<never>(error)
|
||||
/* v8 ignore next -- transportError always returns the error branch. */
|
||||
this.error = folded.ok ? null : folded.error
|
||||
} finally {
|
||||
if (generation === this.generation) this.publishDirtyNow()
|
||||
}
|
||||
}
|
||||
|
||||
private loadOlderPage(): Promise<void> {
|
||||
if (this.olderPromise !== null) return this.olderPromise
|
||||
if (this.state !== 'ready' || !this.hasMore) return Promise.resolve()
|
||||
const generation = this.generation
|
||||
const operation = (async () => {
|
||||
try {
|
||||
const { result } = await this.api.sessions.history({
|
||||
sessionId: this.sessionId,
|
||||
beforeSeq: this.baseSeq,
|
||||
maxMessages: HISTORY_PAGE_MESSAGES,
|
||||
})
|
||||
if (generation !== this.generation || this.state !== 'ready' || !result.ok) return
|
||||
const older = result.value.events
|
||||
if (older.length === 0) {
|
||||
this.hasMore = result.value.hasMore
|
||||
return
|
||||
}
|
||||
const tail = older.at(-1)
|
||||
if (tail === undefined || tail.event.seq + 1 !== this.baseSeq) {
|
||||
console.error(
|
||||
`[web-runtime] inspection history page discontinuous: tail seq ${tail?.event.seq} vs baseSeq ${this.baseSeq}`,
|
||||
)
|
||||
this.hasMore = false
|
||||
return
|
||||
}
|
||||
this.entries = [...older, ...this.entries]
|
||||
this.inspectionEntries = compactHistoryInspectionEntries([...this.entries])
|
||||
this.baseSeq = older[0]?.event.seq ?? this.baseSeq
|
||||
this.hasMore = result.value.hasMore
|
||||
} catch (error) {
|
||||
console.error('[web-runtime] inspection history paging failed:', error)
|
||||
}
|
||||
})()
|
||||
const settled = operation.finally(() => {
|
||||
if (this.olderPromise !== settled) return
|
||||
this.olderPromise = null
|
||||
this.publishDirtyNow()
|
||||
})
|
||||
this.olderPromise = settled
|
||||
return settled
|
||||
}
|
||||
|
||||
private installTail(
|
||||
tail: readonly HistoryEntry[],
|
||||
hasMore: boolean,
|
||||
replace: boolean,
|
||||
): void {
|
||||
if (replace) {
|
||||
this.entries = [...tail]
|
||||
this.hasMore = hasMore
|
||||
} else {
|
||||
const firstSeq = tail[0]?.event.seq
|
||||
const prefix = firstSeq === undefined
|
||||
? this.entries
|
||||
: this.entries.filter(entry => entry.event.seq < firstSeq)
|
||||
this.entries = [...prefix, ...tail]
|
||||
}
|
||||
this.baseSeq = this.entries[0]?.event.seq ?? 0
|
||||
this.inspectionEntries = compactHistoryInspectionEntries([...this.entries])
|
||||
const buffered = this.liveBuffer
|
||||
this.liveBuffer = []
|
||||
for (const entry of buffered) this.appendLive(entry)
|
||||
this.publishDirtyNow()
|
||||
}
|
||||
|
||||
private acceptLive(entry: HistoryEntry): void {
|
||||
if (this.state === 'loading' || this.stitching) {
|
||||
this.liveBuffer.push(entry)
|
||||
return
|
||||
}
|
||||
if (this.state !== 'ready') return
|
||||
const tailSeq = this.tailSeq()
|
||||
if (tailSeq !== null && entry.event.seq > tailSeq + 1) {
|
||||
this.liveBuffer.push(entry)
|
||||
void this.repairGap()
|
||||
return
|
||||
}
|
||||
if (
|
||||
entry.event.type === 'assistant/chunk'
|
||||
&& entry.event.data.chunk.type !== 'usage'
|
||||
) {
|
||||
if (!this.appendIncrementalChunk(entry, entry.event)) return
|
||||
this.publishStreamDirty()
|
||||
return
|
||||
}
|
||||
this.appendLive(entry)
|
||||
this.publishDirtyNow()
|
||||
}
|
||||
|
||||
private appendLive(entry: HistoryEntry): void {
|
||||
const tailSeq = this.tailSeq()
|
||||
if (tailSeq !== null && entry.event.seq <= tailSeq) return
|
||||
this.entries.push(entry)
|
||||
this.inspectionEntries = [...this.inspectionEntries, entry]
|
||||
if (entry.event.type === 'assistant/message') {
|
||||
this.inspectionEntries = compactHistoryInspectionEntries(this.inspectionEntries)
|
||||
}
|
||||
}
|
||||
|
||||
/** Append a chunk against the cached finalized projection; false means no visible publish. */
|
||||
private appendIncrementalChunk(
|
||||
entry: HistoryEntry,
|
||||
event: SessionEvent<'assistant/chunk'>,
|
||||
): boolean {
|
||||
const { turn, step, chunk } = event.data
|
||||
if (!isVisibleAssistantChunk(chunk.type)) {
|
||||
const inspection = this.currentInspection()
|
||||
this.appendLive(entry)
|
||||
this.inspectionCache = { entries: this.inspectionEntries, value: inspection }
|
||||
return false
|
||||
}
|
||||
const base = this.currentInspection()
|
||||
if (
|
||||
this.streamPartial === null
|
||||
|| this.streamPartial.turn !== turn
|
||||
|| this.streamPartial.step !== step
|
||||
) {
|
||||
const current = base.partial
|
||||
this.streamPartial = new PartialAccumulator(
|
||||
turn,
|
||||
step,
|
||||
current?.turn === turn && current.step === step ? current.blocks : [],
|
||||
)
|
||||
}
|
||||
this.streamPartial.push(chunk)
|
||||
this.appendLive(entry)
|
||||
this.inspectionCache = {
|
||||
entries: this.inspectionEntries,
|
||||
value: { ...base, partial: this.streamPartial.toPartial() },
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
/** Coalesce token-stream projection and rendering work to one publish per browser frame. */
|
||||
private publishStreamDirty(): void {
|
||||
if (this.streamPublishToken !== null) return
|
||||
const token = {}
|
||||
this.streamPublishToken = token
|
||||
const publish = () => {
|
||||
if (this.streamPublishToken !== token) return
|
||||
this.streamPublishToken = null
|
||||
this.notifier.markDirty()
|
||||
}
|
||||
if (typeof globalThis.requestAnimationFrame === 'function') {
|
||||
globalThis.requestAnimationFrame(publish)
|
||||
} else {
|
||||
queueMicrotask(publish)
|
||||
}
|
||||
}
|
||||
|
||||
/** Publish structural changes immediately and invalidate an older scheduled stream publish. */
|
||||
private publishDirtyNow(): void {
|
||||
this.streamPublishToken = null
|
||||
this.streamPartial = null
|
||||
this.notifier.markDirty()
|
||||
}
|
||||
|
||||
private async repairGap(): Promise<void> {
|
||||
if (this.stitching) return
|
||||
this.stitching = true
|
||||
const generation = this.generation
|
||||
try {
|
||||
const { result } = await this.api.sessions.history({
|
||||
sessionId: this.sessionId,
|
||||
maxMessages: HISTORY_PAGE_MESSAGES,
|
||||
})
|
||||
if (result.ok && generation === this.generation && this.state === 'ready') {
|
||||
this.installTail(result.value.events, result.value.hasMore, false)
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('[web-runtime] inspection history gap repair failed:', error)
|
||||
} finally {
|
||||
if (generation === this.generation) this.stitching = false
|
||||
}
|
||||
}
|
||||
|
||||
private tailSeq(): number | null {
|
||||
return this.entries.at(-1)?.event.seq ?? null
|
||||
}
|
||||
|
||||
private buildSnapshot(): SessionHistorySnapshot {
|
||||
return {
|
||||
state: this.state,
|
||||
error: this.error,
|
||||
hasMore: this.hasMore,
|
||||
baseSeq: this.baseSeq,
|
||||
inspection: this.currentInspection(),
|
||||
}
|
||||
}
|
||||
|
||||
/** Inspection pinned to the source's current immutable entry array. */
|
||||
private currentInspection(): SessionHistorySnapshot['inspection'] {
|
||||
if (this.inspectionCache?.entries !== this.inspectionEntries) {
|
||||
const entries = this.inspectionEntries
|
||||
this.inspectionCache = {
|
||||
entries,
|
||||
value: createHistoryInspection(() => entries),
|
||||
}
|
||||
}
|
||||
return this.inspectionCache.value
|
||||
}
|
||||
}
|
||||
@@ -2,7 +2,8 @@ import type {
|
||||
ConversationContextReader, ConversationEventInput, ConversationLocationData, ConversationMatch,
|
||||
ConversationNodeContext, ConversationNodeDefinition, ConversationPreviousContext,
|
||||
ConversationLocationDataScope, ConversationPublication, ConversationViewBuilder,
|
||||
ConversationViewDefinition, ConversationViewNode,
|
||||
ConversationViewDefinition, ConversationViewNode, ConversationViewSnapshotMap,
|
||||
ConversationViewSnapshotStore,
|
||||
} from '../contract/conversation.ts'
|
||||
import { conversationContextKey } from '../contract/conversation.ts'
|
||||
import {
|
||||
@@ -133,7 +134,7 @@ export interface ConversationViewDefinitions {
|
||||
* Session-owned incremental engine that assembles business Contexts from a
|
||||
* contiguous Event window and materializes registered view snapshots.
|
||||
*/
|
||||
export class ConversationNodeAssembler {
|
||||
export class ConversationNodeAssembler implements ConversationViewSnapshotStore {
|
||||
private readonly contexts = new Map<string, InternalContext>()
|
||||
private readonly contextsByKind = new Map<string, InternalContext[]>()
|
||||
private readonly contextsBySeq = new Map<number, Set<InternalContext>>()
|
||||
@@ -266,11 +267,11 @@ export class ConversationNodeAssembler {
|
||||
const allByTarget = new Map<string, ConversationViewNode[]>()
|
||||
for (const target of this.views.keys()) allByTarget.set(target, [])
|
||||
for (const context of this.contexts.values()) {
|
||||
for (const target of this.views.keys()) {
|
||||
const node = this.buildNode(context, target)
|
||||
context.current.set(target, node)
|
||||
if (node !== null) allByTarget.get(target)?.push(node)
|
||||
}
|
||||
const target = context.definition.target
|
||||
if (target === undefined || !this.views.has(target)) continue
|
||||
const node = this.buildNode(context, target)
|
||||
context.current.set(target, node)
|
||||
if (node !== null) allByTarget.get(target)?.push(node)
|
||||
}
|
||||
for (const view of this.views.values()) {
|
||||
view.snapshot = view.builder.replace({
|
||||
@@ -288,17 +289,17 @@ export class ConversationNodeAssembler {
|
||||
for (const target of this.views.keys()) upsertsByTarget.set(target, [])
|
||||
if (this.applyDirtyLocationData()) this.timelineDirty = true
|
||||
for (const context of this.dirty) {
|
||||
for (const target of this.views.keys()) {
|
||||
const previous = context.current.get(target) ?? null
|
||||
const node = this.buildNode(context, target)
|
||||
if (node === null && previous !== null) {
|
||||
throw new Error(
|
||||
`conversation Definition "${context.kind}" withdrew materialized target "${target}"; return the same key with hidden visibility instead`,
|
||||
)
|
||||
}
|
||||
context.current.set(target, node)
|
||||
if (node !== null) upsertsByTarget.get(target)?.push(node)
|
||||
const target = context.definition.target
|
||||
if (target === undefined || !this.views.has(target)) continue
|
||||
const previous = context.current.get(target) ?? null
|
||||
const node = this.buildNode(context, target)
|
||||
if (node === null && previous !== null) {
|
||||
throw new Error(
|
||||
`conversation Definition "${context.kind}" withdrew materialized target "${target}"; return the same key with hidden visibility instead`,
|
||||
)
|
||||
}
|
||||
context.current.set(target, node)
|
||||
if (node !== null) upsertsByTarget.get(target)?.push(node)
|
||||
}
|
||||
this.dirty.clear()
|
||||
const timelineDirty = this.timelineDirty
|
||||
@@ -323,6 +324,12 @@ export class ConversationNodeAssembler {
|
||||
return this.views.get(target)?.snapshot
|
||||
}
|
||||
|
||||
get<Target extends Extract<keyof ConversationViewSnapshotMap, string>>(
|
||||
target: Target,
|
||||
): ConversationViewSnapshotMap[Target] | undefined {
|
||||
return this.snapshot(target) as ConversationViewSnapshotMap[Target] | undefined
|
||||
}
|
||||
|
||||
private sortedInputs(): ConversationEventInput[] {
|
||||
return [...this.inputs.values()].sort((left, right) => left.event.seq - right.event.seq)
|
||||
}
|
||||
@@ -358,18 +365,19 @@ export class ConversationNodeAssembler {
|
||||
role: ConversationMatch['role'],
|
||||
) => ConversationPublication,
|
||||
): ConversationPublication {
|
||||
let matched = false
|
||||
const matchedTargets = new Set<string>()
|
||||
let publication: ConversationPublication = 'none'
|
||||
for (const definition of this.eventDefinitions.entries()) {
|
||||
const result = definition.match(input.event)
|
||||
if (result === null) continue
|
||||
matched = true
|
||||
if (definition.target !== undefined) matchedTargets.add(definition.target)
|
||||
publication = maximumPublication(publication, accept(definition, result.id, result.role))
|
||||
}
|
||||
if (!matched) {
|
||||
const fallback = this.eventDefinitions.fallbackEntry()
|
||||
const result = fallback?.match(input.event) ?? null
|
||||
if (fallback !== undefined && result !== null) {
|
||||
const fallback = this.eventDefinitions.fallbackEntry()
|
||||
const target = fallback?.target
|
||||
if (fallback !== undefined && target !== undefined && !matchedTargets.has(target)) {
|
||||
const result = fallback.match(input.event)
|
||||
if (result !== null) {
|
||||
publication = maximumPublication(publication, accept(fallback, result.id, result.role))
|
||||
}
|
||||
}
|
||||
@@ -697,7 +705,8 @@ export class ConversationNodeAssembler {
|
||||
}
|
||||
|
||||
private buildNode(context: InternalContext, target: string): ConversationViewNode | null {
|
||||
const node = context.definition.buildViewNode(contextSnapshot(context), target)
|
||||
if (context.definition.target !== target || context.definition.buildViewNode === undefined) return null
|
||||
const node = context.definition.buildViewNode(contextSnapshot(context))
|
||||
if (node === null) return null
|
||||
if (node.key !== context.key) {
|
||||
throw new Error(`conversation Definition "${context.kind}" returned unstable key "${node.key}"; expected "${context.key}"`)
|
||||
|
||||
@@ -8,6 +8,7 @@
|
||||
import type { CommandId } from '@deepseek-ai/dsh-commands/brand'
|
||||
import type { MessageId } from '@deepseek-ai/dsh-llm/brand'
|
||||
import type { ContentBlock } from '@deepseek-ai/dsh-llm/types'
|
||||
import type { ImageAttachmentRef } from '@deepseek-ai/dsh-attachment'
|
||||
import type { LlmRetryEventData } from '@deepseek-ai/dsh-llm-retry/types'
|
||||
import type { TodoItem } from '@deepseek-ai/dsh-session/types'
|
||||
import type {
|
||||
@@ -16,7 +17,7 @@ import type {
|
||||
import type { PendingInteraction } from './pending.ts'
|
||||
import type { ContextProvenanceView, KnownContextForm } from './context-provenance.ts'
|
||||
import type {
|
||||
ChatConversationViewNode, ConversationTimelineSnapshot,
|
||||
ChatConversationViewNode, ConversationTimelineSnapshot, ConversationViewSnapshotStore,
|
||||
} from '../contract/conversation.ts'
|
||||
export type { TodoItem }
|
||||
|
||||
@@ -43,6 +44,7 @@ export interface AssistantProvenanceView {
|
||||
export type AssistantBlock =
|
||||
| { kind: 'text'; text: string }
|
||||
| { kind: 'reasoning'; text: string }
|
||||
| { kind: 'image'; attachment: ImageAttachmentRef }
|
||||
| { kind: 'tool-call'; callId: string; name: string; argsRaw: string }
|
||||
| { kind: 'other'; block: unknown }
|
||||
|
||||
@@ -64,6 +66,7 @@ export function toAssistantBlock(block: ContentBlock): AssistantBlock {
|
||||
switch (block.type) {
|
||||
case 'text': return { kind: 'text', text: block.text }
|
||||
case 'reasoning': return { kind: 'reasoning', text: block.text }
|
||||
case 'image': return { kind: 'image', attachment: block.attachment }
|
||||
case 'tool-call': return { kind: 'tool-call', callId: String(block.id), name: block.name, argsRaw: block.arguments }
|
||||
default: return { kind: 'other', block }
|
||||
}
|
||||
@@ -381,6 +384,11 @@ export interface ChatSnapshot {
|
||||
const EMPTY_LIST: readonly never[] = []
|
||||
const EMPTY_TIMELINE: ConversationTimelineSnapshot = { turnOrder: EMPTY_LIST, turns: new Map() }
|
||||
|
||||
/** Empty target store used by fixtures and Sessions without registered views. */
|
||||
export const EMPTY_CONVERSATION_VIEWS: ConversationViewSnapshotStore = {
|
||||
get: () => undefined,
|
||||
}
|
||||
|
||||
/** Empty Chat target used before a view builder is registered. */
|
||||
export const EMPTY_CHAT_SNAPSHOT: ChatSnapshot = {
|
||||
order: EMPTY_LIST,
|
||||
@@ -405,6 +413,8 @@ export const EMPTY_CHAT_SNAPSHOT: ChatSnapshot = {
|
||||
/** The immutable snapshot contract Session hands to uSES (see the web client architecture RFC). */
|
||||
export interface ConversationSnapshot {
|
||||
sessionId: SessionId
|
||||
/** Registered target snapshots assembled from Session events. */
|
||||
views: ConversationViewSnapshotStore
|
||||
/** Final Chat target assembled from independently registered business Definitions. */
|
||||
chat: ChatSnapshot
|
||||
/** Legacy top-level compatibility field mirrored from the registered Chat Definitions. */
|
||||
|
||||
@@ -1,121 +0,0 @@
|
||||
import type { ToolSchema } from '@deepseek-ai/dsh-llm/types'
|
||||
import type { HistoryEntry } from '@deepseek-ai/dsh-client-connection/client'
|
||||
import type {
|
||||
ConversationNode, PartialAssistant, RunningToolCall,
|
||||
} from './conversation.ts'
|
||||
import type { ConversationContext } from './conversation-context.ts'
|
||||
import { projectConversationHistory } from '../session-history/history-fold.ts'
|
||||
import { inspectRequests, type RequestView } from './request-inspection.ts'
|
||||
|
||||
function assistantStepKey(turn: number, step: number): string {
|
||||
return `${turn}\u0000${step}`
|
||||
}
|
||||
|
||||
function isFirstTokenCandidate(entry: HistoryEntry): boolean {
|
||||
const event = entry.event
|
||||
if (event.type !== 'assistant/chunk') return false
|
||||
switch (event.data.chunk.type) {
|
||||
case 'text-delta':
|
||||
case 'reasoning-delta':
|
||||
return event.data.chunk.text !== ''
|
||||
case 'tool-call-delta':
|
||||
return event.data.chunk.argumentsDelta !== '' || event.data.chunk.name !== undefined
|
||||
default:
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
/** Lazily derived inspection data for one immutable session-history window. */
|
||||
export interface SessionHistoryInspection {
|
||||
eventNodes: readonly ConversationNode[]
|
||||
contexts: readonly ConversationContext[]
|
||||
requests: readonly RequestView[]
|
||||
callSchemas: ReadonlyMap<string, ToolSchema>
|
||||
interruptedNodes: readonly ConversationNode[]
|
||||
partial: PartialAssistant | null
|
||||
runningCalls: readonly RunningToolCall[]
|
||||
}
|
||||
|
||||
/**
|
||||
* Remove completed-step token payloads that no inspection projection reads.
|
||||
* The first visible token preserves timing, usage chunks preserve accounting,
|
||||
* and unfinished steps retain every chunk for live or interrupted content.
|
||||
* @param entries - Contiguous raw history entries in sequence order.
|
||||
* @returns A projection-equivalent, usually much smaller entry ledger.
|
||||
*/
|
||||
export function compactHistoryInspectionEntries(
|
||||
entries: readonly HistoryEntry[],
|
||||
): readonly HistoryEntry[] {
|
||||
const completedSteps = new Set<string>()
|
||||
for (const { event } of entries) {
|
||||
if (event.type === 'assistant/message') {
|
||||
completedSteps.add(assistantStepKey(event.data.turn, event.data.step))
|
||||
}
|
||||
}
|
||||
|
||||
const firstTokenSteps = new Set<string>()
|
||||
const compacted: HistoryEntry[] = []
|
||||
let changed = false
|
||||
for (const entry of entries) {
|
||||
const event = entry.event
|
||||
if (event.type !== 'assistant/chunk') {
|
||||
compacted.push(entry)
|
||||
continue
|
||||
}
|
||||
const key = assistantStepKey(event.data.turn, event.data.step)
|
||||
if (!completedSteps.has(key) || event.data.chunk.type === 'usage') {
|
||||
compacted.push(entry)
|
||||
continue
|
||||
}
|
||||
if (isFirstTokenCandidate(entry) && !firstTokenSteps.has(key)) {
|
||||
firstTokenSteps.add(key)
|
||||
compacted.push(entry)
|
||||
} else {
|
||||
changed = true
|
||||
}
|
||||
}
|
||||
return changed ? compacted : entries
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a lazy inspection projection over an immutable history window.
|
||||
* Conversation consumers retain the cheap wrapper; only Trajectory snapshots
|
||||
* the entries and replays event order and request lifecycle state.
|
||||
* @param loadEntries - Lazily snapshots contiguous raw entries in sequence order.
|
||||
* @returns Lazy, memoized inspection fields for that exact window.
|
||||
*/
|
||||
export function createHistoryInspection(
|
||||
loadEntries: () => readonly HistoryEntry[],
|
||||
): SessionHistoryInspection {
|
||||
let entries: readonly HistoryEntry[] | undefined
|
||||
let conversation: ReturnType<typeof projectConversationHistory> | undefined
|
||||
let requests: ReturnType<typeof inspectRequests> | undefined
|
||||
const historyEntries = () => entries ??= loadEntries()
|
||||
const conversationProjection = () =>
|
||||
conversation ??= projectConversationHistory(historyEntries())
|
||||
const requestProjection = () =>
|
||||
requests ??= inspectRequests(historyEntries())
|
||||
return {
|
||||
get eventNodes() {
|
||||
return conversationProjection().eventNodes
|
||||
},
|
||||
get contexts() {
|
||||
return conversationProjection().contexts
|
||||
},
|
||||
get interruptedNodes() {
|
||||
return conversationProjection().interruptedNodes
|
||||
},
|
||||
get partial() {
|
||||
return conversationProjection().partial
|
||||
},
|
||||
get runningCalls() {
|
||||
return conversationProjection().runningCalls
|
||||
},
|
||||
get requests() {
|
||||
return requestProjection().requests
|
||||
},
|
||||
get callSchemas() {
|
||||
return requestProjection().callSchemas
|
||||
},
|
||||
}
|
||||
}
|
||||
@@ -800,6 +800,14 @@ export class SessionManager {
|
||||
}
|
||||
return
|
||||
}
|
||||
case 'host/session-preset-changed': {
|
||||
// Every connected client observes the switch here; only the tab that
|
||||
// issued it also gets the RPC echo. The merge keeps the row's own
|
||||
// updatedAt and lowers `blank` only, so re-applying the switching
|
||||
// tab's own frame is a no-op.
|
||||
this.noteAgentPreset(frame.sessionId, frame.agentPreset)
|
||||
return
|
||||
}
|
||||
case 'host/session-removed': {
|
||||
const summary = this.summaries.find(candidate => candidate.sessionId === frame.sessionId)
|
||||
const durableSubagent = summary?.origin === 'subagent' || this.addresses.has(frame.sessionId)
|
||||
@@ -1030,7 +1038,7 @@ export class SessionManager {
|
||||
const prev = this.entryCache.get(entry.sessionId)
|
||||
if (
|
||||
prev !== undefined && prev.updatedAt === entry.updatedAt && prev.running === entry.running
|
||||
&& prev.blank === entry.blank
|
||||
&& prev.blank === entry.blank && prev.agentPreset === entry.agentPreset
|
||||
&& prev.parentSessionId === entry.parentSessionId && prev.cwd === entry.cwd
|
||||
&& prev.origin === entry.origin && prev.title === entry.title && prev.depth === entry.depth
|
||||
&& prev.pendingInteraction === entry.pendingInteraction
|
||||
|
||||
@@ -1,17 +1,7 @@
|
||||
// Request-centric inspection read model. Ordinary generation and compaction
|
||||
// calls share one chronological projection; presentation-specific grouping
|
||||
// remains in the trajectory consumer.
|
||||
|
||||
import type { ContentBlock, TokenUsage, ToolSchema } from '@deepseek-ai/dsh-llm/types'
|
||||
import type { HistoryEntry } from '@deepseek-ai/dsh-client-connection/client'
|
||||
import type { SessionEvent } from '@deepseek-ai/dsh-session/types'
|
||||
import type {} from '@deepseek-ai/dsh-compact/types'
|
||||
import type {} from '@deepseek-ai/dsh-llm-retry/types'
|
||||
import type {} from '@deepseek-ai/dsh-tools/types'
|
||||
import type { ContentBlock, ToolSchema } from '@deepseek-ai/dsh-llm/types'
|
||||
import type {
|
||||
AssistantProvenanceView, AssistantRequestConfig,
|
||||
} from './conversation.ts'
|
||||
import { displayFailureMessage } from './failure-display.ts'
|
||||
|
||||
export type {
|
||||
AssistantProvenanceView, AssistantRequestConfig,
|
||||
@@ -54,7 +44,7 @@ interface RequestViewBase {
|
||||
resultSeq?: number
|
||||
}
|
||||
|
||||
/** One ordinary assistant generation reconstructed from durable request events. */
|
||||
/** One ordinary assistant generation assembled from durable request events. */
|
||||
interface AssistantRequestView extends RequestViewBase {
|
||||
purpose: 'assistant'
|
||||
turn: number
|
||||
@@ -85,321 +75,11 @@ interface CompactionRequestView extends RequestViewBase {
|
||||
rawOutput?: readonly ContentBlock[]
|
||||
}
|
||||
|
||||
/** One provider request reconstructed from durable request lifecycle events. */
|
||||
/** One provider request assembled from durable request lifecycle events. */
|
||||
export type RequestView = AssistantRequestView | CompactionRequestView
|
||||
|
||||
/** Immutable request-centric projection derived from one history window. */
|
||||
/** Request data consumed by the stage-oriented Trajectory layout. */
|
||||
export interface RequestInspectionSnapshot {
|
||||
requests: readonly RequestView[]
|
||||
callSchemas: ReadonlyMap<string, ToolSchema>
|
||||
}
|
||||
|
||||
/**
|
||||
* Derive the request-centric read model from one immutable history window.
|
||||
* Compaction participates as a request purpose rather than a parallel
|
||||
* top-level collection. A leading resume/change header exposes its prompt but
|
||||
* cannot project a change until the preceding header enters the window.
|
||||
* @param entries - Contiguous raw session history.
|
||||
* @returns Requests and call-time schemas derived from that history.
|
||||
*/
|
||||
export function inspectRequests(
|
||||
entries: readonly HistoryEntry[],
|
||||
): RequestInspectionSnapshot {
|
||||
const events = entries.map(entry => entry.event)
|
||||
return {
|
||||
requests: deriveRequests(events),
|
||||
callSchemas: deriveCallSchemas(events),
|
||||
}
|
||||
}
|
||||
|
||||
function requestKey(turn: number, step: number): string {
|
||||
return `${turn}\u0000${step}`
|
||||
}
|
||||
|
||||
function addTokenUsage(current: unknown, next: TokenUsage): TokenUsage {
|
||||
const previous = current as TokenUsage | undefined
|
||||
return {
|
||||
inputTokens: (previous?.inputTokens ?? 0) + next.inputTokens,
|
||||
outputTokens: (previous?.outputTokens ?? 0) + next.outputTokens,
|
||||
...(previous?.cacheReadTokens === undefined && next.cacheReadTokens === undefined
|
||||
? {}
|
||||
: {
|
||||
cacheReadTokens:
|
||||
(previous?.cacheReadTokens ?? 0) + (next.cacheReadTokens ?? 0),
|
||||
}),
|
||||
...(previous?.cacheWriteTokens === undefined && next.cacheWriteTokens === undefined
|
||||
? {}
|
||||
: {
|
||||
cacheWriteTokens:
|
||||
(previous?.cacheWriteTokens ?? 0) + (next.cacheWriteTokens ?? 0),
|
||||
}),
|
||||
...(previous?.reasoningTokens === undefined && next.reasoningTokens === undefined
|
||||
? {}
|
||||
: {
|
||||
reasoningTokens:
|
||||
(previous?.reasoningTokens ?? 0) + (next.reasoningTokens ?? 0),
|
||||
}),
|
||||
}
|
||||
}
|
||||
|
||||
function deriveCallSchemas(
|
||||
events: readonly SessionEvent[],
|
||||
): ReadonlyMap<string, ToolSchema> {
|
||||
let active = new Map<string, ToolSchema>()
|
||||
const calls = new Map<string, ToolSchema>()
|
||||
const capture = (callId: string, name: string): void => {
|
||||
if (calls.has(callId)) return
|
||||
const schema = active.get(name)
|
||||
if (schema !== undefined) calls.set(callId, schema)
|
||||
}
|
||||
for (const event of events) {
|
||||
if (event.type === 'request/header') {
|
||||
const tools: unknown = event.data.header.tools
|
||||
active = new Map(
|
||||
Array.isArray(tools)
|
||||
? (tools as ToolSchema[]).map(schema => [schema.name, schema])
|
||||
: [],
|
||||
)
|
||||
continue
|
||||
}
|
||||
if (event.type === 'tool/call') {
|
||||
capture(String(event.data.callId), event.data.name)
|
||||
continue
|
||||
}
|
||||
if (event.type === 'tool/code-dispatch-start' || event.type === 'tool/code-dispatch') {
|
||||
capture(String(event.data.subCallId), event.data.name)
|
||||
}
|
||||
}
|
||||
return calls
|
||||
}
|
||||
|
||||
function promptChange(
|
||||
previous: ConversationPromptSnapshot | undefined,
|
||||
prompt: ConversationPromptSnapshot,
|
||||
event: SessionEvent<'request/header'>,
|
||||
): RequestPromptChange | undefined {
|
||||
if (previous === undefined && event.data.reason !== 'initial') return
|
||||
const systemChanged = previous !== undefined && previous.system !== prompt.system
|
||||
const toolsChanged = previous !== undefined
|
||||
&& JSON.stringify(previous.tools) !== JSON.stringify(prompt.tools)
|
||||
if (previous !== undefined && !systemChanged && !toolsChanged) return
|
||||
return {
|
||||
seq: event.seq,
|
||||
time: event.time,
|
||||
kind: previous === undefined
|
||||
? 'initial'
|
||||
: systemChanged && toolsChanged
|
||||
? 'system-and-tools'
|
||||
: systemChanged
|
||||
? 'system'
|
||||
: 'tools',
|
||||
...(previous === undefined ? {} : { previous }),
|
||||
}
|
||||
}
|
||||
|
||||
/** Project ordinary and compaction provider calls into one chronological request stream. */
|
||||
function deriveRequests(events: readonly SessionEvent[]): readonly RequestView[] {
|
||||
const requests: RequestView[] = []
|
||||
const ordinaryByStep = new Map<string, number>()
|
||||
const lastStepByTurn = new Map<number, string>()
|
||||
let activeStep: string | undefined
|
||||
let activePrompt: ConversationPromptSnapshot | undefined
|
||||
let activeCompaction: number | undefined
|
||||
|
||||
const updateAssistant = (
|
||||
index: number | undefined,
|
||||
change: Partial<Omit<AssistantRequestView, 'purpose'>>,
|
||||
): void => {
|
||||
if (index === undefined) return
|
||||
const request = requests[index]
|
||||
if (request?.purpose === 'assistant') requests[index] = { ...request, ...change }
|
||||
}
|
||||
const updateCompaction = (
|
||||
index: number | undefined,
|
||||
change: Partial<Omit<CompactionRequestView, 'purpose'>>,
|
||||
): void => {
|
||||
if (index === undefined) return
|
||||
const request = requests[index]
|
||||
if (request?.purpose === 'compaction') requests[index] = { ...request, ...change }
|
||||
}
|
||||
|
||||
for (const sourceEvent of events) {
|
||||
if (sourceEvent.type === 'step/start') {
|
||||
const { turn, step } = sourceEvent.data
|
||||
const key = requestKey(turn, step)
|
||||
ordinaryByStep.set(key, requests.length)
|
||||
lastStepByTurn.set(turn, key)
|
||||
requests.push({
|
||||
purpose: 'assistant',
|
||||
startSeq: sourceEvent.seq,
|
||||
turn,
|
||||
step,
|
||||
startedAt: sourceEvent.time,
|
||||
completedAt: null,
|
||||
status: 'running',
|
||||
...(activePrompt === undefined
|
||||
? {}
|
||||
: { prompt: activePrompt, requestConfig: activePrompt.config }),
|
||||
})
|
||||
activeStep = key
|
||||
continue
|
||||
}
|
||||
if (sourceEvent.type === 'request/header') {
|
||||
const tools: unknown = sourceEvent.data.header.tools
|
||||
const prompt: ConversationPromptSnapshot = {
|
||||
config: sourceEvent.data.header.config,
|
||||
system: sourceEvent.data.header.system ?? '',
|
||||
tools: Array.isArray(tools) ? tools as ToolSchema[] : [],
|
||||
}
|
||||
const change = promptChange(activePrompt, prompt, sourceEvent)
|
||||
activePrompt = prompt
|
||||
updateAssistant(activeStep === undefined ? undefined : ordinaryByStep.get(activeStep), {
|
||||
prompt,
|
||||
requestConfig: prompt.config,
|
||||
...(change === undefined ? {} : { promptChange: change }),
|
||||
})
|
||||
continue
|
||||
}
|
||||
if (
|
||||
sourceEvent.type === 'assistant/chunk'
|
||||
&& sourceEvent.data.chunk.type === 'usage'
|
||||
) {
|
||||
const index = ordinaryByStep.get(
|
||||
requestKey(sourceEvent.data.turn, sourceEvent.data.step),
|
||||
)
|
||||
const request = index === undefined ? undefined : requests[index]
|
||||
updateAssistant(index, {
|
||||
usage: addTokenUsage(
|
||||
request?.purpose === 'assistant' ? request.usage : undefined,
|
||||
sourceEvent.data.chunk.usage,
|
||||
),
|
||||
})
|
||||
continue
|
||||
}
|
||||
if (sourceEvent.type === 'assistant/message') {
|
||||
const index = ordinaryByStep.get(
|
||||
requestKey(sourceEvent.data.turn, sourceEvent.data.step),
|
||||
)
|
||||
const request = index === undefined ? undefined : requests[index]
|
||||
updateAssistant(index, {
|
||||
completedAt: sourceEvent.time,
|
||||
status: 'complete',
|
||||
resultSeq: sourceEvent.seq,
|
||||
provenance: {
|
||||
provider: sourceEvent.data.message.source.provider,
|
||||
model: sourceEvent.data.message.source.model,
|
||||
},
|
||||
...(request?.purpose === 'assistant'
|
||||
&& request.usage !== undefined
|
||||
|| sourceEvent.data.usage === undefined
|
||||
? {}
|
||||
: { usage: sourceEvent.data.usage }),
|
||||
})
|
||||
continue
|
||||
}
|
||||
if (sourceEvent.type === 'step/end') {
|
||||
const key = requestKey(sourceEvent.data.turn, sourceEvent.data.step)
|
||||
const index = ordinaryByStep.get(key)
|
||||
const request = index === undefined ? undefined : requests[index]
|
||||
if (request?.purpose === 'assistant' && request.status === 'running') {
|
||||
updateAssistant(index, {
|
||||
completedAt: sourceEvent.time,
|
||||
status: 'error',
|
||||
})
|
||||
}
|
||||
if (activeStep === key) activeStep = undefined
|
||||
continue
|
||||
}
|
||||
if (sourceEvent.type === 'llm/retry') {
|
||||
const data = sourceEvent.data
|
||||
updateAssistant(ordinaryByStep.get(requestKey(data.turn, data.step)), {
|
||||
status: 'error',
|
||||
error: displayFailureMessage(data.failure),
|
||||
retry: data.retry,
|
||||
...data.mode === 'normal' ? { maxRetries: data.maxRetries } : {},
|
||||
retryDelayMs: data.delayMs,
|
||||
})
|
||||
continue
|
||||
}
|
||||
if (sourceEvent.type === 'turn/end') {
|
||||
const lastStep = lastStepByTurn.get(sourceEvent.data.turn)
|
||||
if (sourceEvent.data.reason.kind === 'error') {
|
||||
updateAssistant(lastStep === undefined ? undefined : ordinaryByStep.get(lastStep), {
|
||||
status: 'error',
|
||||
error: displayFailureMessage(sourceEvent.data.reason.error),
|
||||
})
|
||||
}
|
||||
lastStepByTurn.delete(sourceEvent.data.turn)
|
||||
continue
|
||||
}
|
||||
|
||||
if (sourceEvent.type === 'session/end-seed' && activeCompaction !== undefined) {
|
||||
updateCompaction(activeCompaction, {
|
||||
completedAt: sourceEvent.time,
|
||||
status: 'error',
|
||||
error: 'Compaction was interrupted before completion.',
|
||||
})
|
||||
activeCompaction = undefined
|
||||
continue
|
||||
}
|
||||
if (sourceEvent.type === 'compact/start') {
|
||||
activeCompaction = requests.length
|
||||
requests.push({
|
||||
purpose: 'compaction',
|
||||
startSeq: sourceEvent.seq,
|
||||
turn: sourceEvent.data.turn,
|
||||
step: 0,
|
||||
startedAt: sourceEvent.time,
|
||||
completedAt: null,
|
||||
status: 'running',
|
||||
})
|
||||
continue
|
||||
}
|
||||
if (sourceEvent.type === 'compact/summary' && activeCompaction !== undefined) {
|
||||
const data = sourceEvent.data
|
||||
updateCompaction(activeCompaction, {
|
||||
resultSeq: sourceEvent.seq,
|
||||
summary: data.summary,
|
||||
...(data.rawOutput === undefined ? {} : { rawOutput: data.rawOutput }),
|
||||
provenance: {
|
||||
provider: data.provider,
|
||||
model: data.model,
|
||||
},
|
||||
requestConfig: {
|
||||
provider: data.provider,
|
||||
model: data.model,
|
||||
purpose: 'compaction',
|
||||
...(data.maxTokens === undefined ? {} : { maxTokens: data.maxTokens }),
|
||||
},
|
||||
...(data.usage === undefined ? {} : { usage: data.usage }),
|
||||
})
|
||||
continue
|
||||
}
|
||||
if (
|
||||
sourceEvent.type === 'user/message'
|
||||
&& activeCompaction !== undefined
|
||||
&& isCompactionSource(sourceEvent.data.source)
|
||||
) {
|
||||
updateCompaction(activeCompaction, { replacementSeq: sourceEvent.seq })
|
||||
continue
|
||||
}
|
||||
if (sourceEvent.type !== 'compact/end' || activeCompaction === undefined) continue
|
||||
updateCompaction(activeCompaction, {
|
||||
completedAt: sourceEvent.time,
|
||||
status: sourceEvent.data.error === undefined ? 'complete' : 'error',
|
||||
...(sourceEvent.data.error === undefined ? {} : { error: sourceEvent.data.error }),
|
||||
})
|
||||
activeCompaction = undefined
|
||||
}
|
||||
|
||||
return requests.sort((left, right) => left.startSeq - right.startSeq)
|
||||
}
|
||||
|
||||
function isCompactionSource(source: unknown): boolean {
|
||||
return typeof source === 'object'
|
||||
&& source !== null
|
||||
&& 'kind' in source
|
||||
&& source.kind === 'plugin'
|
||||
&& 'plugin' in source
|
||||
&& source.plugin === 'compact'
|
||||
}
|
||||
|
||||
@@ -14,7 +14,7 @@
|
||||
* tears its scope down immediately unless it is the staged one, whose scope
|
||||
* survives frozen (read-only view) until the stage moves on.
|
||||
*/
|
||||
import type { Context, Fiber } from 'cordis'
|
||||
import type { Context, Fiber } from '@deepseek-ai/cordis'
|
||||
import type {
|
||||
IApiClient, RpcError, RpcResult, SessionId, SubagentAddress, TaskView, WorkspaceId,
|
||||
} from '@deepseek-ai/dsh-client-connection/client'
|
||||
|
||||
@@ -1,10 +1,10 @@
|
||||
// Sessions remain resident after creation so they continue consuming mux frames off-screen.
|
||||
|
||||
import type { Context } from 'cordis'
|
||||
import type { ContentBlock } from '@deepseek-ai/dsh-llm/types'
|
||||
import type { Context } from '@deepseek-ai/cordis'
|
||||
import type { AttachmentIdType, ImageAttachmentRef } from '@deepseek-ai/dsh-attachment'
|
||||
import type { SessionEvent } from '@deepseek-ai/dsh-session/types'
|
||||
import type {
|
||||
HistoryEntry, IApiClient, MessageId, MuxFrame, QueueAction, RpcError,
|
||||
HistoryEntry, IApiClient, MessageId, MuxFrame, PromptContentPart, QueueAction, RpcError,
|
||||
RpcId, RpcResponse, RpcResult, SessionId, SubagentAddress, ToolEventView,
|
||||
} from '@deepseek-ai/dsh-client-connection/client'
|
||||
// Value import from the inline-safe wire layer (not the connection plugin):
|
||||
@@ -178,11 +178,11 @@ export class Session implements SessionFace {
|
||||
|
||||
/**
|
||||
* Send (queue/steer passed through 1:1); failures land in the snapshot's promptError.
|
||||
* @param content - core content blocks verbatim.
|
||||
* @param content - text plus browser-owned temporary image uploads.
|
||||
* @param mode - queue appends after the current turn; steer interrupts it.
|
||||
* @returns the prompt result (also mirrored into promptError on failure).
|
||||
*/
|
||||
async prompt(content: ContentBlock[], mode: 'queue' | 'steer'): Promise<RpcResult<{ accepted: true }>> {
|
||||
async prompt(content: PromptContentPart[], mode: 'queue' | 'steer'): Promise<RpcResult<{ accepted: true }>> {
|
||||
this.promptError = null
|
||||
this.lastAgentError = null
|
||||
// Synchronous, before the first await: the blank → engaging edge must be
|
||||
@@ -205,8 +205,24 @@ export class Session implements SessionFace {
|
||||
},
|
||||
}
|
||||
} else {
|
||||
const routed = (await this.api.subagents.prompt({ ...this.address, content })).result
|
||||
result = routed.ok ? { ok: true, value: { accepted: true } } : routed
|
||||
if (content.some(part => part.type === 'image')) {
|
||||
result = {
|
||||
ok: false,
|
||||
error: {
|
||||
code: 'attachment-error',
|
||||
message: 'Image input is unavailable for subagent continuations.',
|
||||
details: { reason: 'SUBAGENT_IMAGE_UNSUPPORTED' },
|
||||
},
|
||||
}
|
||||
} else {
|
||||
const routed = (await this.api.subagents.prompt({
|
||||
...this.address,
|
||||
content: content.flatMap(part => part.type === 'text'
|
||||
? [{ type: 'text' as const, text: part.text }]
|
||||
: []),
|
||||
})).result
|
||||
result = routed.ok ? { ok: true, value: { accepted: true } } : routed
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
result = transportError(error)
|
||||
@@ -232,6 +248,28 @@ export class Session implements SessionFace {
|
||||
return result
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolve one image referenced by this session into browser-consumable bytes.
|
||||
* @param attachmentId - opaque id found in the folded session log.
|
||||
* @returns the authenticated reference and decoded bytes.
|
||||
*/
|
||||
async readAttachment(
|
||||
attachmentId: AttachmentIdType,
|
||||
): Promise<RpcResult<{ attachment: ImageAttachmentRef; data: Uint8Array }>> {
|
||||
try {
|
||||
const result = (await this.api.sessions.attachment({
|
||||
sessionId: this.sessionId,
|
||||
attachmentId,
|
||||
})).result
|
||||
if (!result.ok) return result
|
||||
const binary = atob(result.value.data)
|
||||
const data = Uint8Array.from(binary, char => char.charCodeAt(0))
|
||||
return { ok: true, value: { attachment: result.value.attachment, data } }
|
||||
} catch (error) {
|
||||
return transportError(error)
|
||||
}
|
||||
}
|
||||
|
||||
/** Apply one operation to a still-pending queue occurrence. */
|
||||
async updateQueue(itemId: MessageId, action: QueueAction): Promise<RpcResult<{ accepted: true }>> {
|
||||
try {
|
||||
@@ -689,6 +727,7 @@ export class Session implements SessionFace {
|
||||
const legacy = chat.legacy
|
||||
return {
|
||||
sessionId: this.sessionId,
|
||||
views: this.conversation,
|
||||
chat,
|
||||
nodes: legacy.nodes,
|
||||
turnTimings: legacy.turnTimings,
|
||||
|
||||
261
packages/client/runtime/src/client/settings-scope.ts
Normal file
261
packages/client/runtime/src/client/settings-scope.ts
Normal file
@@ -0,0 +1,261 @@
|
||||
/** Host-backed settings-namespace synchronization for browser plugins. */
|
||||
|
||||
import type { Context } from '@deepseek-ai/cordis'
|
||||
import type {
|
||||
ConnectionHandle, IApiClient, SettingsNamespaceView,
|
||||
} from '@deepseek-ai/dsh-client-connection/client'
|
||||
import { rehydrateSchema, validateDraft } from '@deepseek-ai/dsh-client-schema-form'
|
||||
import { createSnapshotStore, type SnapshotStore } from './contract/store.ts'
|
||||
|
||||
/** Client-side sync state of one settings namespace. */
|
||||
export interface SettingsScopeSnapshot<T> {
|
||||
/**
|
||||
* `loading` until the first accepted section, `ready` while one stands, and
|
||||
* `unavailable` when the namespace is not exposed to this client or the
|
||||
* connection keeps preferences process-local (memory mode).
|
||||
*/
|
||||
status: 'loading' | 'ready' | 'unavailable'
|
||||
/** Last accepted schema-resolved section; undefined before the first acceptance. */
|
||||
value: T | undefined
|
||||
/** Namespace revision fencing the next write; undefined before the first Host view. */
|
||||
revision: number | undefined
|
||||
/** Whether the Host document accepts writes; memory mode never does. */
|
||||
writable: boolean
|
||||
/** `host` syncs with the Host document; `memory` keeps a remote browser process-local. */
|
||||
mode: 'host' | 'memory'
|
||||
}
|
||||
|
||||
/** Domain-owned description of one settings namespace consumed by a browser plugin. */
|
||||
export interface SettingsScopeSpec<T> {
|
||||
/** Settings namespace registered by the owning Host plugin. */
|
||||
namespace: string
|
||||
/**
|
||||
* Narrow one wire section; undefined keeps the last accepted value. The
|
||||
* default validates the section against the namespace's own serialized wire
|
||||
* schema, so domains add a decoder only to narrow beyond that schema.
|
||||
*/
|
||||
decode?: (section: unknown) => T | undefined
|
||||
}
|
||||
|
||||
/**
|
||||
* Reactive owner handle over one namespace's durable section — the browser
|
||||
* mirror of the Host-side `SettingsScope` owner seam. Domain services read
|
||||
* and observe the snapshot and route explicit user choices through `set`.
|
||||
*/
|
||||
export interface SettingsScope<T> {
|
||||
/** @returns the current sync snapshot (stable reference until the next change). */
|
||||
getSnapshot(): SettingsScopeSnapshot<T>
|
||||
/**
|
||||
* Observe snapshot replacements.
|
||||
* @param listener - invoked after each snapshot change.
|
||||
* @returns the disposer removing this listener.
|
||||
*/
|
||||
subscribe(listener: () => void): () => void
|
||||
/**
|
||||
* Queue one field write. Rapid writes preserve mutation order, each carries
|
||||
* the latest known namespace revision, and only the latest settlement may
|
||||
* publish; a rejected or failed latest write reloads Host state instead.
|
||||
* @param field - scalar field inside the namespace section.
|
||||
* @param value - JSON-shaped value selected by the user.
|
||||
* @returns settlement after the write and any latest-write recovery read.
|
||||
*/
|
||||
set(field: string, value: unknown): Promise<void>
|
||||
}
|
||||
|
||||
type SettingsFace = Pick<IApiClient, 'settings'>
|
||||
|
||||
/**
|
||||
* Serializes one namespace's Host reads and writes behind a snapshot store.
|
||||
* Reads never block plugin activation; writes carry the latest known
|
||||
* namespace revision and teardown waits for the operation already crossing
|
||||
* the wire.
|
||||
*/
|
||||
export class SettingsScopeController<T> implements SettingsScope<T> {
|
||||
private readonly store: SnapshotStore<SettingsScopeSnapshot<T>>
|
||||
private tail: Promise<void> = Promise.resolve()
|
||||
private readGeneration = 0
|
||||
private writeGeneration = 0
|
||||
private disposed = false
|
||||
|
||||
/**
|
||||
* @param api - settings wire face.
|
||||
* @param spec - namespace identity and optional narrowing decoder.
|
||||
* @param persistence - remote browsers remain process-local because settings RPCs are loopback-only.
|
||||
*/
|
||||
constructor(
|
||||
private readonly api: SettingsFace,
|
||||
private readonly spec: SettingsScopeSpec<T>,
|
||||
private readonly persistence: 'host' | 'memory' = 'host',
|
||||
) {
|
||||
this.store = createSnapshotStore<SettingsScopeSnapshot<T>>({
|
||||
status: persistence === 'host' ? 'loading' : 'unavailable',
|
||||
value: undefined,
|
||||
revision: undefined,
|
||||
writable: false,
|
||||
mode: persistence,
|
||||
})
|
||||
}
|
||||
|
||||
/** @returns the current sync snapshot (stable reference until the next change). */
|
||||
getSnapshot(): SettingsScopeSnapshot<T> {
|
||||
return this.store.getSnapshot()
|
||||
}
|
||||
|
||||
/**
|
||||
* Observe snapshot replacements.
|
||||
* @param listener - invoked after each snapshot change.
|
||||
* @returns the disposer removing this listener.
|
||||
*/
|
||||
subscribe(listener: () => void): () => void {
|
||||
return this.store.subscribe(listener)
|
||||
}
|
||||
|
||||
/**
|
||||
* Queue a Host refresh; a newer read or user write suppresses stale publication.
|
||||
* @returns settlement after the queued read completes or is skipped.
|
||||
*/
|
||||
load(): Promise<void> {
|
||||
const generation = ++this.readGeneration
|
||||
return this.enqueue(() => this.read(generation))
|
||||
}
|
||||
|
||||
/**
|
||||
* Queue one field write; see {@link SettingsScope.set} for the ordering,
|
||||
* revision, and recovery contract.
|
||||
* @param field - scalar field inside the namespace section.
|
||||
* @param value - JSON-shaped value selected by the user.
|
||||
* @returns settlement after the write and any latest-write recovery read.
|
||||
*/
|
||||
set(field: string, value: unknown): Promise<void> {
|
||||
this.readGeneration += 1
|
||||
const generation = ++this.writeGeneration
|
||||
return this.enqueue(async () => {
|
||||
const revision = this.getSnapshot().revision
|
||||
let response: Awaited<ReturnType<SettingsFace['settings']['mutate']>>
|
||||
try {
|
||||
response = await this.api.settings.mutate({
|
||||
ns: this.spec.namespace,
|
||||
ops: [{ op: 'set', path: [field], value }],
|
||||
...(revision === undefined ? {} : { expectedRevision: revision }),
|
||||
})
|
||||
} catch (_settingsWriteFailure) {
|
||||
if (!this.disposed && generation === this.writeGeneration) await this.read(++this.readGeneration)
|
||||
return
|
||||
}
|
||||
if (!response.result.ok) {
|
||||
if (!this.disposed && generation === this.writeGeneration) await this.read(++this.readGeneration)
|
||||
return
|
||||
}
|
||||
this.accept(response.result.value, generation === this.writeGeneration)
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* Stop queued operations and wait for the current wire call to settle.
|
||||
* @returns settlement after the controller reaches quiescence.
|
||||
*/
|
||||
async dispose(): Promise<void> {
|
||||
this.disposed = true
|
||||
this.readGeneration += 1
|
||||
this.writeGeneration += 1
|
||||
await this.tail
|
||||
}
|
||||
|
||||
private enqueue(operation: () => Promise<void>): Promise<void> {
|
||||
if (this.persistence === 'memory' || this.disposed) return Promise.resolve()
|
||||
const task = this.tail.then(async () => {
|
||||
if (this.disposed) return
|
||||
await operation()
|
||||
})
|
||||
// The returned task carries its own settlement to the caller; the queue
|
||||
// tail is kept fulfilled so one failed subscriber cannot strand later operations.
|
||||
this.tail = task.catch(() => {})
|
||||
return task
|
||||
}
|
||||
|
||||
private async read(generation: number): Promise<void> {
|
||||
let response: Awaited<ReturnType<SettingsFace['settings']['describe']>>
|
||||
try {
|
||||
response = await this.api.settings.describe({})
|
||||
} catch (_settingsReadFailure) {
|
||||
return
|
||||
}
|
||||
if (!response.result.ok || this.disposed) return
|
||||
const { namespaces, writable } = response.result.value
|
||||
const view = namespaces.find(candidate => candidate.ns === this.spec.namespace)
|
||||
const publish = generation === this.readGeneration
|
||||
if (view === undefined) {
|
||||
if (publish) {
|
||||
this.store.update((draft) => {
|
||||
draft.status = 'unavailable'
|
||||
draft.writable = writable
|
||||
})
|
||||
}
|
||||
return
|
||||
}
|
||||
this.accept(view, publish, writable)
|
||||
}
|
||||
|
||||
private accept(view: SettingsNamespaceView, publish: boolean, writable?: boolean): void {
|
||||
const decoded = publish ? this.decode(view) : undefined
|
||||
this.store.update((draft) => {
|
||||
draft.revision = view.revision
|
||||
if (writable !== undefined) draft.writable = writable
|
||||
if (decoded === undefined) return
|
||||
draft.status = 'ready'
|
||||
draft.value = decoded
|
||||
})
|
||||
}
|
||||
|
||||
private decode(view: SettingsNamespaceView): T | undefined {
|
||||
if (this.spec.decode !== undefined) return this.spec.decode(view.value)
|
||||
// Sections are plain objects by construction; schemastery alone would
|
||||
// resolve null or an array through object defaults instead of refusing.
|
||||
if (typeof view.value !== 'object' || view.value === null || Array.isArray(view.value)) return undefined
|
||||
let failure: string | undefined
|
||||
try {
|
||||
failure = validateDraft(rehydrateSchema(view.schema), view.value)
|
||||
} catch (_malformedSchemaEnvelope) {
|
||||
// A schema envelope this client cannot rehydrate vouches for no section;
|
||||
// the value is treated exactly like a schema-invalid one.
|
||||
return undefined
|
||||
}
|
||||
return failure === undefined ? view.value as T : undefined
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Bind one namespace scope to settings and connection invalidations on the
|
||||
* caller's plugin lifecycle. Listeners exist before the initial background
|
||||
* read starts, so activation never blocks on the settings transport.
|
||||
* @param ctx - owning browser plugin context.
|
||||
* @param spec - domain-owned namespace contract.
|
||||
* @returns the bound scope consumed by the domain's services and rows.
|
||||
*/
|
||||
export function bindSettingsScope<T>(
|
||||
ctx: Context,
|
||||
spec: SettingsScopeSpec<T>,
|
||||
): SettingsScope<T> {
|
||||
const connection = ctx.get('connection') as ConnectionHandle
|
||||
const controller = new SettingsScopeController<T>(
|
||||
connection.api,
|
||||
spec,
|
||||
connection.isLoopback ? 'host' : 'memory',
|
||||
)
|
||||
ctx.effect(() => {
|
||||
const refresh = (namespace?: string): void => {
|
||||
if (namespace !== undefined && namespace !== spec.namespace) return
|
||||
void controller.load()
|
||||
}
|
||||
const disposers = [
|
||||
ctx.on('settings/changed', refresh),
|
||||
ctx.on('connection/reset', () => { refresh() }),
|
||||
]
|
||||
void controller.load()
|
||||
return async () => {
|
||||
for (const dispose of disposers) dispose()
|
||||
await controller.dispose()
|
||||
}
|
||||
}, `runtime: ${spec.namespace} settings scope`)
|
||||
return controller
|
||||
}
|
||||
@@ -14,8 +14,8 @@
|
||||
* holds this package's 'root' row in this compilation unit, but consumers
|
||||
* merge keys in; the rule fires on the narrow-map view, not on real
|
||||
* redundancy. */
|
||||
import { Service } from 'cordis'
|
||||
import type { Context } from 'cordis'
|
||||
import { Service } from '@deepseek-ai/cordis'
|
||||
import type { Context } from '@deepseek-ai/cordis'
|
||||
import { SlotCore } from '@deepseek-ai/dsh-client-ui-slots'
|
||||
import type {
|
||||
LocaleFace, OwnerOf, SlotEntryDef, SlotMap, SlotRenderer, SlotRendererHost,
|
||||
|
||||
@@ -120,7 +120,7 @@ export class WorkspaceManager {
|
||||
/**
|
||||
* Create or resolve a real Workspace, then publish its returned snapshot
|
||||
* without waiting for the changed frame.
|
||||
* @param input - name under workspaceRoot or an existing absolute path.
|
||||
* @param input - the existing absolute path to adopt.
|
||||
* @returns the wire result.
|
||||
*/
|
||||
async create(input: WorkspaceCreateInput): Promise<RpcResult<{ workspace: WorkspaceView; created: boolean }>> {
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
/** WorkspacesService projects the Workspace object manager for UI consumers. */
|
||||
|
||||
import type { Context } from 'cordis'
|
||||
import type { Context } from '@deepseek-ai/cordis'
|
||||
import type {
|
||||
DirectoryListing, IApiClient, RpcError,
|
||||
SessionId, WorkspaceId, WorkspaceView,
|
||||
@@ -186,11 +186,11 @@ export class WorkspacesService implements IWorkspaces {
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a Workspace by name or register an existing path.
|
||||
* @param input - exactly one Host create spelling.
|
||||
* Register an existing path as a Workspace.
|
||||
* @param input - the Host create payload.
|
||||
* @returns the created or idempotently resolved Workspace.
|
||||
*/
|
||||
async create(input: { name: string } | { path: string }): Promise<WorkspaceView> {
|
||||
async create(input: { path: string }): Promise<WorkspaceView> {
|
||||
const result = await this.manager.create(input)
|
||||
if (!result.ok) throw new WorkspaceCreateError(result.error)
|
||||
return result.value.workspace
|
||||
|
||||
@@ -8,7 +8,7 @@ import type { ObservableSnapshot } from '../contract/store.ts'
|
||||
import { Notifier } from '../sessions/notifier.ts'
|
||||
|
||||
/** Host input retained by a local Workspace until materialization succeeds. */
|
||||
export type WorkspaceCreateInput = { name: string } | { path: string }
|
||||
export type WorkspaceCreateInput = { path: string }
|
||||
|
||||
/** Observable state of a client-local Workspace intent. */
|
||||
export interface WorkspaceIntentSnapshot {
|
||||
@@ -137,7 +137,6 @@ export class Workspace implements ObservableSnapshot<WorkspaceSnapshot> {
|
||||
}
|
||||
|
||||
function intentName(input: WorkspaceCreateInput): string {
|
||||
if ('name' in input) return input.name
|
||||
const trimmed = input.path.replace(/[\\/]+$/, '')
|
||||
return trimmed.split(/[\\/]/).pop() ?? input.path
|
||||
}
|
||||
|
||||
@@ -8,7 +8,7 @@
|
||||
* `keyof SlotMap & string` is the declare-merge key pattern: SlotMap is empty
|
||||
* in this compilation unit (intersection reads `never`) but consumers merge
|
||||
* keys in; the rule fires on the empty-map view, not on real redundancy. */
|
||||
import type { Context } from 'cordis'
|
||||
import type { Context } from '@deepseek-ai/cordis'
|
||||
import type { SlotMap } from '@deepseek-ai/dsh-client-ui-slots'
|
||||
import type { InvariantInstaller } from '@deepseek-ai/dsh-invariants'
|
||||
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
* connection handle, stream-loop sink wiring into the object layer, and the
|
||||
* fiber-scoped loop teardown.
|
||||
*/
|
||||
import { Context } from 'cordis'
|
||||
import { Context } from '@deepseek-ai/cordis'
|
||||
import { describe, expect, it, vi } from 'vitest'
|
||||
import type { ConnectionHandle } from '@deepseek-ai/dsh-client-connection/client'
|
||||
import type { ConnectionSinks } from '@deepseek-ai/dsh-client-connection/client'
|
||||
@@ -126,6 +126,7 @@ describe('runtime client apply', () => {
|
||||
const rebuild = vi.spyOn(Session.prototype, 'rebuildConversationRegistry')
|
||||
const definition: ConversationNodeDefinition<null> = {
|
||||
kind: 'registry-probe',
|
||||
target: 'chat',
|
||||
match: () => null,
|
||||
start: () => null,
|
||||
update: context => context.state,
|
||||
|
||||
@@ -30,10 +30,16 @@ interface TestSnapshot {
|
||||
}
|
||||
|
||||
class TestEventDefinitions {
|
||||
readonly definitions: readonly ConversationNodeDefinition[]
|
||||
readonly fallback: ConversationNodeDefinition | undefined
|
||||
|
||||
constructor(
|
||||
readonly definitions: readonly ConversationNodeDefinition[],
|
||||
readonly fallback?: ConversationNodeDefinition,
|
||||
) {}
|
||||
definitions: readonly ConversationNodeDefinition[],
|
||||
fallback?: ConversationNodeDefinition,
|
||||
) {
|
||||
this.definitions = definitions
|
||||
this.fallback = fallback
|
||||
}
|
||||
|
||||
entries(): readonly ConversationNodeDefinition[] {
|
||||
return this.definitions
|
||||
@@ -93,7 +99,10 @@ function chatSnapshot(assembler: ConversationNodeAssembler): TestSnapshot | unde
|
||||
return assembler.snapshot('chat') as TestSnapshot | undefined
|
||||
}
|
||||
|
||||
function node(context: Parameters<ConversationNodeDefinition['buildViewNode']>[0], data: unknown): ConversationViewNode {
|
||||
function node(
|
||||
context: Parameters<NonNullable<ConversationNodeDefinition['buildViewNode']>>[0],
|
||||
data: unknown,
|
||||
): ConversationViewNode {
|
||||
return {
|
||||
key: context.key,
|
||||
kind: context.kind,
|
||||
@@ -103,6 +112,17 @@ function node(context: Parameters<ConversationNodeDefinition['buildViewNode']>[0
|
||||
}
|
||||
}
|
||||
|
||||
function fallbackDefinition(start: () => string): ConversationNodeDefinition<string> {
|
||||
return {
|
||||
kind: 'fallback',
|
||||
target: 'chat',
|
||||
match: event => ({ id: String(event.seq), role: 'start' }),
|
||||
start,
|
||||
update: context => context.state,
|
||||
buildViewNode: context => node(context, context.state),
|
||||
}
|
||||
}
|
||||
|
||||
describe('ConversationNodeAssembler', () => {
|
||||
it('appends through an exact business-id Context without replaying unrelated Contexts', () => {
|
||||
const starts = vi.fn((
|
||||
@@ -122,6 +142,7 @@ describe('ConversationNodeAssembler', () => {
|
||||
},
|
||||
start: starts,
|
||||
update: updates,
|
||||
target: 'chat',
|
||||
buildViewNode: context => node(context, context.state),
|
||||
}
|
||||
const assembler = new ConversationNodeAssembler(
|
||||
@@ -173,6 +194,7 @@ describe('ConversationNodeAssembler', () => {
|
||||
matchCollections.add(context.matches)
|
||||
return updates(context)
|
||||
},
|
||||
target: 'chat',
|
||||
buildViewNode: context => node(context, context.state),
|
||||
}
|
||||
const assembler = new ConversationNodeAssembler(
|
||||
@@ -208,6 +230,7 @@ describe('ConversationNodeAssembler', () => {
|
||||
},
|
||||
start: starts,
|
||||
update: updates,
|
||||
target: 'chat',
|
||||
buildViewNode: context => node(context, context.state),
|
||||
}
|
||||
const assembler = new ConversationNodeAssembler(
|
||||
@@ -247,6 +270,7 @@ describe('ConversationNodeAssembler', () => {
|
||||
},
|
||||
start: () => ({ settled: false }),
|
||||
update: updates,
|
||||
target: 'chat',
|
||||
buildViewNode: context => node(context, context.state ?? { pendingStart: true }),
|
||||
}
|
||||
const assembler = new ConversationNodeAssembler(
|
||||
@@ -280,6 +304,7 @@ describe('ConversationNodeAssembler', () => {
|
||||
: event.type === 'turn/start' ? { id: 'one', role: 'update' } : null,
|
||||
start: () => null,
|
||||
update: context => context.state,
|
||||
target: 'chat',
|
||||
buildViewNode: () => null,
|
||||
}
|
||||
const assembler = new ConversationNodeAssembler(
|
||||
@@ -301,6 +326,7 @@ describe('ConversationNodeAssembler', () => {
|
||||
: null,
|
||||
start: (_context, match) => Number((match.event.data as { value?: unknown }).value ?? 0),
|
||||
update: context => context.state,
|
||||
target: 'chat',
|
||||
buildViewNode: () => null,
|
||||
}
|
||||
const consumerStart = vi.fn((
|
||||
@@ -315,6 +341,7 @@ describe('ConversationNodeAssembler', () => {
|
||||
: null,
|
||||
start: consumerStart,
|
||||
update: context => context.state,
|
||||
target: 'chat',
|
||||
buildViewNode: context => node(context, context.state),
|
||||
}
|
||||
const assembler = new ConversationNodeAssembler(
|
||||
@@ -344,6 +371,7 @@ describe('ConversationNodeAssembler', () => {
|
||||
: null,
|
||||
start: (_context, match) => match.event.seq,
|
||||
update: context => context.state,
|
||||
target: 'chat',
|
||||
buildViewNode: () => null,
|
||||
}
|
||||
const consumer: ConversationNodeDefinition<number> = {
|
||||
@@ -353,6 +381,7 @@ describe('ConversationNodeAssembler', () => {
|
||||
: null,
|
||||
start: (_context, _match, reader) => reader.previous<number>('source')?.state ?? -1,
|
||||
update: context => context.state,
|
||||
target: 'chat',
|
||||
buildViewNode: context => node(context, context.state),
|
||||
}
|
||||
const assembler = new ConversationNodeAssembler(
|
||||
@@ -397,6 +426,7 @@ describe('ConversationNodeAssembler', () => {
|
||||
: null,
|
||||
start: consumerStart,
|
||||
update: context => context.state,
|
||||
target: 'chat',
|
||||
buildViewNode: context => node(context, context.state),
|
||||
}
|
||||
const assembler = new ConversationNodeAssembler(
|
||||
@@ -425,6 +455,7 @@ describe('ConversationNodeAssembler', () => {
|
||||
},
|
||||
start: () => 1,
|
||||
update: (_context, match) => (match.event.data as unknown as { value: number }).value,
|
||||
target: 'chat',
|
||||
buildViewNode: () => null,
|
||||
}
|
||||
const consumerStart = vi.fn((
|
||||
@@ -439,6 +470,7 @@ describe('ConversationNodeAssembler', () => {
|
||||
: null,
|
||||
start: consumerStart,
|
||||
update: context => context.state,
|
||||
target: 'chat',
|
||||
buildViewNode: context => node(context, context.state),
|
||||
}
|
||||
const assembler = new ConversationNodeAssembler(
|
||||
@@ -468,6 +500,7 @@ describe('ConversationNodeAssembler', () => {
|
||||
},
|
||||
start: () => 1,
|
||||
update: (_context, match) => (match.event.data as unknown as { value: number }).value,
|
||||
target: 'chat',
|
||||
buildViewNode: () => null,
|
||||
}
|
||||
const sourceX: ConversationNodeDefinition<number> = {
|
||||
@@ -479,6 +512,7 @@ describe('ConversationNodeAssembler', () => {
|
||||
},
|
||||
start: () => 10,
|
||||
update: (_context, match) => (match.event.data as unknown as { value: number }).value,
|
||||
target: 'chat',
|
||||
buildViewNode: () => null,
|
||||
}
|
||||
const middle: ConversationNodeDefinition<number> = {
|
||||
@@ -491,6 +525,7 @@ describe('ConversationNodeAssembler', () => {
|
||||
+ (reader.previous<number>('diamond-x')?.state ?? 0)
|
||||
),
|
||||
update: context => context.state,
|
||||
target: 'chat',
|
||||
buildViewNode: context => node(context, context.state),
|
||||
}
|
||||
const consumer: ConversationNodeDefinition<number> = {
|
||||
@@ -503,6 +538,7 @@ describe('ConversationNodeAssembler', () => {
|
||||
+ (reader.previous<number>('diamond-b')?.state ?? 0)
|
||||
),
|
||||
update: context => context.state,
|
||||
target: 'chat',
|
||||
buildViewNode: context => node(context, context.state),
|
||||
}
|
||||
const assembler = new ConversationNodeAssembler(
|
||||
@@ -538,6 +574,7 @@ describe('ConversationNodeAssembler', () => {
|
||||
: null,
|
||||
start: starts,
|
||||
update: context => context.state,
|
||||
target: 'chat',
|
||||
buildViewNode: context => node(context, context.state),
|
||||
}
|
||||
const assembler = new ConversationNodeAssembler(
|
||||
@@ -609,6 +646,7 @@ describe('ConversationNodeAssembler', () => {
|
||||
value: { valueSeenFromStep: stepValue ?? -1 },
|
||||
}
|
||||
},
|
||||
target: 'chat',
|
||||
buildViewNode: (context) => {
|
||||
const location = context.start?.location
|
||||
if (location?.kind !== 'step') return null
|
||||
@@ -646,6 +684,7 @@ describe('ConversationNodeAssembler', () => {
|
||||
: null,
|
||||
start: () => null,
|
||||
update: context => context.state,
|
||||
target: 'chat',
|
||||
buildViewNode: context => node(context, context.start?.location.kind === 'turn'
|
||||
? context.start.location.turn.steps.length
|
||||
: -1),
|
||||
@@ -701,6 +740,7 @@ describe('ConversationNodeAssembler', () => {
|
||||
},
|
||||
start: () => null,
|
||||
update: context => context.state,
|
||||
target: 'chat',
|
||||
buildViewNode: (context) => {
|
||||
const location = context.start?.location
|
||||
const data = location?.kind === 'step'
|
||||
@@ -734,6 +774,7 @@ describe('ConversationNodeAssembler', () => {
|
||||
: null,
|
||||
start: () => null,
|
||||
update: context => context.state,
|
||||
target: 'chat',
|
||||
buildViewNode: context => node(context, context.start?.location.kind),
|
||||
}
|
||||
const assembler = new ConversationNodeAssembler(
|
||||
@@ -760,6 +801,7 @@ describe('ConversationNodeAssembler', () => {
|
||||
: null,
|
||||
start: () => null,
|
||||
update: context => context.state,
|
||||
target: 'chat',
|
||||
buildViewNode: (context) => {
|
||||
const location = context.start?.location
|
||||
return node(context, location?.kind === 'step'
|
||||
@@ -792,6 +834,7 @@ describe('ConversationNodeAssembler', () => {
|
||||
: null,
|
||||
start: () => null,
|
||||
update: context => context.state,
|
||||
target: 'chat',
|
||||
buildViewNode: (context) => {
|
||||
const location = context.start?.location
|
||||
return node(context, location?.kind === 'step'
|
||||
@@ -826,6 +869,7 @@ describe('ConversationNodeAssembler', () => {
|
||||
: null,
|
||||
start: seen,
|
||||
update: context => context.state,
|
||||
target: 'chat',
|
||||
buildViewNode: context => node(context, context.state),
|
||||
}
|
||||
const assembler = new ConversationNodeAssembler(
|
||||
@@ -841,24 +885,64 @@ describe('ConversationNodeAssembler', () => {
|
||||
expect(seen).toHaveBeenCalledTimes(2)
|
||||
})
|
||||
|
||||
it('does not invoke the fallback when an ordinary non-rendering Definition claims an event', () => {
|
||||
it('invokes the fallback when only a State-only Definition claims an event', () => {
|
||||
const fallbackStart = vi.fn(() => 'fallback')
|
||||
const claimed: ConversationNodeDefinition<null> = {
|
||||
kind: 'claimed-state',
|
||||
match: event => (event.type as string) === 'command/run'
|
||||
? { id: 'claimed', role: 'start' }
|
||||
: null,
|
||||
start: () => null,
|
||||
update: context => context.state,
|
||||
}
|
||||
const assembler = new ConversationNodeAssembler(
|
||||
new TestEventDefinitions([claimed], fallbackDefinition(fallbackStart)),
|
||||
new TestViewDefinitions([testView()]),
|
||||
)
|
||||
|
||||
assembler.replaceWindow([input(at(1, 'command/run', { commandId: 'one', name: 'x' }))], false)
|
||||
assembler.flush()
|
||||
|
||||
expect(fallbackStart).toHaveBeenCalledOnce()
|
||||
expect(chatSnapshot(assembler)?.order).toHaveLength(1)
|
||||
})
|
||||
|
||||
it('invokes the fallback when only another target claims an event', () => {
|
||||
const fallbackStart = vi.fn(() => 'fallback')
|
||||
const claimed: ConversationNodeDefinition<null> = {
|
||||
kind: 'claimed-trajectory',
|
||||
target: 'trajectory',
|
||||
match: event => (event.type as string) === 'command/run'
|
||||
? { id: 'claimed', role: 'start' }
|
||||
: null,
|
||||
start: () => null,
|
||||
update: context => context.state,
|
||||
buildViewNode: () => null,
|
||||
}
|
||||
const assembler = new ConversationNodeAssembler(
|
||||
new TestEventDefinitions([claimed], fallbackDefinition(fallbackStart)),
|
||||
new TestViewDefinitions([testView()]),
|
||||
)
|
||||
|
||||
assembler.replaceWindow([input(at(1, 'command/run', { commandId: 'one', name: 'x' }))], false)
|
||||
assembler.flush()
|
||||
|
||||
expect(fallbackStart).toHaveBeenCalledOnce()
|
||||
expect(chatSnapshot(assembler)?.order).toHaveLength(1)
|
||||
})
|
||||
|
||||
it('suppresses the fallback when the same target claims an event', () => {
|
||||
const fallbackStart = vi.fn(() => 'fallback')
|
||||
const claimed: ConversationNodeDefinition<null> = {
|
||||
kind: 'claimed',
|
||||
target: 'chat',
|
||||
match: event => (event.type as string) === 'command/run' ? { id: 'claimed', role: 'start' } : null,
|
||||
start: () => null,
|
||||
update: context => context.state,
|
||||
buildViewNode: () => null,
|
||||
}
|
||||
const fallback: ConversationNodeDefinition<string> = {
|
||||
kind: 'fallback',
|
||||
match: event => ({ id: String(event.seq), role: 'start' }),
|
||||
start: fallbackStart,
|
||||
update: context => context.state,
|
||||
buildViewNode: context => node(context, context.state),
|
||||
}
|
||||
const assembler = new ConversationNodeAssembler(
|
||||
new TestEventDefinitions([claimed], fallback),
|
||||
new TestEventDefinitions([claimed], fallbackDefinition(fallbackStart)),
|
||||
new TestViewDefinitions([testView()]),
|
||||
)
|
||||
assembler.replaceWindow([input(at(1, 'command/run', { commandId: 'one', name: 'x' }))], false)
|
||||
@@ -878,6 +962,7 @@ describe('ConversationNodeAssembler', () => {
|
||||
},
|
||||
start: () => true,
|
||||
update: () => false,
|
||||
target: 'chat',
|
||||
buildViewNode: context => context.state === true ? node(context, true) : null,
|
||||
}
|
||||
const assembler = new ConversationNodeAssembler(
|
||||
@@ -900,6 +985,7 @@ describe('ConversationNodeAssembler', () => {
|
||||
match: event => (event.type as string) === 'command/run' ? { id: 'one', role: 'start' } : null,
|
||||
start: () => undefined,
|
||||
update: context => context.state,
|
||||
target: 'chat',
|
||||
buildViewNode: () => null,
|
||||
}
|
||||
const startAssembler = new ConversationNodeAssembler(
|
||||
@@ -919,6 +1005,7 @@ describe('ConversationNodeAssembler', () => {
|
||||
},
|
||||
start: () => true,
|
||||
update: () => undefined as never,
|
||||
target: 'chat',
|
||||
buildViewNode: context => node(context, context.state),
|
||||
}
|
||||
const updateAssembler = new ConversationNodeAssembler(
|
||||
@@ -939,6 +1026,7 @@ describe('ConversationNodeAssembler', () => {
|
||||
match: event => (event.type as string) === 'command/run' ? { id: 'one', role: 'start' } : null,
|
||||
start: (_context, match) => match.event.seq,
|
||||
update: context => context.state,
|
||||
target: 'chat',
|
||||
buildViewNode: context => node(context, context.state),
|
||||
}
|
||||
const assembler = new ConversationNodeAssembler(
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { Context } from 'cordis'
|
||||
import { Context } from '@deepseek-ai/cordis'
|
||||
import { describe, expect, it, vi } from 'vitest'
|
||||
import type { SessionId } from '@deepseek-ai/dsh-client-connection/client'
|
||||
import { ConversationEventRegistry } from '../src/client/conversation/event-registry.ts'
|
||||
@@ -13,6 +13,7 @@ import { FakeApiClient, ok } from './fake-api.ts'
|
||||
function eventDefinition(kind: string): ConversationNodeDefinition<null> {
|
||||
return {
|
||||
kind,
|
||||
target: 'chat',
|
||||
match: () => null,
|
||||
start: () => null,
|
||||
update: context => context.state,
|
||||
@@ -71,6 +72,40 @@ describe('Conversation registries', () => {
|
||||
expect(events.fallbackEntry()).toBeUndefined()
|
||||
})
|
||||
|
||||
it('rejects rendering Definitions that omit either target or builder', async () => {
|
||||
const { events } = await bootRegistries()
|
||||
const targetOnly: ConversationNodeDefinition<null> = {
|
||||
kind: 'target-only',
|
||||
target: 'chat',
|
||||
match: () => null,
|
||||
start: () => null,
|
||||
update: context => context.state,
|
||||
}
|
||||
const builderOnly: ConversationNodeDefinition<null> = {
|
||||
kind: 'builder-only',
|
||||
match: () => null,
|
||||
start: () => null,
|
||||
update: context => context.state,
|
||||
buildViewNode: () => null,
|
||||
}
|
||||
|
||||
expect(() => events.register(targetOnly)).toThrow(/target and buildViewNode together/)
|
||||
expect(() => events.register(builderOnly)).toThrow(/target and buildViewNode together/)
|
||||
})
|
||||
|
||||
it('rejects a State-only Definition as the unmatched-event fallback', async () => {
|
||||
const { events } = await bootRegistries()
|
||||
const fallback: ConversationNodeDefinition<null> = {
|
||||
kind: 'state-only-fallback',
|
||||
match: () => null,
|
||||
start: () => null,
|
||||
update: context => context.state,
|
||||
}
|
||||
|
||||
expect(() => events.registerFallback(fallback))
|
||||
.toThrow('conversation fallback Definition must declare a target')
|
||||
})
|
||||
|
||||
it('rejects duplicate view targets and disposes a view registration once', async () => {
|
||||
const { views } = await bootRegistries()
|
||||
const definition = viewDefinition('chat')
|
||||
|
||||
@@ -1,22 +1,30 @@
|
||||
/** Assistant block classifier (moved here with sessions/conversation.ts). */
|
||||
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { AttachmentId } from '@deepseek-ai/dsh-attachment'
|
||||
import type { ContentBlock } from '@deepseek-ai/dsh-client-connection/client'
|
||||
import { toAssistantBlock, toAssistantBlocks } from '../src/client/sessions/conversation.ts'
|
||||
|
||||
describe('toAssistantBlock', () => {
|
||||
it('classifies the four block shapes', () => {
|
||||
const attachment = {
|
||||
attachmentId: AttachmentId(`sha256:${'a'.repeat(64)}`),
|
||||
mediaType: 'image/png' as const,
|
||||
bytes: 68,
|
||||
width: 1,
|
||||
height: 1,
|
||||
}
|
||||
const blocks: ContentBlock[] = [
|
||||
{ type: 'text', text: '正文' },
|
||||
{ type: 'reasoning', text: '思考' },
|
||||
{ type: 'tool-call', id: 'c1', name: 'echo', arguments: '{}' } as ContentBlock,
|
||||
{ type: 'image', data: 'x' } as unknown as ContentBlock,
|
||||
{ type: 'image', attachment },
|
||||
]
|
||||
expect(toAssistantBlocks(blocks)).toEqual([
|
||||
{ kind: 'text', text: '正文' },
|
||||
{ kind: 'reasoning', text: '思考' },
|
||||
{ kind: 'tool-call', callId: 'c1', name: 'echo', argsRaw: '{}' },
|
||||
{ kind: 'other', block: blocks[3] },
|
||||
{ kind: 'image', attachment },
|
||||
])
|
||||
expect(toAssistantBlock(blocks[0] as ContentBlock)).toEqual({ kind: 'text', text: '正文' })
|
||||
})
|
||||
|
||||
@@ -85,6 +85,8 @@ export class FakeApiClient implements IApiClient {
|
||||
Promise<RpcResponse<{ selected: ModelSelection }>> =
|
||||
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 }))
|
||||
onAttachment: (payload: unknown) => Promise<RpcResponse<{ attachment: { attachmentId: never; mediaType: 'image/png'; bytes: number; width: number; height: number }; data: string }>> =
|
||||
() => Promise.resolve(ok({ attachment: { attachmentId: 'a' as never, mediaType: 'image/png', bytes: 1, width: 1, height: 1 }, data: 'AA==' }))
|
||||
onUpdateQueue: (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 }))
|
||||
|
||||
@@ -129,6 +131,7 @@ export class FakeApiClient implements IApiClient {
|
||||
rename: (payload: unknown) => this.record('session.rename', payload, this.onRename(payload)),
|
||||
fork: (payload: unknown) => this.record('session.fork', payload, this.onFork(payload)),
|
||||
prompt: (payload: unknown) => this.record('session.prompt', payload, this.onPrompt(payload)),
|
||||
attachment: (payload: unknown) => this.record('session.attachment', payload, this.onAttachment(payload)),
|
||||
updateQueue: (payload: unknown) => this.record('session.updateQueue', payload, this.onUpdateQueue(payload)),
|
||||
cancel: (payload: unknown) => this.record('session.cancel', payload, this.onCancel(payload)),
|
||||
}
|
||||
|
||||
@@ -1,232 +0,0 @@
|
||||
import { createMessage, createUserMessage } from '@deepseek-ai/dsh-llm'
|
||||
import type { SessionEvent } from '@deepseek-ai/dsh-session/types'
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { projectConversationHistory } from '../src/client/session-history/history-fold.ts'
|
||||
import { compactHistoryInspectionEntries } from '../src/client/sessions/history.ts'
|
||||
import { inspectRequests } from '../src/client/sessions/request-inspection.ts'
|
||||
import { ev } from './event-script.ts'
|
||||
|
||||
const at = (seq: number, event: Record<string, unknown>): SessionEvent =>
|
||||
({ seq, time: 1_700_000_000_000 + seq, ...event }) as unknown as SessionEvent
|
||||
|
||||
describe('projectConversationHistory', () => {
|
||||
it('names an injected context node from its durable source, like the live adapter', () => {
|
||||
// The fold declares its own node mapping (jscpd:ignore in the source), so
|
||||
// the source projection is pinned on both sides independently.
|
||||
const injected = at(0, {
|
||||
type: 'user/message',
|
||||
surfaceOp: 'append',
|
||||
data: createUserMessage({
|
||||
content: [{ type: 'text', text: '<available_skills>…</available_skills>' }],
|
||||
// A plugin source, because the client program does not see the host
|
||||
// packages that merge richer source kinds; those arms are pinned in
|
||||
// context-provenance.spec.ts.
|
||||
source: { kind: 'plugin', plugin: 'dsh-tool-skill', form: 'catalog' },
|
||||
}),
|
||||
})
|
||||
const { contexts } = projectConversationHistory([{ event: injected }])
|
||||
expect(contexts[contexts.length - 1]?.nodes).toMatchObject([{
|
||||
kind: 'context',
|
||||
seq: 0,
|
||||
provenance: { role: 'inject', label: 'dsh-tool-skill' },
|
||||
form: 'catalog',
|
||||
}])
|
||||
})
|
||||
|
||||
it('projects next-step human input as durable steering', () => {
|
||||
const steering = createUserMessage({
|
||||
content: [{ type: 'text', text: 'change course' }],
|
||||
source: { kind: 'user' },
|
||||
})
|
||||
const events = [
|
||||
at(0, { type: 'agent/inbox/spliced', data: {
|
||||
target: 'next-step', start: 0, inserted: [steering],
|
||||
} }),
|
||||
at(1, { type: 'agent/inbox/spliced', data: {
|
||||
target: 'next-step', start: 0, removedCount: 1, inserted: [],
|
||||
} }),
|
||||
at(2, { type: 'user/message', surfaceOp: 'append', data: steering }),
|
||||
]
|
||||
const projection = projectConversationHistory(events.map(event => ({ event })))
|
||||
expect(projection.eventNodes).toMatchObject([{
|
||||
kind: 'steering', messageId: steering.id, seq: 2,
|
||||
}])
|
||||
})
|
||||
|
||||
it('projects a high-sequence history window without synthesizing its unloaded prefix', () => {
|
||||
const baseSeq = 400_000
|
||||
const events = [
|
||||
ev.user(baseSeq, 'loaded tail'),
|
||||
at(baseSeq + 1, {
|
||||
type: 'assistant/message',
|
||||
surfaceOp: { op: 'replace', start: baseSeq, end: baseSeq },
|
||||
sourceEventSeqs: [baseSeq],
|
||||
data: {
|
||||
turn: 80,
|
||||
step: 1,
|
||||
message: createMessage({
|
||||
role: 'assistant',
|
||||
content: [{ type: 'text', text: 'tail summary' }],
|
||||
source: { kind: 'model', provider: 'fake', model: 'fake' },
|
||||
}),
|
||||
},
|
||||
}),
|
||||
]
|
||||
|
||||
const projection = projectConversationHistory(events.map(event => ({ event })))
|
||||
expect(projection.eventNodes.map(node => node.seq)).toEqual([baseSeq, baseSeq + 1])
|
||||
expect(projection.contexts.map(context => ({
|
||||
originSeq: context.originSeq,
|
||||
nodes: context.nodes.map(node => node.seq),
|
||||
}))).toEqual([
|
||||
{ originSeq: undefined, nodes: [baseSeq] },
|
||||
{ originSeq: baseSeq + 1, nodes: [baseSeq + 1] },
|
||||
])
|
||||
})
|
||||
|
||||
it('projects frozen surface generations without widening the core live surface', () => {
|
||||
const events = [
|
||||
ev.user(0, 'a'),
|
||||
ev.user(1, 'b'),
|
||||
at(2, {
|
||||
type: 'assistant/message',
|
||||
surfaceOp: { op: 'replace', start: 0, end: 0 },
|
||||
sourceEventSeqs: [0],
|
||||
data: {
|
||||
turn: 1,
|
||||
step: 1,
|
||||
message: createMessage({
|
||||
role: 'assistant',
|
||||
content: [{ type: 'text', text: 'summary' }],
|
||||
source: { kind: 'model', provider: 'fake', model: 'fake' },
|
||||
}),
|
||||
},
|
||||
}),
|
||||
at(3, {
|
||||
type: 'assistant/message',
|
||||
surfaceOp: { op: 'replace', start: 2, end: 1 },
|
||||
sourceEventSeqs: [2, 1],
|
||||
data: {
|
||||
turn: 1,
|
||||
step: 2,
|
||||
message: createMessage({
|
||||
role: 'assistant',
|
||||
content: [{ type: 'text', text: 'summary 2' }],
|
||||
source: { kind: 'model', provider: 'fake', model: 'fake' },
|
||||
}),
|
||||
},
|
||||
}),
|
||||
]
|
||||
|
||||
expect(projectConversationHistory(events.map(event => ({ event }))).contexts.map(context => ({
|
||||
id: context.id,
|
||||
parentId: context.parentId,
|
||||
originSeq: context.originSeq,
|
||||
nodes: context.nodes.map(node => node.seq),
|
||||
}))).toEqual([
|
||||
{ id: 0, parentId: undefined, originSeq: undefined, nodes: [0, 1] },
|
||||
{ id: 1, parentId: 0, originSeq: 2, nodes: [2, 1] },
|
||||
{ id: 2, parentId: 1, originSeq: 3, nodes: [3] },
|
||||
])
|
||||
})
|
||||
|
||||
it('projects assistant timing and the active request header from history', () => {
|
||||
const projection = projectConversationHistory([
|
||||
ev.stepStart(0, 1, 2),
|
||||
at(1, { type: 'request/header', data: {
|
||||
reason: 'initial',
|
||||
header: {
|
||||
config: { provider: 'fake', model: 'first' },
|
||||
tools: [],
|
||||
},
|
||||
} }),
|
||||
ev.chunkStart(2, 1, 2),
|
||||
ev.chunkText(3, 1, 'token', 2),
|
||||
ev.assistant(4, 1, 'done', 2),
|
||||
ev.stepStart(5, 2, 1),
|
||||
ev.chunkText(6, 2, 'next', 1),
|
||||
ev.assistant(7, 2, 'next done', 1),
|
||||
].map(event => ({ event })))
|
||||
|
||||
expect(projection.eventNodes[0]).toMatchObject({
|
||||
kind: 'assistant',
|
||||
timing: {
|
||||
stepStartTime: 1_700_000_000_000,
|
||||
firstTokenTime: 1_700_000_000_003,
|
||||
completedTime: 1_700_000_000_004,
|
||||
},
|
||||
requestConfig: { provider: 'fake', model: 'first' },
|
||||
})
|
||||
|
||||
expect(projection.eventNodes.at(-1)).toMatchObject({
|
||||
timing: {
|
||||
stepStartTime: 1_700_000_000_005,
|
||||
firstTokenTime: 1_700_000_000_006,
|
||||
completedTime: 1_700_000_000_007,
|
||||
},
|
||||
requestConfig: { provider: 'fake', model: 'first' },
|
||||
})
|
||||
})
|
||||
|
||||
it('projects nested dispatches onto settled and interrupted history calls', () => {
|
||||
const projection = projectConversationHistory([
|
||||
ev.turnStart(0, 1),
|
||||
ev.toolCall(1, 1, 'settled', 'run_code', '{}'),
|
||||
ev.codeDispatchStart(2, 'settled', 1, 'run_code', { code: 'nested' }),
|
||||
ev.codeDispatchStart(3, 'settled:code:1', 1, 'read', { path: 'a.txt' }),
|
||||
ev.codeDispatch(4, 'settled:code:1', 1, 'read', { path: 'a.txt' }, 'alpha'),
|
||||
ev.codeDispatch(5, 'settled', 1, 'run_code', { code: 'nested' }, 'alpha'),
|
||||
ev.toolResult(6, 1, 'settled', 'done'),
|
||||
ev.turnEnd(7, 1),
|
||||
ev.turnStart(8, 2),
|
||||
ev.toolCall(9, 2, 'interrupted', 'run_code', '{}'),
|
||||
ev.codeDispatchStart(10, 'interrupted', 1, 'bash', { command: 'sleep 1' }),
|
||||
ev.turnEnd(11, 2, 'aborted'),
|
||||
].map(event => ({ event })))
|
||||
|
||||
const settled = {
|
||||
callId: 'settled',
|
||||
subCalls: [{
|
||||
callId: 'settled:code:1',
|
||||
subCalls: [{ callId: 'settled:code:1:code:1', call: { name: 'read' } }],
|
||||
}],
|
||||
}
|
||||
expect(projection.eventNodes).toMatchObject([settled])
|
||||
expect(projection.contexts[0]?.nodes).toMatchObject([settled])
|
||||
expect(projection.interruptedNodes).toMatchObject([{
|
||||
callId: 'interrupted',
|
||||
subCalls: [{ callId: 'interrupted:code:1', name: 'bash' }],
|
||||
}])
|
||||
})
|
||||
|
||||
it('drops completed token payloads without changing inspection projections', () => {
|
||||
const events = [
|
||||
ev.user(0, 'before'),
|
||||
ev.stepStart(1, 1, 0),
|
||||
ev.chunkStart(2, 1),
|
||||
ev.chunkText(3, 1, ''),
|
||||
ev.chunkText(4, 1, 'first'),
|
||||
ev.chunkText(5, 1, ' discarded'),
|
||||
at(6, { type: 'assistant/chunk', data: {
|
||||
turn: 1,
|
||||
step: 0,
|
||||
chunk: { type: 'usage', usage: { inputTokens: 4, outputTokens: 2 } },
|
||||
} }),
|
||||
ev.assistant(7, 1, 'first discarded'),
|
||||
ev.compactSummary(8, 'summary', 0, 7),
|
||||
ev.compactCheckpoint(9, 8, 0, 7),
|
||||
ev.stepStart(10, 2, 0),
|
||||
ev.chunkStart(11, 2),
|
||||
ev.chunkText(12, 2, 'interrupted'),
|
||||
ev.turnEnd(13, 2, 'aborted'),
|
||||
]
|
||||
const raw = events.map(event => ({ event }))
|
||||
const compacted = compactHistoryInspectionEntries(raw)
|
||||
|
||||
expect(compacted.map(entry => entry.event.seq)).toEqual([
|
||||
0, 1, 4, 6, 7, 8, 9, 10, 11, 12, 13,
|
||||
])
|
||||
expect(projectConversationHistory(compacted)).toEqual(projectConversationHistory(raw))
|
||||
expect(inspectRequests(compacted)).toEqual(inspectRequests(raw))
|
||||
})
|
||||
})
|
||||
@@ -3,7 +3,7 @@
|
||||
* a fired key must already carry a bumped version (emission follows the
|
||||
* applied mutation), bogus payloads fail loud, foreign events pass.
|
||||
*/
|
||||
import { Context } from 'cordis'
|
||||
import { Context } from '@deepseek-ai/cordis'
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import InvariantService from '@deepseek-ai/dsh-invariants'
|
||||
import * as RuntimeInvariant from '../src/invariant.ts'
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
/** Node half: the empty host apply (Loader governance + dshClient discovery placeholder). */
|
||||
/** Node half: the empty host apply (Loader governance + dsh.client discovery placeholder). */
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { apply } from '../src/index.ts'
|
||||
|
||||
|
||||
@@ -1,319 +0,0 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import type { HistoryEntry } from '@deepseek-ai/dsh-client-connection/client'
|
||||
import { createAssistantMessage, createUserMessage } from '@deepseek-ai/dsh-llm'
|
||||
import type { SessionEvent } from '@deepseek-ai/dsh-session/types'
|
||||
import { inspectRequests } from '../src/client/sessions/request-inspection.ts'
|
||||
|
||||
const at = (seq: number, type: string, data: unknown): SessionEvent =>
|
||||
({ seq, time: 1_700_000_000_000 + seq, type, data }) as SessionEvent
|
||||
|
||||
const entriesOf = (events: readonly SessionEvent[]): HistoryEntry[] =>
|
||||
events.map(event => ({ event }))
|
||||
|
||||
describe('inspectRequests', () => {
|
||||
it('projects ordinary and compaction calls into one chronological request stream', () => {
|
||||
const events = [
|
||||
at(0, 'step/start', { turn: 1, step: 1 }),
|
||||
at(1, 'request/header', {
|
||||
reason: 'initial',
|
||||
header: {
|
||||
config: { provider: 'fake', model: 'model' },
|
||||
system: 'system',
|
||||
tools: [{
|
||||
name: 'read',
|
||||
description: 'Read a file.',
|
||||
parameters: { type: 'object' },
|
||||
}],
|
||||
},
|
||||
}),
|
||||
at(2, 'tool/call', {
|
||||
turn: 1,
|
||||
step: 1,
|
||||
callId: 'call-1',
|
||||
name: 'read',
|
||||
arguments: '{}',
|
||||
}),
|
||||
at(3, 'assistant/message', {
|
||||
turn: 1,
|
||||
step: 1,
|
||||
message: createAssistantMessage({
|
||||
content: [{ type: 'text', text: 'done' }],
|
||||
source: { provider: 'fake', model: 'model' },
|
||||
}),
|
||||
usage: { inputTokens: 5, outputTokens: 2 },
|
||||
}),
|
||||
at(4, 'step/end', { turn: 1, step: 1 }),
|
||||
at(5, 'compact/start', { turn: 1 }),
|
||||
at(6, 'compact/summary', {
|
||||
summary: [{ type: 'text', text: 'summary' }],
|
||||
rawOutput: [
|
||||
{ type: 'reasoning', text: 'thought' },
|
||||
{ type: 'text', text: 'summary' },
|
||||
],
|
||||
provider: 'fake',
|
||||
model: 'compact-model',
|
||||
usage: { inputTokens: 8, outputTokens: 3 },
|
||||
}),
|
||||
at(7, 'user/message', createUserMessage({
|
||||
content: [{ type: 'text', text: 'checkpoint' }],
|
||||
source: { kind: 'plugin', plugin: 'compact' },
|
||||
})),
|
||||
at(8, 'compact/end', { turn: 1 }),
|
||||
]
|
||||
const snapshot = inspectRequests(entriesOf(events))
|
||||
expect(snapshot.requests).toMatchObject([
|
||||
{
|
||||
purpose: 'assistant',
|
||||
startSeq: 0,
|
||||
resultSeq: 3,
|
||||
status: 'complete',
|
||||
prompt: {
|
||||
config: { provider: 'fake', model: 'model' },
|
||||
system: 'system',
|
||||
},
|
||||
promptChange: { seq: 1, kind: 'initial' },
|
||||
},
|
||||
{
|
||||
purpose: 'compaction',
|
||||
startSeq: 5,
|
||||
resultSeq: 6,
|
||||
replacementSeq: 7,
|
||||
status: 'complete',
|
||||
summary: [{ type: 'text', text: 'summary' }],
|
||||
},
|
||||
])
|
||||
expect(snapshot.callSchemas.get('call-1')?.name).toBe('read')
|
||||
})
|
||||
|
||||
it('does not promote a truncated resume or change header to the initial prompt', () => {
|
||||
for (const reason of ['resume', 'change'] as const) {
|
||||
const snapshot = inspectRequests(entriesOf([
|
||||
at(10, 'step/start', { turn: 3, step: 1 }),
|
||||
at(11, 'request/header', {
|
||||
reason,
|
||||
header: {
|
||||
config: { provider: 'fake', model: 'model' },
|
||||
system: 'tail-window prompt',
|
||||
},
|
||||
}),
|
||||
]))
|
||||
|
||||
expect(snapshot.requests[0]).toMatchObject({
|
||||
purpose: 'assistant',
|
||||
prompt: { system: 'tail-window prompt' },
|
||||
})
|
||||
expect(snapshot.requests[0]).not.toHaveProperty('promptChange')
|
||||
}
|
||||
})
|
||||
|
||||
it('classifies a prompt change once the preceding header is loaded', () => {
|
||||
const snapshot = inspectRequests(entriesOf([
|
||||
at(0, 'step/start', { turn: 1, step: 1 }),
|
||||
at(1, 'request/header', {
|
||||
reason: 'initial',
|
||||
header: {
|
||||
config: { provider: 'fake', model: 'model' },
|
||||
system: 'before',
|
||||
},
|
||||
}),
|
||||
at(2, 'step/start', { turn: 1, step: 2 }),
|
||||
at(3, 'request/header', {
|
||||
reason: 'change',
|
||||
header: {
|
||||
config: { provider: 'fake', model: 'model' },
|
||||
system: 'after',
|
||||
},
|
||||
}),
|
||||
]))
|
||||
|
||||
expect(snapshot.requests[1]).toMatchObject({
|
||||
promptChange: {
|
||||
seq: 3,
|
||||
kind: 'system',
|
||||
previous: { system: 'before' },
|
||||
},
|
||||
})
|
||||
})
|
||||
|
||||
it('preserves a standalone compaction owner without widening assistant turns', () => {
|
||||
const snapshot = inspectRequests(entriesOf([
|
||||
at(0, 'compact/start', { turn: null }),
|
||||
at(1, 'compact/summary', {
|
||||
summary: [{ type: 'text', text: 'standalone summary' }],
|
||||
provider: 'fake',
|
||||
model: 'compact-model',
|
||||
}),
|
||||
at(2, 'compact/end', { turn: null }),
|
||||
at(3, 'step/start', { turn: 2, step: 1 }),
|
||||
]))
|
||||
|
||||
const [compaction, assistant] = snapshot.requests
|
||||
expect(compaction).toMatchObject({
|
||||
purpose: 'compaction',
|
||||
turn: null,
|
||||
step: 0,
|
||||
status: 'complete',
|
||||
})
|
||||
expect(assistant).toMatchObject({
|
||||
purpose: 'assistant',
|
||||
turn: 2,
|
||||
step: 1,
|
||||
status: 'running',
|
||||
})
|
||||
if (assistant?.purpose === 'assistant') {
|
||||
const turn: number = assistant.turn
|
||||
expect(turn).toBe(2)
|
||||
}
|
||||
})
|
||||
|
||||
it('interrupts an orphaned compaction at end-seed before projecting a new attempt', () => {
|
||||
const snapshot = inspectRequests(entriesOf([
|
||||
at(0, 'compact/start', { turn: null }),
|
||||
at(1, 'session/end-seed', {}),
|
||||
at(2, 'compact/start', { turn: null }),
|
||||
at(3, 'compact/summary', {
|
||||
summary: [{ type: 'text', text: 'replacement summary' }],
|
||||
provider: 'fake',
|
||||
model: 'compact-model',
|
||||
}),
|
||||
at(4, 'compact/end', { turn: null }),
|
||||
]))
|
||||
|
||||
expect(snapshot.requests).toMatchObject([
|
||||
{
|
||||
purpose: 'compaction',
|
||||
startSeq: 0,
|
||||
status: 'error',
|
||||
completedAt: 1_700_000_000_001,
|
||||
error: 'Compaction was interrupted before completion.',
|
||||
},
|
||||
{
|
||||
purpose: 'compaction',
|
||||
startSeq: 2,
|
||||
status: 'complete',
|
||||
completedAt: 1_700_000_000_004,
|
||||
summary: [{ type: 'text', text: 'replacement summary' }],
|
||||
},
|
||||
])
|
||||
})
|
||||
|
||||
it('captures schemas for nested tool dispatches from the active request header', () => {
|
||||
const snapshot = inspectRequests(entriesOf([
|
||||
at(0, 'request/header', {
|
||||
reason: 'initial',
|
||||
header: {
|
||||
config: { provider: 'fake', model: 'model' },
|
||||
tools: [{
|
||||
name: 'read',
|
||||
description: 'Read a file.',
|
||||
parameters: { type: 'object' },
|
||||
}],
|
||||
},
|
||||
}),
|
||||
at(1, 'tool/code-dispatch-start', {
|
||||
parentCallId: 'parent',
|
||||
subCallId: 'nested',
|
||||
name: 'read',
|
||||
arguments: {},
|
||||
}),
|
||||
]))
|
||||
|
||||
expect(snapshot.callSchemas.get('nested')?.name).toBe('read')
|
||||
})
|
||||
|
||||
it('keeps chunk-reported usage through request failure and prefers it to message fallback', () => {
|
||||
const chunkUsage = { inputTokens: 21, outputTokens: 3 }
|
||||
const retryUsage = {
|
||||
inputTokens: 5,
|
||||
outputTokens: 2,
|
||||
cacheReadTokens: 8,
|
||||
reasoningTokens: 1,
|
||||
}
|
||||
const snapshot = inspectRequests(entriesOf([
|
||||
at(0, 'step/start', { turn: 1, step: 1 }),
|
||||
at(1, 'assistant/chunk', {
|
||||
turn: 1,
|
||||
step: 1,
|
||||
chunk: { type: 'usage', usage: chunkUsage },
|
||||
}),
|
||||
at(2, 'llm/retry', {
|
||||
turn: 1,
|
||||
step: 1,
|
||||
retry: 1,
|
||||
maxRetries: 2,
|
||||
delayMs: 100,
|
||||
failure: { message: 'rate limited' },
|
||||
}),
|
||||
at(3, 'assistant/chunk', {
|
||||
turn: 1,
|
||||
step: 1,
|
||||
chunk: { type: 'usage', usage: retryUsage },
|
||||
}),
|
||||
at(4, 'assistant/message', {
|
||||
turn: 1,
|
||||
step: 1,
|
||||
message: createAssistantMessage({
|
||||
content: [{ type: 'text', text: 'recovered' }],
|
||||
source: { provider: 'fake', model: 'model' },
|
||||
}),
|
||||
usage: { inputTokens: 1, outputTokens: 1 },
|
||||
}),
|
||||
]))
|
||||
|
||||
expect(snapshot.requests[0]).toMatchObject({
|
||||
status: 'complete',
|
||||
usage: {
|
||||
inputTokens: 26,
|
||||
outputTokens: 5,
|
||||
cacheReadTokens: 8,
|
||||
reasoningTokens: 1,
|
||||
},
|
||||
})
|
||||
})
|
||||
|
||||
it('keeps provider credential fragments out of projected request errors', () => {
|
||||
const snapshot = inspectRequests(entriesOf([
|
||||
at(0, 'step/start', { turn: 1, step: 1 }),
|
||||
at(1, 'turn/end', {
|
||||
turn: 1, reason: { kind: 'error', error: {
|
||||
code: 'AUTH',
|
||||
message: 'Authentication Fails, Your api key: sk-preview-secret is invalid',
|
||||
},
|
||||
},
|
||||
}),
|
||||
at(2, 'step/start', { turn: 2, step: 1 }),
|
||||
at(3, 'turn/end', {
|
||||
turn: 2, reason: { kind: 'error', error: { message: 'plugin exploded', code: 'UNKNOWN' } },
|
||||
}),
|
||||
]))
|
||||
|
||||
expect(snapshot.requests).toMatchObject([
|
||||
{ status: 'error', error: 'API key is invalid' },
|
||||
{ status: 'error', error: 'plugin exploded' },
|
||||
])
|
||||
})
|
||||
|
||||
it('treats a scrubbed durable-fixture tool catalog as unavailable', () => {
|
||||
const snapshot = inspectRequests(entriesOf([
|
||||
at(0, 'step/start', { turn: 1, step: 1 }),
|
||||
at(1, 'request/header', {
|
||||
reason: 'initial',
|
||||
header: {
|
||||
config: { provider: 'fake', model: 'model' },
|
||||
tools: '{{tools}}',
|
||||
},
|
||||
}),
|
||||
at(2, 'tool/call', {
|
||||
turn: 1,
|
||||
step: 1,
|
||||
callId: 'call-1',
|
||||
name: 'read',
|
||||
arguments: '{}',
|
||||
}),
|
||||
]))
|
||||
|
||||
expect(snapshot.callSchemas).toEqual(new Map())
|
||||
const [request] = snapshot.requests
|
||||
expect(request?.purpose === 'assistant' ? request.prompt?.tools : undefined).toEqual([])
|
||||
})
|
||||
})
|
||||
@@ -6,14 +6,14 @@
|
||||
* and a subject-less root dispatch stays unfiltered. Scope-owned listeners
|
||||
* dispose with the fiber.
|
||||
*/
|
||||
import { Context } from 'cordis'
|
||||
import { Context } from '@deepseek-ai/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' {
|
||||
declare module '@deepseek-ai/cordis' {
|
||||
interface Events {
|
||||
/**
|
||||
* Test-only routed probe event.
|
||||
|
||||
@@ -1,180 +0,0 @@
|
||||
import { afterEach, describe, expect, it, vi } from 'vitest'
|
||||
import type { SessionEvent } from '@deepseek-ai/dsh-session/types'
|
||||
import type { SessionId } from '@deepseek-ai/dsh-client-connection/client'
|
||||
import { SessionHistorySource } from '../src/client/session-history/source.ts'
|
||||
import { FakeApiClient, deferred, err, ok } from './fake-api.ts'
|
||||
import { entries, ev, plainTurn } from './event-script.ts'
|
||||
|
||||
const SID = 'history-s1' as SessionId
|
||||
|
||||
afterEach(() => {
|
||||
vi.unstubAllGlobals()
|
||||
})
|
||||
|
||||
function histResponse(events: SessionEvent[], hasMore = false) {
|
||||
return Promise.resolve(ok({ events: entries(events) as never[], hasMore }))
|
||||
}
|
||||
|
||||
describe('SessionHistorySource', () => {
|
||||
it('loads the tail first and prepends older pages on demand', async () => {
|
||||
const pages = [
|
||||
plainTurn(0, 0, '最早问', '最早答'),
|
||||
plainTurn(6, 1, '中间问', '中间答'),
|
||||
plainTurn(12, 2, '最新问', '最新答'),
|
||||
]
|
||||
const api = new FakeApiClient()
|
||||
api.onHistory = (payload) => {
|
||||
if (payload.beforeSeq === undefined) return histResponse(pages[2]!, true)
|
||||
if (payload.beforeSeq === 12) return histResponse(pages[1]!, true)
|
||||
return histResponse(pages[0]!, false)
|
||||
}
|
||||
const source = new SessionHistorySource(SID, api)
|
||||
|
||||
await source.loadTail()
|
||||
|
||||
expect(api.callsOf('session.history')).toHaveLength(1)
|
||||
expect(source.getSnapshot().hasMore).toBe(true)
|
||||
expect(source.getSnapshot().baseSeq).toBe(12)
|
||||
expect(source.getSnapshot().inspection.eventNodes.map(node => node.seq))
|
||||
.toEqual([13, 15])
|
||||
|
||||
expect(await source.loadOlder()).toBe(true)
|
||||
expect(await source.loadOlder()).toBe(true)
|
||||
expect(await source.loadOlder()).toBe(false)
|
||||
|
||||
expect(api.callsOf('session.history')).toHaveLength(3)
|
||||
expect(source.getSnapshot().hasMore).toBe(false)
|
||||
expect(source.getSnapshot().baseSeq).toBe(0)
|
||||
expect(source.getSnapshot().inspection.eventNodes.map(node => node.seq))
|
||||
.toEqual([1, 3, 7, 9, 13, 15])
|
||||
})
|
||||
|
||||
it('pins a lazy inspection to the entries in its source snapshot', async () => {
|
||||
const api = new FakeApiClient()
|
||||
api.onHistory = () => histResponse(plainTurn(0, 0, '问', '答'))
|
||||
const source = new SessionHistorySource(SID, api)
|
||||
await source.loadTail()
|
||||
const before = source.getSnapshot()
|
||||
|
||||
source.handleMuxFrame({
|
||||
type: 'session/event',
|
||||
sessionId: SID,
|
||||
event: ev.user(6, 'later'),
|
||||
})
|
||||
|
||||
expect(before.inspection.eventNodes.map(node => node.seq)).toEqual([1, 3])
|
||||
expect(source.getSnapshot().inspection.eventNodes.map(node => node.seq))
|
||||
.toEqual([1, 3, 6])
|
||||
})
|
||||
|
||||
it('publishes multiple assistant chunks once per browser frame', async () => {
|
||||
const api = new FakeApiClient()
|
||||
api.onHistory = () => histResponse(plainTurn(0, 0, '问', '答'))
|
||||
const source = new SessionHistorySource(SID, api)
|
||||
await source.loadTail()
|
||||
const frames: FrameRequestCallback[] = []
|
||||
vi.stubGlobal('requestAnimationFrame', (callback: FrameRequestCallback) => {
|
||||
frames.push(callback)
|
||||
return frames.length
|
||||
})
|
||||
let notifications = 0
|
||||
const unsubscribe = source.subscribe(() => { notifications++ })
|
||||
const before = source.getSnapshot().inspection
|
||||
const finalizedNodes = before.eventNodes
|
||||
const requests = before.requests
|
||||
const contexts = before.contexts
|
||||
|
||||
for (const event of [
|
||||
ev.chunkStart(6, 1),
|
||||
ev.chunkText(7, 1, 'stream '),
|
||||
ev.chunkText(8, 1, 'content'),
|
||||
]) {
|
||||
source.handleMuxFrame({
|
||||
type: 'session/event',
|
||||
sessionId: SID,
|
||||
event,
|
||||
})
|
||||
}
|
||||
|
||||
expect(frames).toHaveLength(1)
|
||||
expect(notifications).toBe(0)
|
||||
frames[0]?.(0)
|
||||
await Promise.resolve()
|
||||
|
||||
expect(notifications).toBe(1)
|
||||
const streamed = source.getSnapshot().inspection
|
||||
expect(streamed.eventNodes).toBe(finalizedNodes)
|
||||
expect(streamed.requests).toBe(requests)
|
||||
expect(streamed.contexts).toBe(contexts)
|
||||
expect(streamed.partial?.blocks).toEqual([
|
||||
{ kind: 'text', text: 'stream content' },
|
||||
])
|
||||
|
||||
source.handleMuxFrame({
|
||||
type: 'session/event',
|
||||
sessionId: SID,
|
||||
event: ev.chunkText(9, 1, ' then final'),
|
||||
})
|
||||
source.handleMuxFrame({
|
||||
type: 'session/event',
|
||||
sessionId: SID,
|
||||
event: ev.assistant(10, 1, 'stream content then final'),
|
||||
})
|
||||
await Promise.resolve()
|
||||
|
||||
expect(notifications).toBe(2)
|
||||
const finalized = source.getSnapshot().inspection
|
||||
expect(finalized.eventNodes).not.toBe(finalizedNodes)
|
||||
expect(finalized.partial).toBeNull()
|
||||
frames[1]?.(0)
|
||||
await Promise.resolve()
|
||||
expect(notifications).toBe(2)
|
||||
unsubscribe()
|
||||
})
|
||||
|
||||
it('stops loading when an older page fails to advance', async () => {
|
||||
const api = new FakeApiClient()
|
||||
api.onHistory = payload => payload.beforeSeq === undefined
|
||||
? histResponse(plainTurn(6, 1, '新问', '新答'), true)
|
||||
: Promise.resolve(err({
|
||||
code: 'internal',
|
||||
message: 'page unavailable',
|
||||
details: {},
|
||||
}))
|
||||
const source = new SessionHistorySource(SID, api)
|
||||
|
||||
await source.loadTail()
|
||||
expect(await source.loadOlder()).toBe(false)
|
||||
|
||||
expect(api.callsOf('session.history')).toHaveLength(2)
|
||||
expect(source.getSnapshot().hasMore).toBe(true)
|
||||
})
|
||||
|
||||
it('finishes an already started older page after consumer cancellation', async () => {
|
||||
const middle = deferred<Awaited<ReturnType<FakeApiClient['onHistory']>>>()
|
||||
const olderStarted = deferred<undefined>()
|
||||
const api = new FakeApiClient()
|
||||
api.onHistory = (payload) => {
|
||||
if (payload.beforeSeq === undefined) {
|
||||
return histResponse(plainTurn(12, 2, '最新问', '最新答'), true)
|
||||
}
|
||||
olderStarted.resolve(undefined)
|
||||
return middle.promise
|
||||
}
|
||||
const source = new SessionHistorySource(SID, api)
|
||||
const controller = new AbortController()
|
||||
await source.loadTail(controller.signal)
|
||||
const complete = source.loadOlder(controller.signal)
|
||||
await olderStarted.promise
|
||||
controller.abort()
|
||||
middle.resolve(ok({
|
||||
events: entries(plainTurn(6, 1, '中间问', '中间答')) as never[],
|
||||
hasMore: true,
|
||||
}))
|
||||
|
||||
expect(await complete).toBe(true)
|
||||
|
||||
expect(api.callsOf('session.history')).toHaveLength(2)
|
||||
expect(source.getSnapshot().hasMore).toBe(true)
|
||||
})
|
||||
})
|
||||
@@ -123,12 +123,13 @@ function testViewDefinition(): ConversationViewDefinition<ChatConversationViewNo
|
||||
|
||||
const TEST_EVENT_DEFINITION: ConversationNodeDefinition<TestEventState> = {
|
||||
kind: 'runtime-test-event',
|
||||
target: 'chat',
|
||||
match: event => ({ id: String(event.seq), role: 'start' }),
|
||||
start: (_context, match) => ({ event: match.event, view: match.view }),
|
||||
update: context => context.state,
|
||||
publication: match => match.event.type === 'assistant/chunk' ? 'animation-frame' : 'immediate',
|
||||
buildViewNode: (context, target) => {
|
||||
if (target !== 'chat' || context.state === undefined || context.start === undefined) return null
|
||||
buildViewNode: (context) => {
|
||||
if (context.state === undefined || context.start === undefined) return null
|
||||
return {
|
||||
key: context.key,
|
||||
kind: 'runtime-test-event',
|
||||
@@ -531,6 +532,21 @@ describe('prompt and cancel errors', () => {
|
||||
expect(result.ok).toBe(false)
|
||||
expect(session.getSnapshot().promptError).toMatchObject({ op: 'stop', error: { code: 'internal' } })
|
||||
})
|
||||
|
||||
it('reads session-authorized attachment bytes and keeps the opaque id on the wire', async () => {
|
||||
const { api, session } = makeSession()
|
||||
const result = await session.readAttachment('attachment-1' as never)
|
||||
expect(result).toEqual({
|
||||
ok: true,
|
||||
value: {
|
||||
attachment: { attachmentId: 'a', mediaType: 'image/png', bytes: 1, width: 1, height: 1 },
|
||||
data: Uint8Array.of(0),
|
||||
},
|
||||
})
|
||||
expect(api.callsOf('session.attachment')).toEqual([{
|
||||
sessionId: SID, attachmentId: 'attachment-1',
|
||||
}])
|
||||
})
|
||||
})
|
||||
|
||||
describe('rename', () => {
|
||||
|
||||
@@ -6,7 +6,7 @@
|
||||
* deferral — the stage follows list.current), binding identity, breadcrumb
|
||||
* projection, create.
|
||||
*/
|
||||
import { Context } from 'cordis'
|
||||
import { Context } from '@deepseek-ai/cordis'
|
||||
import { afterEach, describe, expect, it, vi } from 'vitest'
|
||||
import type { SessionId } from '@deepseek-ai/dsh-client-connection/client'
|
||||
import { SessionCreateError, SessionsService, scopeOf } from '../src/client/sessions/service.ts'
|
||||
@@ -35,6 +35,7 @@ type FeedRow = {
|
||||
origin?: 'subagent'
|
||||
running?: boolean
|
||||
blank?: boolean
|
||||
agentPreset?: string
|
||||
}
|
||||
|
||||
async function feedList(b: Bench, rows: FeedRow[]): Promise<void> {
|
||||
@@ -44,6 +45,7 @@ async function feedList(b: Bench, rows: FeedRow[]): Promise<void> {
|
||||
...(r.cwd !== undefined ? { cwd: r.cwd } : {}),
|
||||
...(r.parentId !== undefined ? { parentSessionId: sid(r.parentId) } : {}),
|
||||
...(r.origin !== undefined ? { origin: r.origin } : {}),
|
||||
...(r.agentPreset !== undefined ? { agentPreset: r.agentPreset } : {}),
|
||||
})),
|
||||
}) as never)
|
||||
await b.svc.refresh()
|
||||
@@ -70,6 +72,38 @@ describe('list store projection', () => {
|
||||
expect(state.byId[sid('s2')]?.title).toBeUndefined()
|
||||
})
|
||||
|
||||
it('reprojects a blank session whose composition switched and nothing else moved', async () => {
|
||||
const b = bench()
|
||||
await feedList(b, [{ id: 's1', blank: true, agentPreset: 'standard' }])
|
||||
expect(b.svc.list.getSnapshot().byId[sid('s1')]?.agentPreset).toBe('standard')
|
||||
|
||||
// A confirmed switch moves the preset alone: the row keeps its updatedAt,
|
||||
// title, running, and blank bits, so an identity guard blind to the preset
|
||||
// would serve the old row forever — and every reader (the hero chip's own
|
||||
// no-op check, the header label) would keep the composition it replaced.
|
||||
b.svc.noteAgentPreset(sid('s1'), 'minimal')
|
||||
await Promise.resolve()
|
||||
|
||||
expect(b.svc.list.getSnapshot().byId[sid('s1')]?.agentPreset).toBe('minimal')
|
||||
})
|
||||
|
||||
it('learns a preset switch from the host frame, not only from the tab that issued it', async () => {
|
||||
const b = bench()
|
||||
await feedList(b, [{ id: 's1', blank: true, agentPreset: 'standard' }])
|
||||
|
||||
// Every connected client gets this frame; only the switching tab gets the
|
||||
// RPC echo. A client that ignored the payload would keep labelling the
|
||||
// session with the composition it replaced.
|
||||
b.svc.handleHostEnvelope({
|
||||
rpcId: 'r1' as never,
|
||||
payload: { type: 'host/session-preset-changed', sessionId: sid('s1'), agentPreset: 'minimal' } as never,
|
||||
})
|
||||
await Promise.resolve()
|
||||
|
||||
expect(b.svc.list.getSnapshot().byId[sid('s1')]?.agentPreset).toBe('minimal')
|
||||
expect(b.svc.list.getSnapshot().byId[sid('s1')]?.blank).toBe(true)
|
||||
})
|
||||
|
||||
it('reflects live increments (host stream via manager) into the store', async () => {
|
||||
const b = bench()
|
||||
await feedList(b, [{ id: 's1' }])
|
||||
|
||||
352
packages/client/runtime/tests/settings-scope.spec.ts
Normal file
352
packages/client/runtime/tests/settings-scope.spec.ts
Normal file
@@ -0,0 +1,352 @@
|
||||
import { Context } from '@deepseek-ai/cordis'
|
||||
import z from '@deepseek-ai/schemastery'
|
||||
import { describe, expect, it, vi } from 'vitest'
|
||||
import type { RpcResponse, SettingsNamespaceView } from '@deepseek-ai/dsh-client-connection/client'
|
||||
import {
|
||||
bindSettingsScope, SettingsScopeController, type SettingsScope,
|
||||
} from '../src/client/settings-scope.ts'
|
||||
|
||||
interface UiTestSettings {
|
||||
preference: 'light' | 'dark' | 'system'
|
||||
}
|
||||
|
||||
const ENVELOPE = z.object({
|
||||
preference: z.union(['light', 'dark', 'system']).default('system'),
|
||||
}).toJSON()
|
||||
|
||||
let rpc = 0
|
||||
|
||||
function ok<T>(value: T): RpcResponse<T> {
|
||||
return { rpcId: `scope-${rpc++}` as never, result: { ok: true, value } }
|
||||
}
|
||||
|
||||
function rejected<T>(): RpcResponse<T> {
|
||||
return {
|
||||
rpcId: `scope-${rpc++}` as never,
|
||||
result: {
|
||||
ok: false,
|
||||
error: { code: 'settings-rejected', message: 'conflict', details: { ns: 'ui-test' } },
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
function view(value: unknown, revision = 0): SettingsNamespaceView {
|
||||
return {
|
||||
ns: 'ui-test',
|
||||
schema: ENVELOPE,
|
||||
value,
|
||||
applies: 'live',
|
||||
secrets: [],
|
||||
revision,
|
||||
}
|
||||
}
|
||||
|
||||
function described(value: unknown, revision = 0) {
|
||||
return ok({ writable: true, hasDocument: true, namespaces: [view(value, revision)] })
|
||||
}
|
||||
|
||||
function deferred<T>() {
|
||||
let resolve!: (value: T) => void
|
||||
let reject!: (reason: unknown) => void
|
||||
const promise = new Promise<T>((res, rej) => { resolve = res; reject = rej })
|
||||
return { promise, resolve, reject }
|
||||
}
|
||||
|
||||
/** Record each distinct published section, starting from the current one. */
|
||||
function trackValues(scope: SettingsScope<UiTestSettings>): Array<UiTestSettings | undefined> {
|
||||
const seen: Array<UiTestSettings | undefined> = [scope.getSnapshot().value]
|
||||
scope.subscribe(() => {
|
||||
const value = scope.getSnapshot().value
|
||||
if (value !== seen[seen.length - 1]) seen.push(value)
|
||||
})
|
||||
return seen
|
||||
}
|
||||
|
||||
describe('SettingsScopeController', () => {
|
||||
it('starts loading and publishes a schema-valid section with revision and writability', async () => {
|
||||
const describeCall = vi.fn().mockResolvedValueOnce(described({ preference: 'dark' }, 3))
|
||||
const scope = new SettingsScopeController<UiTestSettings>(
|
||||
{ settings: { describe: describeCall } } as never,
|
||||
{ namespace: 'ui-test' },
|
||||
)
|
||||
expect(scope.getSnapshot()).toEqual({
|
||||
status: 'loading', value: undefined, revision: undefined, writable: false, mode: 'host',
|
||||
})
|
||||
await scope.load()
|
||||
expect(scope.getSnapshot()).toEqual({
|
||||
status: 'ready', value: { preference: 'dark' }, revision: 3, writable: true, mode: 'host',
|
||||
})
|
||||
})
|
||||
|
||||
it('keeps the last good value across invalid, rejected, and failed reads while tracking revisions', async () => {
|
||||
const describeCall = vi.fn()
|
||||
.mockResolvedValueOnce(described({ preference: 'dark' }, 3))
|
||||
.mockResolvedValueOnce(described({ preference: 'sepia' }, 4))
|
||||
.mockResolvedValueOnce(described(null, 5))
|
||||
.mockResolvedValueOnce(described('scalar', 6))
|
||||
.mockResolvedValueOnce(described(['queue'], 7))
|
||||
.mockResolvedValueOnce(rejected())
|
||||
.mockRejectedValueOnce(new Error('offline'))
|
||||
const scope = new SettingsScopeController<UiTestSettings>(
|
||||
{ settings: { describe: describeCall } } as never,
|
||||
{ namespace: 'ui-test' },
|
||||
)
|
||||
const good = trackValues(scope)
|
||||
for (let i = 0; i < 7; i++) await scope.load()
|
||||
expect(scope.getSnapshot()).toMatchObject({
|
||||
status: 'ready', value: { preference: 'dark' }, revision: 7,
|
||||
})
|
||||
expect(good).toEqual([undefined, { preference: 'dark' }])
|
||||
})
|
||||
|
||||
it('treats a schema envelope it cannot rehydrate as vouching for no section', async () => {
|
||||
const broken = { ...view({ preference: 'dark' }, 2), schema: null }
|
||||
const describeCall = vi.fn()
|
||||
.mockResolvedValueOnce(ok({ writable: true, hasDocument: true, namespaces: [broken] }))
|
||||
const scope = new SettingsScopeController<UiTestSettings>(
|
||||
{ settings: { describe: describeCall } } as never,
|
||||
{ namespace: 'ui-test' },
|
||||
)
|
||||
await scope.load()
|
||||
expect(scope.getSnapshot()).toMatchObject({ status: 'loading', value: undefined, revision: 2 })
|
||||
})
|
||||
|
||||
it('suppresses a superseded read of an unexposed namespace', async () => {
|
||||
const describeCall = vi.fn()
|
||||
.mockResolvedValueOnce(ok({ writable: true, hasDocument: true, namespaces: [] }))
|
||||
.mockResolvedValueOnce(described({ preference: 'dark' }, 1))
|
||||
const scope = new SettingsScopeController<UiTestSettings>(
|
||||
{ settings: { describe: describeCall } } as never,
|
||||
{ namespace: 'ui-test' },
|
||||
)
|
||||
const statuses: string[] = []
|
||||
scope.subscribe(() => { statuses.push(scope.getSnapshot().status) })
|
||||
const stale = scope.load()
|
||||
const fresh = scope.load()
|
||||
await Promise.all([stale, fresh])
|
||||
expect(statuses).not.toContain('unavailable')
|
||||
expect(scope.getSnapshot()).toMatchObject({ status: 'ready', value: { preference: 'dark' } })
|
||||
})
|
||||
|
||||
it('reports an unexposed namespace as unavailable and recovers when it reappears', async () => {
|
||||
const describeCall = vi.fn()
|
||||
.mockResolvedValueOnce(described({ preference: 'light' }, 1))
|
||||
.mockResolvedValueOnce(ok({ writable: true, hasDocument: true, namespaces: [] }))
|
||||
.mockResolvedValueOnce(described({ preference: 'system' }, 2))
|
||||
const scope = new SettingsScopeController<UiTestSettings>(
|
||||
{ settings: { describe: describeCall } } as never,
|
||||
{ namespace: 'ui-test' },
|
||||
)
|
||||
await scope.load()
|
||||
expect(scope.getSnapshot().status).toBe('ready')
|
||||
await scope.load()
|
||||
expect(scope.getSnapshot()).toMatchObject({ status: 'unavailable', value: { preference: 'light' } })
|
||||
await scope.load()
|
||||
expect(scope.getSnapshot()).toMatchObject({ status: 'ready', value: { preference: 'system' }, revision: 2 })
|
||||
})
|
||||
|
||||
it('applies a custom decode override in place of the wire schema', async () => {
|
||||
const describeCall = vi.fn()
|
||||
.mockResolvedValueOnce(described({ preference: 'light' }, 1))
|
||||
.mockResolvedValueOnce(described({ preference: 'dark' }, 2))
|
||||
const scope = new SettingsScopeController<UiTestSettings>(
|
||||
{ settings: { describe: describeCall } } as never,
|
||||
{
|
||||
namespace: 'ui-test',
|
||||
decode: section => (section as UiTestSettings).preference === 'dark'
|
||||
? section as UiTestSettings
|
||||
: undefined,
|
||||
},
|
||||
)
|
||||
await scope.load()
|
||||
expect(scope.getSnapshot()).toMatchObject({ status: 'loading', value: undefined, revision: 1 })
|
||||
await scope.load()
|
||||
expect(scope.getSnapshot()).toMatchObject({ status: 'ready', value: { preference: 'dark' }, revision: 2 })
|
||||
})
|
||||
|
||||
it('serializes rapid set writes, carries revisions, and publishes only the latest settlement', async () => {
|
||||
const first = deferred<RpcResponse<SettingsNamespaceView>>()
|
||||
const describeCall = vi.fn().mockResolvedValue(described({ preference: 'system' }, 4))
|
||||
const mutate = vi.fn()
|
||||
.mockReturnValueOnce(first.promise)
|
||||
.mockResolvedValueOnce(ok(view({ preference: 'light' }, 6)))
|
||||
const scope = new SettingsScopeController<UiTestSettings>(
|
||||
{ settings: { describe: describeCall, mutate } } as never,
|
||||
{ namespace: 'ui-test' },
|
||||
)
|
||||
const published = trackValues(scope)
|
||||
await scope.load()
|
||||
const dark = scope.set('preference', 'dark')
|
||||
const light = scope.set('preference', 'light')
|
||||
await vi.waitFor(() => { expect(mutate).toHaveBeenCalledOnce() })
|
||||
first.resolve(ok(view({ preference: 'dark' }, 5)))
|
||||
await Promise.all([dark, light])
|
||||
expect(published.map(section => section?.preference)).toEqual([undefined, 'system', 'light'])
|
||||
expect(scope.getSnapshot()).toMatchObject({ value: { preference: 'light' }, revision: 6 })
|
||||
expect(mutate).toHaveBeenNthCalledWith(1, {
|
||||
ns: 'ui-test',
|
||||
ops: [{ op: 'set', path: ['preference'], value: 'dark' }],
|
||||
expectedRevision: 4,
|
||||
})
|
||||
expect(mutate).toHaveBeenNthCalledWith(2, {
|
||||
ns: 'ui-test',
|
||||
ops: [{ op: 'set', path: ['preference'], value: 'light' }],
|
||||
expectedRevision: 5,
|
||||
})
|
||||
})
|
||||
|
||||
it('recovers the latest rejected or thrown write from Host state', async () => {
|
||||
const describeCall = vi.fn()
|
||||
.mockResolvedValueOnce(described({ preference: 'system' }, 2))
|
||||
.mockResolvedValueOnce(described({ preference: 'light' }, 3))
|
||||
const mutate = vi.fn()
|
||||
.mockResolvedValueOnce(rejected())
|
||||
.mockRejectedValueOnce(new Error('offline'))
|
||||
const scope = new SettingsScopeController<UiTestSettings>(
|
||||
{ settings: { describe: describeCall, mutate } } as never,
|
||||
{ namespace: 'ui-test' },
|
||||
)
|
||||
const published = trackValues(scope)
|
||||
await scope.set('preference', 'dark')
|
||||
await scope.set('preference', 'system')
|
||||
expect(published.map(section => section?.preference)).toEqual([undefined, 'system', 'light'])
|
||||
})
|
||||
|
||||
it('does not recover superseded rejected or thrown writes', async () => {
|
||||
const describeCall = vi.fn()
|
||||
const mutate = vi.fn()
|
||||
.mockResolvedValueOnce(rejected())
|
||||
.mockRejectedValueOnce(new Error('offline'))
|
||||
.mockResolvedValueOnce(ok(view({ preference: 'light' }, 3)))
|
||||
const scope = new SettingsScopeController<UiTestSettings>(
|
||||
{ settings: { describe: describeCall, mutate } } as never,
|
||||
{ namespace: 'ui-test' },
|
||||
)
|
||||
const published = trackValues(scope)
|
||||
await Promise.all([
|
||||
scope.set('preference', 'dark'),
|
||||
scope.set('preference', 'system'),
|
||||
scope.set('preference', 'light'),
|
||||
])
|
||||
expect(describeCall).not.toHaveBeenCalled()
|
||||
expect(published.map(section => section?.preference)).toEqual([undefined, 'light'])
|
||||
})
|
||||
|
||||
it('keeps the write queue usable when a subscriber throws', async () => {
|
||||
const describeCall = vi.fn()
|
||||
.mockResolvedValueOnce(described({ preference: 'dark' }, 1))
|
||||
.mockResolvedValueOnce(described({ preference: 'light' }, 2))
|
||||
const scope = new SettingsScopeController<UiTestSettings>(
|
||||
{ settings: { describe: describeCall } } as never,
|
||||
{ namespace: 'ui-test' },
|
||||
)
|
||||
let thrown = false
|
||||
scope.subscribe(() => {
|
||||
if (thrown) return
|
||||
thrown = true
|
||||
throw new Error('subscriber failed')
|
||||
})
|
||||
await expect(scope.load()).rejects.toThrow('subscriber failed')
|
||||
await expect(scope.load()).resolves.toBeUndefined()
|
||||
expect(scope.getSnapshot()).toMatchObject({ value: { preference: 'light' }, revision: 2 })
|
||||
})
|
||||
|
||||
it('cancels queued and post-dispose writes while draining the in-flight mutation', async () => {
|
||||
const first = deferred<RpcResponse<SettingsNamespaceView>>()
|
||||
const mutate = vi.fn().mockReturnValue(first.promise)
|
||||
const describeCall = vi.fn()
|
||||
const scope = new SettingsScopeController<UiTestSettings>(
|
||||
{ settings: { describe: describeCall, mutate } } as never,
|
||||
{ namespace: 'ui-test' },
|
||||
)
|
||||
const published = trackValues(scope)
|
||||
const dark = scope.set('preference', 'dark')
|
||||
await vi.waitFor(() => { expect(mutate).toHaveBeenCalledOnce() })
|
||||
const light = scope.set('preference', 'light')
|
||||
let stopped = false
|
||||
const stop = scope.dispose().then(() => { stopped = true })
|
||||
await Promise.resolve()
|
||||
expect(stopped).toBe(false)
|
||||
first.resolve(ok(view({ preference: 'dark' }, 1)))
|
||||
await Promise.all([dark, light, stop])
|
||||
await scope.set('preference', 'system')
|
||||
await scope.load()
|
||||
expect(mutate).toHaveBeenCalledOnce()
|
||||
expect(describeCall).not.toHaveBeenCalled()
|
||||
expect(published).toEqual([undefined])
|
||||
})
|
||||
|
||||
it('keeps a remote browser in memory mode without Host calls', async () => {
|
||||
const describeCall = vi.fn()
|
||||
const mutate = vi.fn()
|
||||
const scope = new SettingsScopeController<UiTestSettings>(
|
||||
{ settings: { describe: describeCall, mutate } } as never,
|
||||
{ namespace: 'ui-test' },
|
||||
'memory',
|
||||
)
|
||||
expect(scope.getSnapshot()).toEqual({
|
||||
status: 'unavailable', value: undefined, revision: undefined, writable: false, mode: 'memory',
|
||||
})
|
||||
await scope.load()
|
||||
await scope.set('preference', 'dark')
|
||||
await scope.dispose()
|
||||
expect(describeCall).not.toHaveBeenCalled()
|
||||
expect(mutate).not.toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
|
||||
describe('bindSettingsScope', () => {
|
||||
it('subscribes before the initial read and converges to the latest queued invalidation', async () => {
|
||||
const initial = deferred<ReturnType<typeof described>>()
|
||||
const describeCall = vi.fn()
|
||||
.mockReturnValueOnce(initial.promise)
|
||||
.mockResolvedValueOnce(described({ preference: 'light' }, 2))
|
||||
.mockResolvedValueOnce(described({ preference: 'system' }, 3))
|
||||
const ctx = new Context()
|
||||
ctx.provide('connection', {
|
||||
api: { settings: { describe: describeCall } },
|
||||
isLoopback: true,
|
||||
} as never)
|
||||
let scope!: SettingsScope<UiTestSettings>
|
||||
const fiber = ctx.plugin({
|
||||
inject: ['connection'],
|
||||
apply: (plugin: Context) => {
|
||||
scope = bindSettingsScope<UiTestSettings>(plugin, { namespace: 'ui-test' })
|
||||
},
|
||||
})
|
||||
await fiber.await()
|
||||
await vi.waitFor(() => { expect(describeCall).toHaveBeenCalledOnce() })
|
||||
ctx.emit('settings/changed', 'unrelated')
|
||||
ctx.emit('settings/changed', 'ui-test')
|
||||
ctx.emit('connection/reset')
|
||||
initial.resolve(described({ preference: 'dark' }, 1))
|
||||
await vi.waitFor(() => { expect(describeCall).toHaveBeenCalledTimes(3) })
|
||||
await vi.waitFor(() => {
|
||||
expect(scope.getSnapshot()).toMatchObject({ value: { preference: 'system' }, revision: 3 })
|
||||
})
|
||||
await fiber.dispose()
|
||||
ctx.emit('settings/changed', 'ui-test')
|
||||
await Promise.resolve()
|
||||
expect(describeCall).toHaveBeenCalledTimes(3)
|
||||
})
|
||||
|
||||
it('binds a remote browser in memory mode without starting a settings read', async () => {
|
||||
const describeCall = vi.fn()
|
||||
const ctx = new Context()
|
||||
ctx.provide('connection', {
|
||||
api: { settings: { describe: describeCall } },
|
||||
isLoopback: false,
|
||||
} as never)
|
||||
let scope!: SettingsScope<UiTestSettings>
|
||||
const fiber = ctx.plugin({
|
||||
inject: ['connection'],
|
||||
apply: (plugin: Context) => {
|
||||
scope = bindSettingsScope<UiTestSettings>(plugin, { namespace: 'ui-test' })
|
||||
},
|
||||
})
|
||||
await fiber.await()
|
||||
expect(scope.getSnapshot()).toMatchObject({ status: 'unavailable', mode: 'memory', writable: false })
|
||||
await fiber.dispose()
|
||||
expect(describeCall).not.toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
@@ -5,7 +5,7 @@
|
||||
* contract (double install / not installed / non-root key), store instance
|
||||
* resolution and lifecycle on the ledger axis, and the entry-unload cascade.
|
||||
*/
|
||||
import { Context } from 'cordis'
|
||||
import { Context } from '@deepseek-ai/cordis'
|
||||
import { describe, expect, it, vi } from 'vitest'
|
||||
import type { FC } from 'react'
|
||||
import type { SlotRendererHost } from '@deepseek-ai/dsh-client-ui-slots'
|
||||
|
||||
@@ -1,9 +1,10 @@
|
||||
/**
|
||||
* Wire-to-typed-event bridge: host/commands-changed
|
||||
* → ctx 'commands/changed'; each established connection generation →
|
||||
* → ctx 'commands/changed'; host/session-preset-changed →
|
||||
* ctx 'session/preset-changed'; each established connection generation →
|
||||
* ctx 'connection/reset' (the forced cache-invalidation broadcast).
|
||||
*/
|
||||
import { Context } from 'cordis'
|
||||
import { Context } from '@deepseek-ai/cordis'
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import type { ConnectionHandle, ConnectionSinks } from '@deepseek-ai/dsh-client-connection/client'
|
||||
import TypertRegistry from '@deepseek-ai/dsh-typert-registry'
|
||||
@@ -67,6 +68,17 @@ describe('wire event bridge', () => {
|
||||
])
|
||||
})
|
||||
|
||||
it('broadcasts session/preset-changed with the recomposed session and its new preset', async () => {
|
||||
const bench = await mount()
|
||||
const seen: Array<[string, string]> = []
|
||||
bench.ctx.on('session/preset-changed', (sessionId, agentPreset) => { seen.push([sessionId, agentPreset]) })
|
||||
bench.sinks?.onHostEnvelope?.({
|
||||
rpcId: 'r1' as never,
|
||||
payload: { type: 'host/session-preset-changed', sessionId: 's1' as never, agentPreset: 'minimal' },
|
||||
})
|
||||
expect(seen).toEqual([['s1', 'minimal']])
|
||||
})
|
||||
|
||||
it('broadcasts connection/reset on every established generation (reconnect invalidation)', async () => {
|
||||
const bench = await mount()
|
||||
let resets = 0
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { Context } from 'cordis'
|
||||
import { Context } from '@deepseek-ai/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'
|
||||
@@ -59,7 +59,7 @@ describe('WorkspaceManager', () => {
|
||||
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 () => {
|
||||
it('creates by path, prepends a new row, and folds failures', async () => {
|
||||
const api = new FakeApiClient()
|
||||
const manager = new WorkspaceManager(api)
|
||||
api.onWorkspaceCreate = payload => Promise.resolve(ok({
|
||||
@@ -67,8 +67,8 @@ describe('WorkspaceManager', () => {
|
||||
created: true,
|
||||
payload,
|
||||
} as never))
|
||||
await expect(manager.create({ name: 'created' })).resolves.toMatchObject({ ok: true })
|
||||
expect(api.callsOf('workspace.create')).toEqual([{ name: 'created' }])
|
||||
await expect(manager.create({ path: '/w/created' })).resolves.toMatchObject({ ok: true })
|
||||
expect(api.callsOf('workspace.create')).toEqual([{ path: '/w/created' }])
|
||||
expect(manager.getSnapshot().items[0]?.workspaceId).toBe('created')
|
||||
|
||||
api.onWorkspaceCreate = () => Promise.reject(new Error('create transport'))
|
||||
|
||||
@@ -8,6 +8,9 @@
|
||||
"src"
|
||||
],
|
||||
"references": [
|
||||
{
|
||||
"path": "../../attachment/attachment"
|
||||
},
|
||||
{
|
||||
"path": "../../../vendor/cordis"
|
||||
},
|
||||
@@ -20,6 +23,9 @@
|
||||
{
|
||||
"path": "../connection"
|
||||
},
|
||||
{
|
||||
"path": "../schema-form"
|
||||
},
|
||||
{
|
||||
"path": "../../host/apiproxy"
|
||||
},
|
||||
|
||||
@@ -1,8 +1,15 @@
|
||||
{
|
||||
"name": "@deepseek-ai/dsh-client-schema-form",
|
||||
"description": "Schema/draft model layer for settings editors: rehydrates a serialized schemastery schema, validates drafts, and edits them immutably by path",
|
||||
"version": "0.0.1",
|
||||
"private": true,
|
||||
"version": "0.0.1-rc.1",
|
||||
"publishConfig": {
|
||||
"access": "restricted"
|
||||
},
|
||||
"repository": {
|
||||
"type": "git",
|
||||
"url": "git+https://github.com/deepseek-ai/deepseek-harness.git",
|
||||
"directory": "packages/client/schema-form"
|
||||
},
|
||||
"type": "module",
|
||||
"main": "lib/index.js",
|
||||
"types": "lib/types/index.d.ts",
|
||||
@@ -20,15 +27,15 @@
|
||||
},
|
||||
"license": "BSD-3-Clause",
|
||||
"dependencies": {
|
||||
"schemastery": "^3.18.0"
|
||||
"@deepseek-ai/schemastery": "workspace:^"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@deepseek-ai/dsh-invariants": "^0.0.1",
|
||||
"cordis": "^4.0.0-rc.7"
|
||||
"@deepseek-ai/dsh-invariants": "workspace:^",
|
||||
"@deepseek-ai/cordis": "workspace:^"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@deepseek-ai/dsh-invariants": "workspace:^",
|
||||
"cordis": "^4.0.0-rc.7"
|
||||
"@deepseek-ai/cordis": "workspace:^"
|
||||
},
|
||||
"files": [
|
||||
"lib/index.js",
|
||||
|
||||
@@ -4,7 +4,7 @@
|
||||
*/
|
||||
|
||||
/* jscpd:ignore-start */
|
||||
import type { Context } from 'cordis'
|
||||
import type { Context } from '@deepseek-ai/cordis'
|
||||
import type { InvariantInstaller } from '@deepseek-ai/dsh-invariants'
|
||||
|
||||
const PACKAGE_NAME = '@deepseek-ai/dsh-client-schema-form'
|
||||
|
||||
@@ -6,7 +6,7 @@
|
||||
* @module @deepseek-ai/dsh-client-schema-form/model
|
||||
*/
|
||||
|
||||
import Schema from 'schemastery'
|
||||
import Schema from '@deepseek-ai/schemastery'
|
||||
|
||||
/** Live schemastery node; the renderer reads only its structural relations. */
|
||||
export type SchemaNode = Schema
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { Context } from 'cordis'
|
||||
import { Context } from '@deepseek-ai/cordis'
|
||||
import * as SchemaFormInvariant from '@deepseek-ai/dsh-client-schema-form/invariant'
|
||||
import InvariantService from '@deepseek-ai/dsh-invariants'
|
||||
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import Schema from 'schemastery'
|
||||
import Schema from '@deepseek-ai/schemastery'
|
||||
import {
|
||||
deletePath, getPath, hasPath, nodeAtPath, rehydrateSchema, setPath, validateDraft,
|
||||
} from '../src/model.ts'
|
||||
|
||||
@@ -2,5 +2,5 @@
|
||||
# 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 packages/client/test-runtime/README.md
|
||||
README.md: 74da8fde7fd9cc3733d2d1ae03dd3d213e4d553e
|
||||
README.zh.md: 1df28f7b25c35333e91476e10480c22a728cdab3
|
||||
README.md: d5c0797c37168578f08a08f3d5d57670d7973db0
|
||||
README.zh.md: 57854213c3a9ea28850665eb26d07bc824c017e8
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user