Merge worktree/schedule-conversational-after into worktree/schedule-explicit-at
This commit is contained in:
@@ -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/host/apiproxy/README.md
|
||||
README.md: 8f8064f9f1b3b50e202110a4ea85cdcbe5ffd3a1
|
||||
README.zh.md: 671efbc0bcaaf966df314e336f357c8f0569162d
|
||||
README.md: 340a7e9b255ae60e1d0918e57f56bfd3ed7180e4
|
||||
README.zh.md: 6d8064b8415273e8182a8a98e11dc7eaa007cd8b
|
||||
|
||||
@@ -2,11 +2,11 @@
|
||||
|
||||
English | [中文](README.zh.md)
|
||||
|
||||
The API gateway every client shape shares: the TS contract (`src/api/`, zero Node dependencies, importable from the browser), the fetch carrier pair (`src/fetch/`: `toFetchHandler` on the host side, `AbstractApiClient` plus platform subclasses on the client side), and the host-side implementation (`src/api-proxy.ts`: `createApiProxy` plus the default-exported `ApiProxyService` gateway plugin — config `{workspaceRoot?}`, provides `ctx.apiProxy`). Transport-agnostic by design: this package registers no routes; carriers such as HTTP wrap `ctx.apiProxy` themselves. The shipped Web composition lives in [`packages/bundle/web-app/cordis.patch.yml`](../../bundle/web-app/cordis.patch.yml), while its default Agent model selection belongs to [`@deepseek-ai/dsh-agent-default-model`](../../core/agent-default-model/README.md) in the base bundle.
|
||||
The API gateway shared by every client consists of the TypeScript API contract (`src/api/`, zero Node dependencies, importable from the browser), the fetch carrier pair (`src/fetch/`: `toFetchHandler` on the host side, `AbstractApiClient` plus platform subclasses on the client side), and the host-side implementation (`src/api-proxy.ts`: `createApiProxy` plus the default-exported `ApiProxyService` gateway plugin — config `{nativeOpen?, sessionExportCompressionLevel?}`, provides `ctx.apiProxy`). This package registers no routes; carriers such as HTTP wrap `ctx.apiProxy` themselves. The shipped Web composition lives in [`packages/bundle/web-app/cordis.patch.yml`](../../bundle/web-app/cordis.patch.yml), while its default Agent model selection belongs to [`@deepseek-ai/dsh-agent-default-model`](../../core/agent-default-model/README.md) in the base bundle.
|
||||
|
||||
## The shared Agent default (`agent-default-model` Settings section)
|
||||
|
||||
`ApiProxyService` consumes `ctx.agentDefaultModel`; it does not own a provider/model config or settings section. The shared service registers `{provider, model, reasoningEffort?}` under `agent-default-model`: the base bundle's composition entry is the lower layer and `settings.yaml` layers the user's choice over it. `workspaceRoot` remains ApiProxy config because it is a Host launcher fact, not a model preference.
|
||||
`ApiProxyService` consumes `ctx.agentDefaultModel`; it does not own a provider/model config or settings section. The shared service registers `{provider, model, reasoningEffort?}` under `agent-default-model`: the base bundle's composition entry is the lower layer and `settings.yaml` layers the user's choice over it.
|
||||
|
||||
A session resolves its model selection from three tiers on every access: a selection made in this process, otherwise the session's latest logged `request/header`, otherwise this default. A session that has run a turn derives its selection from its log, while a blank session observes a default saved after it was created.
|
||||
|
||||
@@ -28,17 +28,21 @@ Question responses are validated against their pending request before the first
|
||||
|
||||
`session.history`'s tail page (`beforeSeq` absent) additionally carries an optional `projections` block — the watermark snapshot of every unit registered on `ctx.sessionProjections` (`@deepseek-ai/dsh-session-projection`), with `asOfSeq` = the last event seq the values reflect (`-1` on an empty log). The gateway also subscribes to the registry's change feed and mints a `session/projection` mux frame per changed unit (`{sessionId, key, value, seq}` — live push state, never logged; clients hold one generic per-session value store under higher-seq-wins). The carrier holds zero domain knowledge (each value passed its unit's own schema inside the registry; the wire schemas keep `values`/`value` wide); loadOlder pages never carry the block, and a composition without the registry serves histories without either surface.
|
||||
|
||||
Session-log export is a host-only download surface, not an RPC: `GET /api/session.export?sessionId=…&includeDescendants=true` streams a ZIP whose files are each session's stored artifact text verbatim (the persistence backend's `readRaw` — exact durable bytes decoded from the physical encoding, never a reconstruction from parsed events), root under its original base name plus each subagent descendant under `subagents/<id>/`, and every image any included log references under `media/<attachmentId>.<ext>` (read and verified from the attachment store; a shared image appears once). Each live root or descendant crosses the authoritative `SessionStore.flush` durability barrier immediately before its raw artifact read; cold sessions have no in-memory work to flush. Compression runs on the host with fflate's streaming Zip API at validated `sessionExportCompressionLevel` 0–9 (default 6), so deployments can trade CPU and latency against archive size; the response is chunked as it is produced and the host never holds the whole archive in one buffer. Once the response queue reaches its 64 KiB byte high-water mark, production waits until consumer pull restores positive capacity; fflate's synchronous callback can overshoot that bound only by the output of one bounded input push. Request abort and response-body cancellation stop lineage and artifact work, terminate the active compressor, and propagate as cancellation rather than an HTTP 500. It requires the persistence, session-query, and attachment services: a deployment without any answers 500, a persistence backend without per-session raw artifacts answers 501, a missing root session answers 404, and a descendant without a stored artifact or a referenced image that cannot be read fails the stream (fail-loud, never silent under-export). The carrier mounts the endpoint; `ApiProxy.downloads.sessionLog` implements it.
|
||||
|
||||
Session titles ride the generic projection pair like every other domain — the history-tail `projections` block plus `session/projection` frames under the `title` key. Titles do not join `session.list`; cold sessions remain metadata-only there until opening or resuming attaches their logs. `session.rename` accepts an explicit user title (resuming a cold session first), delegating to `ctx.sessionTitle.rename` — the accepted `session/title` event pins the title against automatic regeneration — and returns the normalized title plus its event seq so a client settles its `title` projection cell ahead of the push frame; a title that normalizes to empty returns `title-invalid`.
|
||||
|
||||
`session.fork` maps an optional event anchor to the first `turn/end` at or after it, letting a message action include that message's whole turn. An omitted or past-end anchor selects the last completed turn; an in-log anchor whose turn remains open returns `fork-unavailable` rather than clipping backward. The published child inherits the source's seeded history, cwd, latest logged `ModelSelection`, and lineage before joining the source Workspace. If Workspace attachment fails, `workspace-attach-failed` carries the already-published child id so clients can reconcile it. The [SessionStore fork decision](../../../.agents/notes/implemented/feature/2026-06-30-session-store-fork-api.md) owns the boundary rationale.
|
||||
`session.fork` maps an optional event anchor to the first `turn/end` at or after it, letting a message action include that message's whole turn. An omitted or past-end anchor selects the last completed turn; an in-log anchor whose turn remains open returns `fork-unavailable` rather than clipping backward. The published child inherits the source's seeded history, cwd, latest logged `ModelSelection`, and lineage before joining the source Workspace. If Workspace attachment fails, `workspace-attach-failed` carries the already-published child id so clients can reconcile it. The [SessionStore fork decision](../../../.agents/notes/implemented/feature/2026-06-30-session-store-fork-api.md) records why the anchor maps to that `turn/end`.
|
||||
|
||||
Session model selection is a session-domain contract. `session.models` returns the current `ModelSelection` separately from provider-grouped advisory models, exact-model reasoning metadata, and provider-local lookup failures. The selection may be absent from the groups and is never injected as a synthetic row; clients can prompt for another selection without turning the directory into a routing whitelist. `session.selectModel` validates the optional adapter-owned reasoning effort and assigns the complete selection for the next prompt-assembly boundary. Catalog membership is not validation: an adapter may resolve an unlisted model, while an unavailable provider or unsupported effort returns `model-unavailable`. `session.models` additionally reports `routable`: whether an adapter currently serves the selected provider. This is deliberately not derivable from the groups because an adapter may serve an unadvertised model. `session.prompt` refuses on the same fact with `model-unavailable` before opening a turn; a disabled composer is a client affordance, and the method remains callable.
|
||||
Session model selection is a session-domain contract. `session.models` returns the current `ModelSelection` separately from provider-grouped advisory models, exact-model reasoning metadata, and provider-local lookup failures. The selection may be absent from the groups and is never injected as a synthetic row; clients can prompt for another selection without turning the directory into a routing whitelist. `session.selectModel` validates the optional adapter-owned reasoning effort and assigns the complete selection for the next prompt assembly. Catalog membership is not validation: an adapter may resolve an unlisted model, while an unavailable provider or unsupported effort returns `model-unavailable`. `session.models` additionally reports `routable`: whether an adapter currently serves the selected provider. This is deliberately not derivable from the groups because an adapter may serve an unadvertised model. `session.prompt` refuses on the same fact with `model-unavailable` before opening a turn; a disabled composer is a client affordance, and the method remains callable.
|
||||
|
||||
`session.prompt` and `subagent.prompt` accept optional request-local `clientTimeZone` provenance. When present, the Host validates and canonicalizes `UTC` or an IANA Area/Location before Agent entry, rejects invalid input with `invalid-time-zone`, and records the canonical value on that exact `user-rpc` message beside its `rpcId`. The value is not Session, connection, create, resume, or fork state; non-browser callers may omit it.
|
||||
|
||||
Pending queued input is a live control-plane contract, not conversation history. The gateway derives the complete `next-turn` queue from durable `agent/inbox/spliced` mutations and broadcasts authoritative `session/queue` snapshots after each change and on reconnect; pending `next-step` steering stays outside this Web projection. Within `next-step`, user-origin messages carry the `steering` placement while injected context (approval notices, task completion, attached snapshots) carries `context` and is not surfaced until claimed. The message-local `agent/inbox/inserted`, `claimed`, and `discarded` notifications remain available to lifecycle observers but do not build the queue view. `session.updateQueue` addresses one `MessageId`; edit and remove mutate the attached Agent through `Inbox.splice()`. A claim's pure deletion splice wins races before pre-step admission, so a later operation returns `queue-item-not-found`. `session.cancel` aborts only the active turn and preserves pending inbox work; after cancellation reaches quiescence and the closing turn flushes, AgentLoop claims the next waking message in FIFO order, and the browser never resends or promotes it. Queue operations never resume a cold session, and the client never infers retirement from turn or status events.
|
||||
|
||||
Workspace and Session lists are separate reconnect baselines. `workspace.create({ name })` creates a uniquely titled directory under the configured root, while `workspace.create({ path })` adopts an existing canonical directory and permits basename-derived titles to repeat. `workspace.delete` removes only the Workspace registration, `session.create` accepts an optional preallocated Session id, and `host/workspace-changed`, `host/workspace-removed`, plus `host/session-added` carry committed increments in either arrival order. `workspace.archiveSession` adds one session to the registry-global archive set and answers the full updated set; `workspace.list` carries that set as the reconnect baseline and `host/archived-sessions-changed` pushes the full snapshot after every durable change. Archiving hides the session from grouping surfaces without touching its log or its workspace account; a session neither live nor persisted fails with `session-not-found`. Registration deletion preserves the directory and session logs; its Sessions remain in `session.list` and become Ungrouped. `SessionSummary.blank` and the `host/session-added` frame carry the derived zero-events bit: clients hide blank sessions and reuse them per workspace, flip blank on the first `host/session-status(running:true)`, and treat `session.list` as the reconnect authority; cold summaries are never blank because lazy persistence keeps never-appended sessions out of `list()`.
|
||||
Background tasks ride the same live-push posture. When `ctx.tasks` is composed, the gateway subscribes to its change feed and broadcasts a whole `session/tasks` snapshot after every registry commit that alters what a session can see — registration, the stopping transition, settlement, and owner-disposal removal — plus a subscription baseline for each session that already has tasks (an absent baseline is the empty set; a change that empties a set still sends `[]`). A change carrying an owner reads through that exact `Agent`, so a push stays correct while its scope tears down; the baseline reads `ctx.agents.get(sessionId)`, which yields only unowned tasks for a session with no live Agent and never resumes a cold one. An unowned change fans out to every subscribed session, because unowned tasks are visible to every caller. The wire `TaskView` drops `ownerSession`, `reported`, and `outputLimitBytes`: the frame's own `sessionId` carries the first, and the other two are internal notice and model-presentation policy. A composition without the registry emits no such frames.
|
||||
|
||||
Workspace and Session lists are separate reconnect baselines. `workspace.create({ path })` adopts an existing canonical directory and permits basename-derived titles to repeat. `workspace.delete` removes only the Workspace registration, `session.create` accepts an optional preallocated Session id, and `host/workspace-changed`, `host/workspace-removed`, plus `host/session-added` carry committed increments in either arrival order. `workspace.archiveSession` adds one session to the registry-global archive set and answers the full updated set; `workspace.list` carries that set as the reconnect baseline and `host/archived-sessions-changed` pushes the full snapshot after every durable change. Archiving hides the session from grouping surfaces without touching its log or its workspace account; a session neither live nor persisted fails with `session-not-found`. Registration deletion preserves the directory and session logs; its Sessions remain in `session.list` and become Ungrouped. `SessionSummary.blank` and the `host/session-added` frame carry the derived zero-events bit: clients hide blank sessions and reuse them per workspace, flip blank on the first `host/session-status(running:true)`, and treat `session.list` as the reconnect authority; cold summaries are never blank because lazy persistence keeps never-appended sessions out of `list()`.
|
||||
|
||||
`session.search` is a bounded content-search projection over the sessions visible through `session.list`. The gateway asks the optional `ctx.sessionQuery` service for globally ranked current-surface user, assistant, and steering matches, consumes that stream until it has at most 20 visible session/snippet pairs plus one lookahead, and revalidates every hit against the list-derived authorization set before returning it. Provider pages start at 20 hits; when a first-page request rejects that limit, the gateway probes 10, 5, 2, then 1 and retains the learned size for continuation and stale-generation restarts. Returned snippets contain at most 240 Unicode code points, and the response schema independently enforces that bound at each client boundary. Keeping the authorization set in Host memory avoids SQLite's variable ceiling for large valid corpora without weakening visibility or ranking.
|
||||
|
||||
@@ -48,13 +52,17 @@ Directory picking delegates to the composed `ctx.directoryPicker` backend ([the
|
||||
|
||||
`host.openPath` opens a filesystem path with the operating system's default application (`open` on macOS, `Invoke-Item` on Windows, and `xdg-open` on desktop Linux). For `.html`, `.htm`, `.xhtml`, and `.svg`, macOS and desktop Linux prefer a named default browser and fall back to that application handoff when none can be named. WSL translates every Linux path through `wslpath -w` and hands the resulting Windows/UNC path to Windows `Invoke-Item`, including browser-renderable documents, instead of assuming a Linux desktop association. The browser carrier applies the same loopback, same-origin restriction as `host.pickDirectory`.
|
||||
|
||||
The `command.*` and `skill.*` domains expose the host command registry and skill catalog to clients. Every method addresses one session's agent by `sessionId` (a served session always has an Agent; `command.*` resumes cold sessions through the same path as `session.*`, while `skill.list` resolves the project root from the session header without touching the Agent registry). `skill.list` serves the composer's menu: it returns every user-invocable skill with its `modelInvocable` flag, so menus can mark user-only (`disable-model-invocation`) entries whose only entry point the slash gesture is. Listing is the skill domain's only RPC — invocation itself is an ordinary `session.prompt` whose whitespace-bounded `/name` tokens `dsh-tool-skill` recognizes at the pre-step boundary and answers with injected `<skill_content>` context, so every front end (web, TUI, ACP, hand-typed text) shares one deterministic path with no dedicated invocation wire. `command.execute` runs a slash-command line host-side with pure admission semantics: the response reports whether the line resolved to a handler plus the minted lifecycle `commandId` when it did (correlating the acknowledgment with the flow node), while the outcome rides the durably logged `command/run`/`command/done` lifecycle pair broadcast on the mux stream. Command handlers may legitimately outlast the 30-second transport health deadline, so `command.execute` carries only caller/connection cancellation; that signal cancels the running handler. `host/commands-changed` is the catalog invalidation frame: clients refetch `command.list` instead of diffing.
|
||||
The `agentPreset.list` domain exposes the deployment's preset roster so a browser can offer a choice when starting a session; each row carries its `trust` (a `user` preset is exactly as privileged as the plugins it names), whether it is the current default, and — when the preset cannot compose a session — a `broken` reason, because a damaged directory still occupies its id and a surface must be able to show and delete it rather than offer it and fail the session start. A deployment composing no presets answers with an empty roster rather than an error, because sharing the host composition is a valid deployment. `agentPreset.select` recomposes one session's agent from a different preset, and is allowed only while the session is blank: once a turn has run, that history was produced under the preset's tools and swapping them would strand logged tool calls, so the attempt answers `agent-preset-locked`. The agent and the session survive — only the composition is swapped, and a failed swap restores the previous one.
|
||||
|
||||
The `settings.*`, `credentials.*`, and `llm.*` domains are the configuration-page wire. The settings domain serves the namespaces addressed by registered configurable providers (`ctx.llm.listConfigurableProviders()`) plus a small explicit allowlist — the Web preference `permission` and the product-owned `ui-onboarding`; adding a Settings registration alone never makes it remotely readable or writable. Any other namespace answers `settings-not-exposed` — the same answer an unregistered namespace gets, so no caller can enumerate the registry by probing. `settings.describe` returns each exposed namespace's serialized schemastery schema, redacted layered values (resolved/`base`/`user` — a field's presence in `user` marks it user-overridden), the `secrets` slot list, the section's `revision`, and the boolean `hasDocument` capability flag. The browser receives no Host path: pathless `settings.openDocument` asks the provider to materialize its document and then hands the Host-resolved result to the native opener, so no browser payload can select an arbitrary filesystem target. `settings.update`/`settings.replace` write the user layer; `settings.mutate` applies path ops (`set`/`unset`) against the section as stored, which is the removal path for a client holding the redacted view — rebuilding a section from it and replacing wholesale would delete the secrets the wire never returned. Any write may carry `expectedRevision`; a stale one answers `settings-conflict` with both revisions rather than overwriting the writer that landed first, and every other seam refusal folds into `settings-rejected`. Secret-role values never ride any response in any layer; a secret crosses the wire in exactly one direction — inside an `update`/`mutate` payload or `credentials.set`. `credentials.describe` returns value-free views (`configured`/`source`/`writable`), and `credentials.set`/`credentials.unset` map a shadowed-reference refusal onto `credential-rejected`. `llm.providers` merges the configurable-provider directory with live routes (dormant entries carry `active: false`; undeclared live routes append with no settings address) and `llm.models` is the session-independent catalog. `llm.discoverModels` interrogates a provider endpoint the page is still drafting: `settingsNs` selects the adapter family that knows how to read the listing, and the endpoint, protocol, and key come from the form rather than from storage. It writes nothing — the reply is candidates, and only a later `settings.mutate` decides what a route serves — so its `apiKey` is the third payload on which a secret may ride, alongside `settings.update`/`mutate` and `credentials.set`. The host never stores or returns it; like the other two it does ride the client's outgoing envelope, which `subscribeEnvelopes()` observers can see, and redacting that tap is a configuration-plane-wide change rather than this method's to make alone. Every refusal (an unserved namespace, a protocol with no readable listing, an unreachable endpoint, a rejected credential) folds into `model-discovery-failed`, whose message is the adapter's own text and whose details name the endpoint asked but never the credential offered. Three invalidation frames keep every surface converged without polling: `host/settings-changed {ns}` (`settings/document-updated` passthrough, so a raw change whose resolved value is unchanged still reaches clients), `host/credentials-changed {ref}` (reference names only, never values), and `host/models-changed` — fired by `llm/adapters-updated` and by a change to a configurable-provider namespace, whose settings carry that provider's catalog and endpoint; a `permission` or `ui-onboarding` change emits only its settings invalidation. The browser carrier restricts the whole configuration plane, reads and native actions included (`settings.describe`/`openDocument`/`update`/`replace`/`mutate`, `credentials.describe`/`set`/`unset`), to loopback same-origin requests — the `host.pickDirectory` privileged set. A composition without a settings or credential provider answers those domains with an actionable `internal` error naming the missing plugin.
|
||||
`agentPreset.read`, `copy`, `openDocument`, and `remove` manage the compositions themselves. `read` reports the text with its `trust`, for the read-only viewer. Authoring is copy-only: `copy` takes `{ from, agentPreset, name? }` — two ids the Host resolves against its own roots plus an optional display name — and copies the source's whole directory, so no composition text crosses the wire and a copy is exactly as loadable as its source; an uncontainable or already-taken id answers `agent-preset-invalid`, and `remove` refuses a shipped preset as `agent-preset-read-only`. `openDocument` hands one locally authored preset's DIRECTORY to the platform opener — the request carries an id, never a path, so no browser payload can select an arbitrary filesystem target; where the deployment has no native opener the reply is `{ opened: false, path }` for the surface to show as text, a shipped preset is refused like `remove`, and the gateway's `nativeOpen` config pins the capability where platform detection (`canOpenNativePath`) would mislead. These four are loopback-pinned in [`dsh-client-connection`](../../client/connection/README.md): a composition names the plugins a session runs, so reading one is reconnaissance, and copy/remove/openDocument manage the roster and drive the host desktop. `list` and `select` stay ordinary — the roster carries ids and trust and every preset picker needs it, and choosing a preset grants nothing `session.create`'s own `agentPreset` did not, over a default that already carries bash. `list` reports two path-free capability flags: `authorable`, whether the deployment configures a root a new preset could be copied to, and `hasDocument`, whether `openDocument` would open natively rather than answer a path.
|
||||
|
||||
The `command.*` and `skill.*` domains expose the host command registry and skill catalog to clients. Every method addresses one session's agent by `sessionId` (a served session always has an Agent; `command.*` resumes cold sessions through the same path as `session.*`, while `skill.list` resolves the project root from the session header without touching the Agent registry). `skill.list` serves the composer's menu: it returns every user-invocable skill with its `modelInvocable` flag, so menus can mark user-only (`disable-model-invocation`) entries whose only invocation path is the slash gesture. Listing is the skill domain's only RPC — invocation itself is an ordinary `session.prompt` whose whitespace-bounded `/name` tokens `dsh-tool-skill` recognizes at the pre-step boundary and answers with injected `<skill_content>` context, so every entry point (Web, TUI, and ACP) shares one deterministic path—including for hand-typed text—with no dedicated invocation wire. `command.execute` runs a slash-command line host-side with pure admission semantics: the response reports whether the line resolved to a handler plus the minted lifecycle `commandId` when it did (correlating the acknowledgment with the flow node), while the outcome rides the durably logged `command/run`/`command/done` lifecycle pair broadcast on the mux stream. Command handlers may legitimately outlast the 30-second transport health deadline, so `command.execute` carries only caller/connection cancellation; that signal cancels the running handler. `host/commands-changed` is the registry-wide catalog invalidation frame: clients refetch `command.list` instead of diffing. `host/session-preset-changed` is its per-session counterpart, framed off the logged `agent-preset/selected` commit: recomposing a blank session's agent re-parents its scope without registering anything, so both catalogs that session's composition decides (`command.list`, `skill.list`) go stale with no registry change to announce it.
|
||||
|
||||
The `settings.*`, `credentials.*`, and `llm.*` domains are the configuration-page wire. The settings domain serves the namespaces addressed by registered configurable providers (`ctx.llm.listConfigurableProviders()`) plus a small explicit allowlist — the Web preferences `locale`, `permission`, `ui-conversation`, and `ui-theme`, and the product-owned `ui-onboarding`; adding a Settings registration alone never makes it remotely readable or writable. Any other namespace answers `settings-not-exposed` — the same answer an unregistered namespace gets, so no caller can enumerate the registry by probing. `settings.describe` returns each exposed namespace's serialized schemastery schema, redacted layered values (resolved/`base`/`user` — a field's presence in `user` marks it user-overridden), the `secrets` slot list, the section's `revision`, and the boolean `hasDocument` capability flag. The browser receives no Host path: pathless `settings.openDocument` asks the provider to materialize its document and then hands the Host-resolved result to the native opener, so no browser payload can select any filesystem target. `settings.update`/`settings.replace` write the user layer; `settings.mutate` applies path ops (`set`/`unset`) against the section as stored, which is the removal path for a client holding the redacted view — rebuilding a section from it and replacing wholesale would delete the secrets the wire never returned. Any write may carry `expectedRevision`; a stale one answers `settings-conflict` with both revisions rather than overwriting the writer that landed first, and every other seam refusal folds into `settings-rejected`. Secret-role values never ride any response in any layer; a secret crosses the wire in exactly one direction — inside an `update`/`mutate` payload or `credentials.set`. `credentials.describe` returns value-free views (`configured`/`source`/`writable`), and `credentials.set`/`credentials.unset` map a shadowed-reference refusal onto `credential-rejected`. `llm.providers` merges the configurable-provider directory with live routes (dormant entries carry `active: false`; undeclared live routes append with no settings address) and `llm.models` is the session-independent catalog. `llm.discoverModels` interrogates a provider endpoint the page is still drafting: `settingsNs` selects the adapter family that knows how to read the listing, and the endpoint, protocol, and key come from the form rather than from storage. It writes nothing — the reply is candidates, and only a later `settings.mutate` decides what a route serves — so its `apiKey` is the third payload on which a secret may ride, alongside `settings.update`/`mutate` and `credentials.set`. The host never stores or returns it; like the other two it does ride the client's outgoing envelope, which `subscribeEnvelopes()` observers can see, and redacting that tap is a configuration-plane-wide change rather than this method's to make alone. Every refusal (an unserved namespace, a protocol with no readable listing, an unreachable endpoint, a rejected credential) folds into `model-discovery-failed`, whose message is the adapter's own text and whose details name the endpoint asked but never the credential offered. Three invalidation frames keep every surface converged without polling: `host/settings-changed {ns}` (`settings/document-updated` passthrough, so a raw change whose resolved value is unchanged still reaches clients), `host/credentials-changed {ref}` (reference names only, never values), and `host/models-changed` — fired by `llm/adapters-updated` and by a change to a configurable-provider namespace, whose settings carry that provider's catalog and endpoint; a `locale`, `permission`, `ui-conversation`, `ui-theme`, or `ui-onboarding` change emits only its settings invalidation. The browser carrier restricts the whole configuration plane, reads and native actions included (`settings.describe`/`openDocument`/`update`/`replace`/`mutate`, `credentials.describe`/`set`/`unset`), to loopback same-origin requests — the `host.pickDirectory` privileged set. A composition without a settings or credential provider answers those domains with an actionable `internal` error naming the missing plugin.
|
||||
|
||||
## Carrier layer (`/client` + root)
|
||||
|
||||
`AbstractApiClient` holds every protocol invariant — rpcId minting, envelope wrap/unwrap, zod parsing, SSE frame decoding, unary timeout, microtask-batched envelope observation (`subscribeEnvelopes`) — while platform subclasses supply only the `doFetch` transport aspect. `InProcessApiClient` over `toFetchHandler(api)` remains the isomorphic point for callers and carrier tests that need the full wire serialization/validation path without a network. Product `dsh run` is a direct core front door and does not mount this package.
|
||||
`AbstractApiClient` holds every protocol invariant — rpcId minting, envelope wrap/unwrap, zod parsing, SSE frame decoding, unary timeout, microtask-batched envelope observation (`subscribeEnvelopes`) — while platform subclasses supply only the `doFetch` transport aspect. `InProcessApiClient` over `toFetchHandler(api)` remains the isomorphic point for callers and carrier tests that need the full wire serialization/validation path without a network. Product `dsh --profile headless` is a direct core entry point and does not mount this package.
|
||||
|
||||
## Model Experience
|
||||
|
||||
@@ -66,7 +74,7 @@ None; this package neither assembles nor sends a provider request.
|
||||
|
||||
## Known Limitations and Deferred Work
|
||||
|
||||
- **Pending-interaction state is host-side** — the wire shape is POST `/api/respond` plus `RpcReceipt`; the table in `src/api-proxy.ts` handles questions only and has no approval entries.
|
||||
- **Pending-interaction state is host-side** — the wire uses POST `/api/respond` plus `RpcReceipt`; the table in `src/api-proxy.ts` handles questions only and has no approval entries.
|
||||
- **Reserved seams stay out of `RpcMethodMap`** — `prompt.mode: 'inject'`, `task.list`, and a describe `hostInstanceId` are documented reservations; model discovery uses `llm.models`. An unknown method fails loud at envelope parse rather than getting a not-implemented code.
|
||||
- **No protocol version field** — client and host ship together; `host.describe` gains a version negotiation field only when an independently released client exists.
|
||||
- **Search failures include provider diagnostics** — the gateway is a single-user local service. A carrier that exposes it to multiple users must replace internal search details with a public-safe diagnostic.
|
||||
|
||||
@@ -2,11 +2,11 @@
|
||||
|
||||
[English](README.md) | 中文
|
||||
|
||||
所有客户端形态共用的 API 网关:TS 约定(`src/api/`,不依赖 Node,可从浏览器导入)、fetch 载体对(`src/fetch/`:宿主侧的 `toFetchHandler`,以及客户端侧的 `AbstractApiClient` 与平台子类)和宿主侧实现(`src/api-proxy.ts`:`createApiProxy` 加上默认导出的 `ApiProxyService` 网关插件,其配置为 `{workspaceRoot?}`,提供 `ctx.apiProxy`)。该包在设计上与传输方式无关,不注册任何路由;HTTP 等载体自行包装 `ctx.apiProxy`。随发行版交付的 Web 组合位于 [`packages/bundle/web-app/cordis.patch.yml`](../../bundle/web-app/cordis.patch.yml),其默认 Agent(智能体)模型选择属于 base 组合包中的 [`@deepseek-ai/dsh-agent-default-model`](../../core/agent-default-model/README.md)。
|
||||
所有客户端共用的 API 网关由三部分组成:TypeScript API 约定(`src/api/`,不依赖 Node,可从浏览器导入)、fetch 载体对(`src/fetch/`:宿主侧的 `toFetchHandler`,以及客户端侧的 `AbstractApiClient` 与平台子类)和宿主侧实现(`src/api-proxy.ts`:`createApiProxy` 加上默认导出的 `ApiProxyService` 网关插件,其配置为 `{nativeOpen?, sessionExportCompressionLevel?}`,提供 `ctx.apiProxy`)。该包不注册任何路由;HTTP 等载体自行包装 `ctx.apiProxy`。随发行版交付的 Web 组合位于 [`packages/bundle/web-app/cordis.patch.yml`](../../bundle/web-app/cordis.patch.yml),其默认 Agent(智能体)模型选择属于 base 组合包中的 [`@deepseek-ai/dsh-agent-default-model`](../../core/agent-default-model/README.md)。
|
||||
|
||||
## 共享 Agent 默认值(`agent-default-model` Settings 分节)
|
||||
|
||||
`ApiProxyService` 消费 `ctx.agentDefaultModel`;它不持有提供方/模型配置或 Settings 分节。共享服务在 `agent-default-model` 下注册 `{provider, model, reasoningEffort?}`:base 组合包的组合条目是底层,`settings.yaml` 把用户选择叠加其上。`workspaceRoot` 仍属于 ApiProxy 配置,因为它是 Host 启动器事实,而不是模型偏好。
|
||||
`ApiProxyService` 消费 `ctx.agentDefaultModel`;它不持有提供方/模型配置或 Settings 分节。共享服务在 `agent-default-model` 下注册 `{provider, model, reasoningEffort?}`:base 组合包的组合条目是底层,`settings.yaml` 把用户选择叠加其上。
|
||||
|
||||
会话每次访问时都按三级解析模型选择:本进程内作出的选择,其次是该会话日志中最新的 `request/header`,最后是这个默认值。已经跑过一轮的会话从自己的日志推导选择,空白会话则能观察到创建之后保存的默认值。
|
||||
|
||||
@@ -28,17 +28,21 @@ Settings 分节中的 `reasoningEffort` 在 agent-default-model 插件配置中
|
||||
|
||||
`session.history` 的尾页(不带 `beforeSeq`)额外携带一个可选的 `projections` 块——`ctx.sessionProjections`(`@deepseek-ai/dsh-session-projection`)上每个已注册单元的水位线快照,`asOfSeq` = 这些值共同反映到的最后一个事件 seq(空日志为 `-1`)。网关还订阅注册表的变更流,为每个状态发生变化的单元生成一个 `session/projection` mux 帧(`{sessionId, key, value, seq}`——实时推送状态,绝不入日志;客户端按 seq 高者胜维护一个按会话的通用值仓)。载体不持有任何领域知识(每个值在注册表内部已过其单元自己的 schema;协议 schema 对 `values`/`value` 保持宽松);loadOlder 页永不携带该块,未装注册表的组合则两个面都不提供。
|
||||
|
||||
会话日志导出是宿主侧的下载面,不是 RPC:`GET /api/session.export?sessionId=…&includeDescendants=true` 流式返回一个 ZIP,其中每个文件都是会话存储工件的逐字原文(持久化后端的 `readRaw`——按物理编码解码的确切持久化字节,绝非从解析后事件重建),根会话放在其原始基础文件名下,每个子代理后代放在 `subagents/<id>/` 下,每个被任何包含的日志引用的图片放在 `media/<attachmentId>.<ext>` 下(从附件存储读取并校验;共享图片只出现一次)。每个实时根会话或后代都会在读取原始工件前立即通过权威的 `SessionStore.flush` 持久性屏障;冷会话没有需要 flush 的内存工作。压缩在宿主侧使用 fflate 流式 Zip API 和已验证的 `sessionExportCompressionLevel` 0–9(默认 6),使部署可以在 CPU/延迟与归档大小之间取舍;响应边生成边分块写出,宿主从不把整个归档放进单个缓冲区。响应队列达到 64 KiB 字节高水位后,生产会等待 Consumer pull 恢复正容量;fflate 的同步回调最多只会让该界限多出一次有界输入 push 的输出。请求中止或响应 body 取消会停止血缘与工件工作、终止活跃压缩器,并继续按取消传播,而不会变成 HTTP 500。它要求同时挂载持久化、session-query 与附件服务:任一缺失应答 500,持久化后端不提供每会话原始工件时应答 501,根会话缺失时应答 404,后代缺少存储工件或引用的图片无法读取则整个流失败(fail-loud,绝不静默少导出)。端点由传输层挂载,`ApiProxy.downloads.sessionLog` 实现它。
|
||||
|
||||
会话标题与其他所有领域一样搭乘这对通用投影机制——历史尾页的 `projections` 块外加 `title` 键下的 `session/projection` 帧。标题不会加入 `session.list`;冷会话在其中仍只有元数据,直到打开或恢复操作附加其日志。`session.rename` 接受用户显式标题(冷会话先恢复),委托给 `ctx.sessionTitle.rename`——被接受的 `session/title` 事件将标题钉住、不再被自动生成覆盖——并返回规范化后的标题及其事件 seq,让 client 在推送帧到达前就结算自己的 `title` 投影格;规范化后为空的标题返回 `title-invalid`。
|
||||
|
||||
`session.fork` 将可选事件锚点映射到该锚点处或其后的首个 `turn/end`,使消息操作可包含该消息所在的完整轮次。锚点省略或超过末尾时,选择最后一个已完成轮次;若锚点已在日志中,而其所在轮次仍开放,则返回 `fork-unavailable`,不会向较早位置裁剪。发布后的子会话会先继承源会话的种子历史、cwd、日志中最新的 `ModelSelection` 及谱系,再加入源 Workspace。如果附加到 Workspace 失败,`workspace-attach-failed` 会携带已发布的子会话 id,供客户端对账。[SessionStore fork 决策](../../../.agents/notes/implemented/feature/2026-06-30-session-store-fork-api.md)给出边界设计的理由。
|
||||
`session.fork` 将可选事件锚点映射到该锚点处或其后的首个 `turn/end`,使消息操作可包含该消息所在的完整轮次。锚点省略或超过末尾时,选择最后一个已完成轮次;若锚点已在日志中,而其所在轮次仍开放,则返回 `fork-unavailable`,不会向较早位置裁剪。发布后的子会话会先继承源会话的种子历史、cwd、日志中最新的 `ModelSelection` 及谱系,再加入源 Workspace。如果附加到 Workspace 失败,`workspace-attach-failed` 会携带已发布的子会话 id,供客户端对账。[SessionStore fork 决策](../../../.agents/notes/implemented/feature/2026-06-30-session-store-fork-api.md)记录了为何锚点要映射到该 `turn/end`。
|
||||
|
||||
会话模型选择属于会话领域约定。`session.models` 将当前 `ModelSelection` 与按提供方分组的建议性模型、精确模型的推理(reasoning)元数据和逐提供方查询失败记录分开返回。该选择可能不在这些分组中,也绝不会作为合成行注入;客户端可以提示用户作出另一项选择,而无需把目录变成路由白名单。`session.selectModel` 校验由适配器持有的可选推理强度,并指定将在下一提示词组装边界使用的完整选择。目录成员关系不构成校验:适配器可以解析未列出的模型,而不可用的提供方或不受支持的推理强度会返回 `model-unavailable`。`session.models` 还会报告 `routable`,即当前是否有适配器为所选提供方提供服务。该值刻意不从分组推导,因为适配器可以服务未公布的模型。`session.prompt` 会依据同一事实,在开启轮次之前以 `model-unavailable` 拒绝;客户端禁用 composer 只是提示性设计,这个方法始终可被调用。
|
||||
会话模型选择属于会话领域约定。`session.models` 将当前 `ModelSelection` 与按提供方分组的建议性模型、精确模型的推理(reasoning)元数据和逐提供方查询失败记录分开返回。该选择可能不在这些分组中,也绝不会作为合成行注入;客户端可以提示用户作出另一项选择,而无需把目录变成路由白名单。`session.selectModel` 校验由适配器持有的可选推理强度,并指定下次组装提示词时使用的完整选择。目录成员关系不构成校验:适配器可以解析未列出的模型,而不可用的提供方或不受支持的推理强度会返回 `model-unavailable`。`session.models` 还会报告 `routable`,即当前是否有适配器为所选提供方提供服务。该值刻意不从分组推导,因为适配器可以服务未公布的模型。`session.prompt` 会依据同一事实,在开启轮次之前以 `model-unavailable` 拒绝;客户端禁用 composer 只是提示性设计,这个方法始终可被调用。
|
||||
|
||||
`session.prompt` 和 `subagent.prompt` 接受可选的请求本地 `clientTimeZone` 来源信息。若提供该值,Host 会在进入 Agent 前校验 `UTC` 或 IANA Area/Location 并将其规范化;无效输入以 `invalid-time-zone` 拒绝,规范值则与 `rpcId` 一起记录在这条确切的 `user-rpc` 消息上。该值不属于 Session、连接、create、resume 或 fork 状态;非浏览器调用方可以省略它。
|
||||
|
||||
待处理的 queued 输入属于实时控制平面约定,而非对话历史。网关根据持久 `agent/inbox/spliced` 变更派生完整的 `next-turn` 队列,并在每次变更后及重连时广播权威 `session/queue` 快照;待处理的 `next-step` steering(中途引导)不进入此 Web 投影。在 `next-step` 内,用户来源的消息携带 `steering` placement,而注入上下文(审批通知、任务完成、附加快照)携带 `context`,领取前不对外呈现。面向单条消息的 `agent/inbox/inserted`、`claimed` 与 `discarded` 通知仍供生命周期观察方使用,但不用于构建队列视图。`session.updateQueue` 通过 `MessageId` 寻址单个项;编辑和移除经已挂载 Agent 的 `Inbox.splice()` 修改队列。claim 的纯删除 splice 会在 pre-step 准入前赢得竞态,因此之后的操作返回 `queue-item-not-found`。`session.cancel` 仅中止活动轮次并保留待处理 inbox 工作;取消达到完全停稳且结束中的轮次完成 flush 后,AgentLoop 按 FIFO 顺序认领下一条可唤醒消息,浏览器绝不重发或提升它。队列操作绝不恢复冷会话,客户端也绝不根据轮次或状态事件推断某项已退出队列。
|
||||
|
||||
Workspace 列表与 Session 列表是相互独立的重连基线。`workspace.create({ name })` 会在配置根目录下创建显示标题唯一的目录,而 `workspace.create({ path })` 会接纳已有的规范目录,并允许由 basename 派生的标题重复。`workspace.delete` 只移除 Workspace 注册记录,`session.create` 接受可选的预分配 Session id,`host/workspace-changed`、`host/workspace-removed` 与 `host/session-added` 则以任意到达顺序携带已提交的增量。`workspace.archiveSession` 向注册表级全局归档集合添加一个会话,并应答完整的更新后集合;`workspace.list` 携带该集合作为重连基线,`host/archived-sessions-changed` 在每次持久变更后推送完整快照。归档只把会话从各分组视图中隐藏,不触碰其日志和 workspace 记账;既非实时也未持久化的会话以 `session-not-found` 失败。删除注册记录会保留目录和会话日志;相关 Session 仍留在 `session.list` 中,并进入 Ungrouped。`SessionSummary.blank` 与 `host/session-added` 帧携带派生的零事件位:客户端隐藏空白会话并按 workspace 复用它们,在首个 `host/session-status(running:true)` 时翻转 blank,并以 `session.list` 作为重连权威;冷会话摘要永远不是空白:惰性持久化让从未追加过事件的会话根本不出现在 `list()` 中。
|
||||
后台任务沿用同一种实时推送姿态。当组合中有 `ctx.tasks` 时,网关订阅它的变更订阅,并在注册表每一次改变某个会话可见内容的提交后——注册、转入 stopping、结算,以及 owner 销毁时的移除——广播一份完整的 `session/tasks` 快照,另外为每个已经有任务的会话发送订阅 baseline(没有 baseline 即表示空集;把集合清空的那次变更仍然发送 `[]`)。带 owner 的变更通过那个确切的 `Agent` 读取,因此推送在其 scope 拆除期间依然正确;baseline 读 `ctx.agents.get(sessionId)`,对没有活体 Agent 的会话只得到无主任务,且绝不恢复冷会话。无主变更向每一个已订阅会话扇出,因为无主任务对所有调用方可见。线路上的 `TaskView` 丢弃 `ownerSession`、`reported` 和 `outputLimitBytes`:第一个由帧自身的 `sessionId` 携带,另外两个分别是内部通知位和模型呈现策略。没有该注册表的组合不发出这类帧。
|
||||
|
||||
Workspace 列表与 Session 列表是相互独立的重连基线。`workspace.create({ path })` 会接纳已有的规范目录,并允许由 basename 派生的标题重复。`workspace.delete` 只移除 Workspace 注册记录,`session.create` 接受可选的预分配 Session id,`host/workspace-changed`、`host/workspace-removed` 与 `host/session-added` 则以任意到达顺序携带已提交的增量。`workspace.archiveSession` 向注册表级全局归档集合添加一个会话,并应答完整的更新后集合;`workspace.list` 携带该集合作为重连基线,`host/archived-sessions-changed` 在每次持久变更后推送完整快照。归档只把会话从各分组视图中隐藏,不触碰其日志和 workspace 记账;既非实时也未持久化的会话以 `session-not-found` 失败。删除注册记录会保留目录和会话日志;相关 Session 仍留在 `session.list` 中,并进入 Ungrouped。`SessionSummary.blank` 与 `host/session-added` 帧携带派生的零事件位:客户端隐藏空白会话并按 workspace 复用它们,在首个 `host/session-status(running:true)` 时翻转 blank,并以 `session.list` 作为重连权威;冷会话摘要永远不是空白:惰性持久化让从未追加过事件的会话根本不出现在 `list()` 中。
|
||||
|
||||
`session.search` 是以 `session.list` 所列会话为范围的有界内容搜索投影。网关向可选的 `ctx.sessionQuery` 服务请求全局排序后的当前内容视图中的 user、assistant 和 steering 匹配项,并持续消费该结果流,直到获得至多 20 个可见会话/snippet 对及一个前瞻项;返回前仍会依据从列表推导的授权集合重新校验每个命中。提供方分页初始请求 20 个命中;如果第一页请求因这一上限被拒绝,网关会依次探测 10、5、2、1,并在续传和陈旧世代重启中沿用探测所得的页面大小。返回的 snippet 最多包含 240 个 Unicode 码点,响应 schema 则会在每个客户端边界独立强制执行该上限。将授权集合保留在宿主内存中,可在不削弱可见性或排序的前提下避开有效大型语料库的 SQLite 变量上限。
|
||||
|
||||
@@ -48,17 +52,21 @@ Workspace 列表与 Session 列表是相互独立的重连基线。`workspace.cr
|
||||
|
||||
`host.openPath` 会用操作系统的默认应用打开一个文件系统路径(macOS 为 `open`,Windows 为 `Invoke-Item`,桌面 Linux 为 `xdg-open`)。对于 `.html`、`.htm`、`.xhtml` 与 `.svg`,macOS 和桌面 Linux 会优先使用能够确定的默认浏览器;无法确定时回退到上述应用交接。WSL 会通过 `wslpath -w` 转换每个 Linux 路径,并将所得 Windows/UNC 路径交给 Windows `Invoke-Item`,浏览器可渲染的文档也不例外,而非假定存在 Linux 桌面文件关联。浏览器载体对其施加与 `host.pickDirectory` 相同的回环、同源限制。
|
||||
|
||||
`command.*` 与 `skill.*` 领域向客户端暴露宿主命令注册表和 skill(技能)目录。每个方法都通过 `sessionId` 寻址一个会话的 Agent(被服务的会话必有 Agent;`command.*` 经由与 `session.*` 相同的路径恢复冷会话,而 `skill.list` 从会话头解析项目根目录,不触碰 Agent 注册表)。`skill.list` 服务于 composer 的菜单:它返回每一个用户可调用的 skill 及其 `modelInvocable` 标志,让菜单能够标出仅限用户(`disable-model-invocation`)的条目——斜杠手势是这类条目唯一的入口。列表是 skill 领域唯一的 RPC——调用本身就是一次普通的 `session.prompt`,`dsh-tool-skill` 会在 pre-step 边界识别其中以空白为界的 `/name` token,并以注入的 `<skill_content>` 上下文作答,因此每一种前端(web、TUI、ACP(Agent Client Protocol)、手动键入的文本)共享同一条确定性路径,没有专设的调用协议。`command.execute` 在宿主侧运行一条斜杠命令行,语义为纯准入:响应报告该行是否解析到处理器,并在解析到时回带铸造的生命周期 `commandId`(将本次确认与流节点关联);结局经由持久落账并在 mux 流广播的 `command/run`/`command/done` 生命周期事件对承载。命令处理器运行超过 30 秒的传输健康时限仍属正常,因此 `command.execute` 仅携带调用方/连接取消信号;该信号可取消正在运行的处理器。`host/commands-changed` 是目录失效帧:客户端重新拉取 `command.list` 而不是做差分。
|
||||
`agentPreset.list` 领域向浏览器暴露部署的 preset 名单,使其在开启会话时能够提供选择;每一行携带它的 `trust`(`user` preset 的权限恰好等于它所引用的插件)、它是否为当前默认值,以及——当该 preset 无法组装会话时——一条 `broken` 原因:损坏的目录仍占着它的 id,界面必须能展示并删除它,而不是把它端出来然后在会话启动时失败。未组装任何 preset 的部署返回空名单而非错误,因为共用宿主组装本身就是一种有效部署。`agentPreset.select` 用另一个 preset 重组某个会话的 agent,且仅在会话空白时允许:一旦跑过任何轮次,那段历史就是在该 preset 的工具下产生的,替换会留下无法执行的已记录 tool call,此时返回 `agent-preset-locked`。agent 与会话都不销毁——只替换组装,且替换失败会恢复原来的组装。
|
||||
|
||||
`settings.*`、`credentials.*` 与 `llm.*` 领域是配置页协议。settings 领域服务于已注册可配置提供方所指向的 namespace(`ctx.llm.listConfigurableProviders()`),并额外服务于一份小型、显式的 allowlist——Web 偏好 `permission` 与产品持有的 `ui-onboarding`;仅新增一项 Settings 注册,绝不会使其可被远程读取或写入。其他任何 namespace 都只会得到 `settings-not-exposed`——未注册的 namespace 得到的是同一个答复,因此没有调用方能靠逐个探测把注册表枚举出来。`settings.describe` 为每个已暴露 namespace 提供其序列化 schemastery schema、脱敏后的分层值(resolved/`base`/`user`——字段出现在 `user` 中即标记其被用户覆盖)、`secrets` 槽位列表、该分节的 `revision`,以及布尔型 `hasDocument` 能力标志。浏览器不会收到 Host 路径:无路径参数的 `settings.openDocument` 会请求提供方准备文档,再把由 Host 解析出的结果交给原生打开器,因此任何浏览器载荷都无法选择任意文件系统目标。`settings.update`/`settings.replace` 写入用户层;`settings.mutate` 则在已存分节上施加路径 op(`set`/`unset`),这是持有脱敏视图的客户端的删除路径——据此重建分节再整体替换,会删掉协议从未回传过的那些机密。任何写入都可携带 `expectedRevision`;陈旧的期望值会以 `settings-conflict` 连同两个 revision 作答,而不是覆盖先落地的那个写方,其余每种 seam 拒绝则折叠为 `settings-rejected`。secret 角色的值绝不在任何一层搭乘任何响应;secret 只沿一个方向跨越协议——在 `update`/`mutate` 载荷或 `credentials.set` 之内。`credentials.describe` 返回不含值的视图(`configured`/`source`/`writable`),`credentials.set`/`credentials.unset` 则把被遮蔽引用的拒绝映射为 `credential-rejected`。`llm.providers` 把可配置提供方目录与存活路由合并(休眠条目携带 `active: false`;未声明的存活路由追加在后,不带 settings 地址),`llm.models` 则是与会话无关的目录。`llm.discoverModels` 询问页面尚在起草的提供方端点:`settingsNs` 选出懂得读取该列表的适配器家族,端点、协议与密钥则来自表单而非存储。它什么都不写——回复是候选,只有随后的 `settings.mutate` 才决定路由服务什么——因此其 `apiKey` 是 secret 可以搭乘的第三个载荷(另两个是 `settings.update`/`mutate` 与 `credentials.set`),且绝不被存储或回显。host 从不存储或回传它;与另两者一样,它确实会搭乘客户端的出站信封,`subscribeEnvelopes()` 的观察者能看到——为该 tap 做脱敏是整个配置面的改动,而非本方法一家的事。每一种拒绝(无人服务的 namespace、没有可读列表的协议、不可达端点、被拒凭据)都折叠为 `model-discovery-failed`,其消息是适配器自己的文本,details 点名被询问的端点,绝不点名所提供的凭据。三个失效帧让每个面无需轮询即保持收敛:`host/settings-changed {ns}`(`settings/document-updated` 透传,因此解析值未变的原始变更同样能到达客户端)、`host/credentials-changed {ref}`(只带引用名,绝不带值),以及 `host/models-changed`——它由 `llm/adapters-updated` 和可配置提供方 namespace 的变更触发,因为该提供方的设置正承载着它的目录与端点;`permission` 或 `ui-onboarding` 变更只会发出自身的 settings 失效通知。浏览器载体把整个配置面(含读取与原生操作:`settings.describe`/`openDocument`/`update`/`replace`/`mutate` 与 `credentials.describe`/`set`/`unset`)限制为仅接受来自回环地址的同源请求——即 `host.pickDirectory` 所在的特权集合。未装 settings 或凭据 provider 的组合会以指名缺失插件、包含解决建议的 `internal` 错误应答这些领域。
|
||||
`agentPreset.read`、`copy`、`openDocument` 与 `remove` 负责管理组装本身。`read` 返回文本连同它的 `trust`,供只读查看器使用。创作只有复制一种写入:`copy` 接收 `{ from, agentPreset, name? }`——两个由 Host 对照自身根目录解析的 id 加一个可选显示名——并整目录复制来源,因此组装文本不经过传输层,副本与其来源同等可加载;不可约束或已被占用的 id 回答 `agent-preset-invalid`,`remove` 对随附 preset 回答 `agent-preset-read-only`。`openDocument` 把一个本地创作 preset 的**目录**交给平台打开器——请求只携带 id、绝不携带路径,因此没有任何浏览器载荷能选中任意文件系统目标;部署没有原生打开器时回答 `{ opened: false, path }` 供界面以文本展示,随附 preset 与 `remove` 一样被拒绝,而网关的 `nativeOpen` 配置可在平台探测(`canOpenNativePath`)失真处钉死该能力。这四个方法在 [`dsh-client-connection`](../../client/connection/README.md) 中被固定在环回地址:组装指明了一个会话所运行的插件,因此读取它是侦察,而 copy/remove/openDocument 管理名单并驱动宿主桌面。`list` 与 `select` 保持为普通方法——名单只携带 id 与信任级别,每个 preset 选择器都需要它;而选择一个 preset 并不比 `session.create` 自带的 `agentPreset` 多给任何能力,何况默认 preset 本就带着 bash。`list` 报告两个不含路径的能力标志:`authorable`,即部署是否配置了可供复制新 preset 的根目录;`hasDocument`,即 `openDocument` 会原生打开、还是回答一个路径。
|
||||
|
||||
`command.*` 与 `skill.*` 领域向客户端暴露宿主命令注册表和 skill(技能)目录。每个方法都通过 `sessionId` 寻址一个会话的 Agent(被服务的会话必有 Agent;`command.*` 经由与 `session.*` 相同的路径恢复冷会话,而 `skill.list` 从会话头解析项目根目录,不触碰 Agent 注册表)。`skill.list` 服务于 composer 的菜单:它返回每一个用户可调用的 skill 及其 `modelInvocable` 标志,让菜单能够标出仅限用户(`disable-model-invocation`)的条目——斜杠手势是这类条目唯一的调用路径。列表是 skill 领域唯一的 RPC——调用本身就是一次普通的 `session.prompt`,`dsh-tool-skill` 会在 pre-step 边界识别其中以空白为界的 `/name` token,并以注入的 `<skill_content>` 上下文作答,因此所有入口(Web、TUI 与 ACP(Agent Client Protocol))共享同一条确定性路径,手动键入的文本也走该路径,且没有专设的调用协议。`command.execute` 在宿主侧运行一条斜杠命令行,语义为纯准入:响应报告该行是否解析到处理器,并在解析到时回带铸造的生命周期 `commandId`(将本次确认与流节点关联);结局经由持久落账并在 mux 流广播的 `command/run`/`command/done` 生命周期事件对承载。命令处理器运行超过 30 秒的传输健康时限仍属正常,因此 `command.execute` 仅携带调用方/连接取消信号;该信号可取消正在运行的处理器。`host/commands-changed` 是注册表级目录失效帧:客户端重新拉取 `command.list` 而不是做差分。`host/session-preset-changed` 是它按会话粒度的对应物,由落账的 `agent-preset/selected` 提交点成帧:重组空会话的 agent 只是重新挂接其 scope,不产生任何注册,因此该会话组成所决定的两份目录(`command.list`、`skill.list`)都会失效,却没有任何注册表变化来宣告它。
|
||||
|
||||
`settings.*`、`credentials.*` 与 `llm.*` 领域是配置页协议。settings 领域服务于已注册可配置提供方所指向的 namespace(`ctx.llm.listConfigurableProviders()`),并额外服务于一份小型、显式的 allowlist——Web 偏好 `locale`、`permission`、`ui-conversation` 与 `ui-theme`,以及产品持有的 `ui-onboarding`;仅新增一项 Settings 注册,绝不会使其可被远程读取或写入。其他任何 namespace 都只会得到 `settings-not-exposed`——未注册的 namespace 得到的是同一个答复,因此没有调用方能靠逐个探测把注册表枚举出来。`settings.describe` 为每个已暴露 namespace 提供其序列化 schemastery schema、脱敏后的分层值(resolved/`base`/`user`——字段出现在 `user` 中即标记其被用户覆盖)、`secrets` 槽位列表、该分节的 `revision`,以及布尔型 `hasDocument` 能力标志。浏览器不会收到 Host 路径:无路径参数的 `settings.openDocument` 会请求提供方准备文档,再把由 Host 解析出的结果交给原生打开器,因此任何浏览器载荷都无法选择任意文件系统目标。`settings.update`/`settings.replace` 写入用户层;`settings.mutate` 则在已存分节上施加路径 op(`set`/`unset`),这是持有脱敏视图的客户端的删除路径——据此重建分节再整体替换,会删掉协议从未回传过的那些机密。任何写入都可携带 `expectedRevision`;陈旧的期望值会以 `settings-conflict` 连同两个 revision 作答,而不是覆盖先落地的那个写方,其余每种 seam 拒绝则折叠为 `settings-rejected`。secret 角色的值绝不在任何一层搭乘任何响应;secret 只沿一个方向跨越协议——在 `update`/`mutate` 载荷或 `credentials.set` 之内。`credentials.describe` 返回不含值的视图(`configured`/`source`/`writable`),`credentials.set`/`credentials.unset` 则把被遮蔽引用的拒绝映射为 `credential-rejected`。`llm.providers` 把可配置提供方目录与存活路由合并(休眠条目携带 `active: false`;未声明的存活路由追加在后,不带 settings 地址),`llm.models` 则是与会话无关的目录。`llm.discoverModels` 询问页面尚在起草的提供方端点:`settingsNs` 选出懂得读取该列表的适配器家族,端点、协议与密钥则来自表单而非存储。它什么都不写——回复是候选,只有随后的 `settings.mutate` 才决定路由服务什么——因此其 `apiKey` 是 secret 可以搭乘的第三个载荷(另两个是 `settings.update`/`mutate` 与 `credentials.set`),且绝不被存储或回显。host 从不存储或回传它;与另两者一样,它确实会搭乘客户端的出站信封,`subscribeEnvelopes()` 的观察者能看到——为该 tap 做脱敏是整个配置面的改动,而非本方法一家的事。每一种拒绝(无人服务的 namespace、没有可读列表的协议、不可达端点、被拒凭据)都折叠为 `model-discovery-failed`,其消息是适配器自己的文本,details 点名被询问的端点,绝不点名所提供的凭据。三个失效帧让每个面无需轮询即保持收敛:`host/settings-changed {ns}`(`settings/document-updated` 透传,因此解析值未变的原始变更同样能到达客户端)、`host/credentials-changed {ref}`(只带引用名,绝不带值),以及 `host/models-changed`——它由 `llm/adapters-updated` 和可配置提供方 namespace 的变更触发,因为该提供方的设置正承载着它的目录与端点;`locale`、`permission`、`ui-conversation`、`ui-theme` 或 `ui-onboarding` 变更只会发出自身的 settings 失效通知。浏览器载体把整个配置面(含读取与原生操作:`settings.describe`/`openDocument`/`update`/`replace`/`mutate` 与 `credentials.describe`/`set`/`unset`)限制为仅接受来自回环地址的同源请求——即 `host.pickDirectory` 所在的特权集合。未装 settings 或凭据 provider 的组合会以指名缺失插件、包含解决建议的 `internal` 错误应答这些领域。
|
||||
|
||||
## 载体层(`/client` + 根路径)
|
||||
|
||||
`AbstractApiClient` 持有全部协议不变量:签发 rpcId、包装/解包信封、Zod 解析、SSE 帧解码、一元请求超时,以及按微任务批处理的信封观测(`subscribeEnvelopes`);平台子类只提供 `doFetch` 传输环节。`InProcessApiClient` 以 `toFetchHandler(api)` 为基础,仍是同构接点:它运行完整的协议序列化与校验路径而不经过网络,供需要该路径的调用方和载体测试使用。产品的 `dsh run` 是直连 core 的入口,不挂载本包。
|
||||
`AbstractApiClient` 持有全部协议不变量:签发 rpcId、包装/解包信封、Zod 解析、SSE 帧解码、一元请求超时,以及按微任务批处理的信封观测(`subscribeEnvelopes`);平台子类只提供 `doFetch` 传输环节。`InProcessApiClient` 以 `toFetchHandler(api)` 为基础,仍是同构接点:它运行完整的协议序列化与校验路径而不经过网络,供需要该路径的调用方和载体测试使用。产品的 `dsh --profile headless` 是直连 core 的入口,不挂载本包。
|
||||
|
||||
## 模型体验
|
||||
|
||||
无。该包定义客户端与宿主间的协议约定和载体,其中没有任何内容会进入模型请求。
|
||||
无。该包定义客户端与宿主间的 wire 约定和载体,其中没有任何内容会进入模型请求。
|
||||
|
||||
#### KV Cache 影响
|
||||
|
||||
@@ -66,7 +74,7 @@ Workspace 列表与 Session 列表是相互独立的重连基线。`workspace.cr
|
||||
|
||||
## 已知限制与暂缓事项
|
||||
|
||||
- **待处理交互状态位于宿主侧**:协议形状为 POST `/api/respond` 加 `RpcReceipt`;`src/api-proxy.ts` 中的表只处理问题,不包含审批条目。
|
||||
- **待处理交互状态位于宿主侧**:wire 使用 POST `/api/respond` 加 `RpcReceipt`;`src/api-proxy.ts` 中的表只处理问题,不包含审批条目。
|
||||
- **预留 seam 不进入 `RpcMethodMap`**:`prompt.mode: 'inject'`、`task.list` 和描述字段 `hostInstanceId` 都是已记录的预留项;模型发现使用 `llm.models`。未知方法会在信封解析时直接失败,而不会返回「尚未实现」错误码。
|
||||
- **没有协议版本字段**:客户端与宿主一同发布;只有出现独立发布的客户端后,`host.describe` 才会增加版本协商字段。
|
||||
- **搜索失败会包含提供方诊断信息**:网关是单用户本地服务。将其暴露给多名用户的载体必须用可安全公开的诊断信息替代内部搜索细节。
|
||||
|
||||
@@ -1,8 +1,15 @@
|
||||
{
|
||||
"name": "@deepseek-ai/dsh-host-apiproxy",
|
||||
"description": "API gateway: the ApiProxy contract (api/), the fetch carrier pair (fetch/), and the host-side gateway plugin providing ctx.apiProxy",
|
||||
"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/host/apiproxy"
|
||||
},
|
||||
"type": "module",
|
||||
"main": "lib/index.js",
|
||||
"types": "lib/types/index.d.ts",
|
||||
@@ -38,6 +45,7 @@
|
||||
],
|
||||
"license": "BSD-3-Clause",
|
||||
"dependencies": {
|
||||
"@deepseek-ai/dsh-attachment": "workspace:^",
|
||||
"@deepseek-ai/dsh-agent": "workspace:^",
|
||||
"@deepseek-ai/dsh-agent-default-model": "workspace:^",
|
||||
"@deepseek-ai/dsh-api-remotes": "workspace:^",
|
||||
@@ -57,23 +65,27 @@
|
||||
"@deepseek-ai/dsh-settings": "workspace:^",
|
||||
"@deepseek-ai/dsh-skill": "workspace:^",
|
||||
"@deepseek-ai/dsh-subagent": "workspace:^",
|
||||
"@deepseek-ai/dsh-tasks": "workspace:^",
|
||||
"@deepseek-ai/dsh-tools": "workspace:^",
|
||||
"@deepseek-ai/dsh-user-approval": "workspace:^",
|
||||
"@deepseek-ai/dsh-user-interaction": "workspace:^",
|
||||
"@deepseek-ai/dsh-workspace": "workspace:^",
|
||||
"schemastery": "^3.18.0",
|
||||
"@deepseek-ai/schemastery": "workspace:^",
|
||||
"fflate": "^0.8.2",
|
||||
"zod": "^4.4.3"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"cordis": "^4.0.0-rc.7",
|
||||
"@deepseek-ai/dsh-invariants": "^0.0.1"
|
||||
"@deepseek-ai/dsh-agent-presets": "workspace:^",
|
||||
"@deepseek-ai/dsh-invariants": "workspace:^",
|
||||
"@deepseek-ai/cordis": "workspace:^"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@deepseek-ai/dsh-agent-presets": "workspace:^",
|
||||
"@deepseek-ai/dsh-invariants": "workspace:^",
|
||||
"@deepseek-ai/dsh-storage": "workspace:^",
|
||||
"@deepseek-ai/dsh-storage-domain": "workspace:^",
|
||||
"@deepseek-ai/dsh-type-meta": "workspace:^",
|
||||
"@deepseek-ai/dsh-typert-registry": "workspace:^",
|
||||
"cordis": "^4.0.0-rc.7",
|
||||
"@deepseek-ai/dsh-invariants": "workspace:^"
|
||||
"@deepseek-ai/cordis": "workspace:^"
|
||||
}
|
||||
}
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
88
packages/host/apiproxy/src/api/agent-presets.schema.ts
Normal file
88
packages/host/apiproxy/src/api/agent-presets.schema.ts
Normal file
@@ -0,0 +1,88 @@
|
||||
/**
|
||||
* agent-presets domain zod schemas (names derived from map keys:
|
||||
* agentPresetListRequestSchema / agentPresetListValueSchema).
|
||||
*/
|
||||
|
||||
import { z } from 'zod'
|
||||
import type { RequestPayload, ResponseValue } from './rpc-map.ts'
|
||||
import type { Wire } from './rpc.schema.ts'
|
||||
import { sessionIdSchema } from './sessions.schema.ts'
|
||||
import type { AgentPresetEntry } from './agent-presets.ts'
|
||||
|
||||
/** AgentPresetEntry row of agentPreset.list. */
|
||||
export const agentPresetEntrySchema = z.object({
|
||||
id: z.string().min(1),
|
||||
trust: z.union([z.literal('system'), z.literal('user')]),
|
||||
isDefault: z.boolean(),
|
||||
name: z.string().optional(),
|
||||
description: z.string().optional(),
|
||||
broken: z.string().min(1).optional(),
|
||||
}) satisfies z.ZodType<Wire<AgentPresetEntry>>
|
||||
|
||||
/** agentPreset.list request payload. */
|
||||
export const agentPresetListRequestSchema = z.object({
|
||||
}) satisfies z.ZodType<Wire<RequestPayload<'agentPreset.list'>>>
|
||||
|
||||
/** agentPreset.list response value. */
|
||||
export const agentPresetListValueSchema = z.object({
|
||||
presets: z.array(agentPresetEntrySchema),
|
||||
authorable: z.boolean(),
|
||||
hasDocument: z.boolean(),
|
||||
}) satisfies z.ZodType<Wire<ResponseValue<'agentPreset.list'>>>
|
||||
|
||||
/** agentPreset.select request payload. */
|
||||
export const agentPresetSelectRequestSchema = z.object({
|
||||
sessionId: sessionIdSchema,
|
||||
agentPreset: z.string().min(1),
|
||||
}) satisfies z.ZodType<Wire<RequestPayload<'agentPreset.select'>>>
|
||||
|
||||
/** agentPreset.select response value. */
|
||||
export const agentPresetSelectValueSchema = z.object({
|
||||
agentPreset: z.string(),
|
||||
}) satisfies z.ZodType<Wire<ResponseValue<'agentPreset.select'>>>
|
||||
|
||||
/** agentPreset.read request payload. */
|
||||
export const agentPresetReadRequestSchema = z.object({
|
||||
agentPreset: z.string().min(1),
|
||||
}) satisfies z.ZodType<Wire<RequestPayload<'agentPreset.read'>>>
|
||||
|
||||
/** agentPreset.read response value. */
|
||||
export const agentPresetReadValueSchema = z.object({
|
||||
agentPreset: z.string(),
|
||||
trust: z.union([z.literal('system'), z.literal('user')]),
|
||||
content: z.string(),
|
||||
name: z.string().optional(),
|
||||
description: z.string().optional(),
|
||||
}) satisfies z.ZodType<Wire<ResponseValue<'agentPreset.read'>>>
|
||||
|
||||
/** agentPreset.copy request payload. */
|
||||
export const agentPresetCopyRequestSchema = z.object({
|
||||
from: z.string().min(1),
|
||||
agentPreset: z.string().min(1),
|
||||
name: z.string().optional(),
|
||||
}) satisfies z.ZodType<Wire<RequestPayload<'agentPreset.copy'>>>
|
||||
|
||||
/** agentPreset.copy response value. */
|
||||
export const agentPresetCopyValueSchema = z.object({
|
||||
agentPreset: z.string(),
|
||||
}) satisfies z.ZodType<Wire<ResponseValue<'agentPreset.copy'>>>
|
||||
|
||||
/** agentPreset.openDocument request payload. */
|
||||
export const agentPresetOpenDocumentRequestSchema = z.object({
|
||||
agentPreset: z.string().min(1),
|
||||
}) satisfies z.ZodType<Wire<RequestPayload<'agentPreset.openDocument'>>>
|
||||
|
||||
/** agentPreset.openDocument response value. */
|
||||
export const agentPresetOpenDocumentValueSchema = z.union([
|
||||
z.object({ opened: z.literal(true) }),
|
||||
z.object({ opened: z.literal(false), path: z.string() }),
|
||||
]) satisfies z.ZodType<Wire<ResponseValue<'agentPreset.openDocument'>>>
|
||||
|
||||
/** agentPreset.remove request payload. */
|
||||
export const agentPresetRemoveRequestSchema = z.object({
|
||||
agentPreset: z.string().min(1),
|
||||
}) satisfies z.ZodType<Wire<RequestPayload<'agentPreset.remove'>>>
|
||||
|
||||
/** agentPreset.remove response value. */
|
||||
export const agentPresetRemoveValueSchema = z.object({
|
||||
}) satisfies z.ZodType<Wire<ResponseValue<'agentPreset.remove'>>>
|
||||
116
packages/host/apiproxy/src/api/agent-presets.ts
Normal file
116
packages/host/apiproxy/src/api/agent-presets.ts
Normal file
@@ -0,0 +1,116 @@
|
||||
/**
|
||||
* agent-presets domain contract: the roster a browser offers when starting a
|
||||
* session, plus the authoring calls behind it.
|
||||
*
|
||||
* `list` is ordinary: it carries ids and trust, and every preset picker needs
|
||||
* it. The authoring calls are privileged and loopback-pinned — a composition
|
||||
* names the plugins a session runs, so reading one is reconnaissance, and
|
||||
* although authoring is copy-only (no caller supplies composition text or a
|
||||
* path), copying and deleting still rearrange what the deployment offers.
|
||||
*/
|
||||
|
||||
import type { SessionId } from '@deepseek-ai/dsh-session/types'
|
||||
import type { RpcRequest, RpcResponse } from './rpc.ts'
|
||||
|
||||
/** One preset the deployment can compose a session's agent from. */
|
||||
export interface AgentPresetEntry {
|
||||
/** Stable identifier, also the display name until presets carry metadata. */
|
||||
readonly id: string
|
||||
/**
|
||||
* Whether the preset ships with the deployment or was authored locally.
|
||||
* A `user` preset is exactly as privileged as the plugins it names, so a
|
||||
* surface offering one should say so rather than present it as vetted.
|
||||
*/
|
||||
readonly trust: 'system' | 'user'
|
||||
/** Whether a session that names no preset gets this one. */
|
||||
readonly isDefault: boolean
|
||||
/**
|
||||
* Display name the preset published, absent when it published none. A
|
||||
* surface falls back to {@link id}; it is never a second identity, and it
|
||||
* never decides trust — a locally authored preset cannot name itself into
|
||||
* the shipped set.
|
||||
*/
|
||||
readonly name?: string
|
||||
/** One sentence on what the preset is for, when it published one. */
|
||||
readonly description?: string
|
||||
/**
|
||||
* Why this preset cannot compose a session, absent when it can. A broken
|
||||
* preset stays listed — its directory still occupies the id, so a surface
|
||||
* must be able to show and delete it — but offering it for selection would
|
||||
* only defer this reason to a failed session start.
|
||||
*/
|
||||
readonly broken?: string
|
||||
}
|
||||
|
||||
/** agent-preset-domain unary methods (the map key agentPreset.* of RpcMethodMap). */
|
||||
export interface AgentPresetsApi {
|
||||
/**
|
||||
* Lists every preset the deployment currently supplies, in root-precedence
|
||||
* order — the roots as configured, each root's own presets sorted by id,
|
||||
* and the first root to supply an id wins. The order is not globally
|
||||
* sorted: a user root's preset sits in that root's block, not among the
|
||||
* shipped ids.
|
||||
* An empty roster means the deployment composes no presets at all, and
|
||||
* every session shares the host composition. `authorable` reports whether
|
||||
* the deployment configures a root new presets can be written to, and
|
||||
* `hasDocument` whether `openDocument` can hand a preset directory to a
|
||||
* native opener — both deployment facts rather than per-preset ones, and
|
||||
* neither exposes a Host path.
|
||||
*/
|
||||
list(request: RpcRequest<{}>):
|
||||
Promise<RpcResponse<{ presets: readonly AgentPresetEntry[]; authorable: boolean; hasDocument: boolean }>>
|
||||
|
||||
/**
|
||||
* Recompose one session's agent from a different preset.
|
||||
*
|
||||
* Allowed only while the session is blank — no turn has run. Once a
|
||||
* conversation starts, its history was produced under that preset's tools,
|
||||
* and swapping them would leave logged tool calls the new composition cannot
|
||||
* make; the attempt answers `agent-preset-locked`.
|
||||
*/
|
||||
select(request: RpcRequest<{ sessionId: SessionId; agentPreset: string }>):
|
||||
Promise<RpcResponse<{ agentPreset: string }>>
|
||||
|
||||
/**
|
||||
* Read one preset's composition text, for the read-only viewer.
|
||||
*
|
||||
* Privileged: a composition names the plugins a session runs, so reading
|
||||
* one is reconnaissance.
|
||||
*/
|
||||
read(request: RpcRequest<{ agentPreset: string }>):
|
||||
Promise<RpcResponse<{
|
||||
agentPreset: string
|
||||
trust: 'system' | 'user'
|
||||
content: string
|
||||
name?: string
|
||||
description?: string
|
||||
}>>
|
||||
|
||||
/**
|
||||
* Create a locally authored preset by copying an existing one whole.
|
||||
*
|
||||
* The only authoring write. No composition text and no path crosses the
|
||||
* wire: `from` and `agentPreset` are ids the Host resolves against its own
|
||||
* roots, so a copy is exactly as loadable as its source and grants nothing
|
||||
* the roster did not already carry. The copy keeps the source's description
|
||||
* (the file is the author's to edit afterwards) but not its name — `name`
|
||||
* here or the id fallback is what distinguishes the rows.
|
||||
*/
|
||||
copy(request: RpcRequest<{ from: string; agentPreset: string; name?: string }>):
|
||||
Promise<RpcResponse<{ agentPreset: string }>>
|
||||
|
||||
/**
|
||||
* Hand one locally authored preset's DIRECTORY to the platform opener, for
|
||||
* editing the files that are now the only composition editor. The request
|
||||
* carries an id, never a path — the Host resolves it — so no browser
|
||||
* payload can select an arbitrary filesystem target. Where the deployment
|
||||
* has no native opener (`hasDocument: false` on `list`), the reply carries
|
||||
* the resolved directory for the surface to show as text instead. Shipped
|
||||
* presets are refused: their install is not the user's to manage.
|
||||
*/
|
||||
openDocument(request: RpcRequest<{ agentPreset: string }>, signal: AbortSignal):
|
||||
Promise<RpcResponse<{ opened: true } | { opened: false; path: string }>>
|
||||
|
||||
/** Delete a locally authored preset. Shipped presets are refused. */
|
||||
remove(request: RpcRequest<{ agentPreset: string }>): Promise<RpcResponse<{}>>
|
||||
}
|
||||
@@ -10,7 +10,7 @@ import type { ApprovalResponsePayload } from './approvals.ts'
|
||||
import type { Wire } from './rpc.schema.ts'
|
||||
import { sessionIdSchema } from './sessions.schema.ts'
|
||||
|
||||
/** ApprovalRequestId: one brand cast after shape validation (the only cast point in this domain). */
|
||||
/** ApprovalRequestId: one brand cast after schema validation (the only cast point in this domain). */
|
||||
export const approvalRequestIdSchema = z.string().min(1) as unknown as z.ZodType<ApprovalRequestId>
|
||||
|
||||
/** Approval answer payload (the result.value slot of a client-response). */
|
||||
|
||||
@@ -33,7 +33,7 @@ export const commandExecuteRequestSchema = z.object({
|
||||
line: z.string(),
|
||||
}) satisfies z.ZodType<Wire<RequestPayload<'command.execute'>>>
|
||||
|
||||
/** CommandId: one brand cast after shape validation (the only cast point in this domain). */
|
||||
/** CommandId: one brand cast after schema validation (the only cast point in this domain). */
|
||||
export const commandIdSchema = z.string().min(1) as unknown as z.ZodType<CommandId>
|
||||
|
||||
/** command.execute response value: pure admission — outcomes ride the logged
|
||||
|
||||
26
packages/host/apiproxy/src/api/downloads.schema.ts
Normal file
26
packages/host/apiproxy/src/api/downloads.schema.ts
Normal file
@@ -0,0 +1,26 @@
|
||||
/**
|
||||
* downloads domain zod schemas. The GET download surface has no wire
|
||||
* envelope: the request arrives as query parameters (all strings), so its
|
||||
* request schema parses the raw query-parameter object into the method's
|
||||
* exact request shape. SessionId brand cast point: sessionIdSchema, and only
|
||||
* there (hosted in sessions.schema like every other cast).
|
||||
*/
|
||||
|
||||
import { z } from 'zod'
|
||||
import type { DownloadsApi } from './downloads.ts'
|
||||
import { sessionIdSchema } from './sessions.schema.ts'
|
||||
|
||||
/**
|
||||
* session.export query params → the sessionLog request. `includeDescendants`
|
||||
* accepts exactly `true`/`false`/absent; any other value is rejected (400) so
|
||||
* a misspelled flag cannot silently under-export.
|
||||
*/
|
||||
export const sessionLogQuerySchema = z
|
||||
.object({
|
||||
sessionId: sessionIdSchema,
|
||||
includeDescendants: z.union([z.literal('true'), z.literal('false')]).optional(),
|
||||
})
|
||||
.transform(query => ({
|
||||
sessionId: query.sessionId,
|
||||
...(query.includeDescendants === 'true' ? { includeDescendants: true } : {}),
|
||||
})) satisfies z.ZodType<Parameters<DownloadsApi['sessionLog']>[0]>
|
||||
25
packages/host/apiproxy/src/api/downloads.ts
Normal file
25
packages/host/apiproxy/src/api/downloads.ts
Normal file
@@ -0,0 +1,25 @@
|
||||
/**
|
||||
* downloads domain contract: host-only download surfaces — the GET-download
|
||||
* channel family, the mirror of the SSE-stream `events` domain. No wire
|
||||
* envelope: the carrier's GET routes answer these directly, and the browser
|
||||
* `IApiClient` never exposes them.
|
||||
*/
|
||||
|
||||
import type { SessionId } from '@deepseek-ai/dsh-session/types'
|
||||
|
||||
/** Host-only download surfaces (no wire envelope; absent from IApiClient). */
|
||||
export interface DownloadsApi {
|
||||
/**
|
||||
* Stream one session-log ZIP — the root artifact verbatim plus each subagent
|
||||
* descendant's — as an attachment response. The carrier's GET route answers
|
||||
* this directly; the browser never calls it.
|
||||
* @param request - the root session id and whether to include descendants.
|
||||
* @param signal - cancellation for the underlying reads.
|
||||
* @returns the ZIP attachment response; missing services answer 500 and a
|
||||
* missing root session 404 before any byte is produced.
|
||||
*/
|
||||
sessionLog(
|
||||
request: { sessionId: SessionId; includeDescendants?: boolean },
|
||||
signal: AbortSignal,
|
||||
): Promise<Response>
|
||||
}
|
||||
@@ -13,9 +13,10 @@ import { approvalRequestIdSchema } from './approvals.schema.ts'
|
||||
import {
|
||||
contentBlockSchema, messageIdSchema, sessionEventSchema, sessionIdSchema, toolEventViewSchema,
|
||||
} from './sessions.schema.ts'
|
||||
import { taskViewSchema } from './tasks.schema.ts'
|
||||
import { workspaceIdSchema, workspaceViewSchema } from './workspace.schema.ts'
|
||||
|
||||
/** Question shape validated strictly against core dsh-user-interaction. */
|
||||
/** Question fields validated strictly against core dsh-user-interaction. */
|
||||
export const askUserQuestionItemSchema = z.object({
|
||||
id: z.string(),
|
||||
question: z.string(),
|
||||
@@ -58,6 +59,7 @@ export const muxFrameSchema = z.discriminatedUnion('type', [
|
||||
message: messageSchema,
|
||||
})),
|
||||
}),
|
||||
z.object({ type: z.literal('session/tasks'), sessionId: sessionIdSchema, tasks: z.array(taskViewSchema) }),
|
||||
// value stays wide: it already passed its unit's own schema on the host,
|
||||
// and deep-validating here would import every domain's schema into the carrier.
|
||||
z.object({ type: z.literal('session/projection'), sessionId: sessionIdSchema, key: z.string().min(1), value: z.unknown(), seq: z.number().int().nonnegative() }),
|
||||
@@ -73,6 +75,7 @@ export const hostFrameSchema = z.discriminatedUnion('type', [
|
||||
parentSessionId: sessionIdSchema.optional(),
|
||||
origin: z.literal('subagent').optional(),
|
||||
cwd: z.string().optional(),
|
||||
agentPreset: z.string().optional(),
|
||||
}),
|
||||
z.object({ type: z.literal('host/session-removed'), sessionId: sessionIdSchema }),
|
||||
z.object({ type: z.literal('host/session-status'), sessionId: sessionIdSchema, running: z.boolean() }),
|
||||
@@ -81,6 +84,7 @@ export const hostFrameSchema = z.discriminatedUnion('type', [
|
||||
z.object({ type: z.literal('host/workspace-removed'), workspaceId: workspaceIdSchema }),
|
||||
z.object({ type: z.literal('host/archived-sessions-changed'), archivedSessionIds: z.array(sessionIdSchema) }),
|
||||
z.object({ type: z.literal('host/commands-changed') }),
|
||||
z.object({ type: z.literal('host/session-preset-changed'), sessionId: sessionIdSchema, agentPreset: z.string() }),
|
||||
z.object({ type: z.literal('host/settings-changed'), ns: z.string() }),
|
||||
z.object({ type: z.literal('host/credentials-changed'), ref: z.string() }),
|
||||
z.object({ type: z.literal('host/models-changed') }),
|
||||
|
||||
@@ -14,6 +14,7 @@ import type { CallId } from '@deepseek-ai/dsh-llm/brand'
|
||||
import type { SessionEvent, SessionId } from '@deepseek-ai/dsh-session/types'
|
||||
import type { ToolCallView, ToolResultView } from '@deepseek-ai/dsh-tools/presentation'
|
||||
import type { RpcError, RpcId, RpcRequest } from './rpc.ts'
|
||||
import type { TaskView } from './tasks.ts'
|
||||
import type { WorkspaceView } from './workspace.ts'
|
||||
|
||||
// Client-side consumers take the render-intent vocabulary from the contract;
|
||||
@@ -81,6 +82,20 @@ export type MuxFrame =
|
||||
* in QueueDock, while pending steering renders at the conversation tail.
|
||||
*/
|
||||
| { type: 'session/queue'; sessionId: SessionId; items: QueuedInboxItem[] }
|
||||
/**
|
||||
* Complete set of background tasks this session can see, after every registry
|
||||
* commit that changes it: registration, the stopping transition, settlement,
|
||||
* and owner-disposal removal. The registry is process-local and holds no
|
||||
* durable event, so — exactly like `session/queue` — the whole snapshot is
|
||||
* what makes a start, a kill, a reconnect, and a second tab converge on one
|
||||
* authoritative value.
|
||||
*
|
||||
* Sent as a subscription baseline only for a session that currently has
|
||||
* tasks; an absent key means an empty set. A change that empties the set
|
||||
* still sends `[]`, since that transition is the only one absence cannot
|
||||
* express.
|
||||
*/
|
||||
| { type: 'session/tasks'; sessionId: SessionId; tasks: TaskView[] }
|
||||
/**
|
||||
* One projection unit's finished value changed (session-projection RFC).
|
||||
* Live push state, never logged — replay recomputes on the host (the
|
||||
@@ -116,6 +131,7 @@ export type HostFrame =
|
||||
parentSessionId?: SessionId
|
||||
origin?: 'subagent'
|
||||
cwd?: string
|
||||
agentPreset?: string
|
||||
}
|
||||
| { type: 'host/session-removed'; sessionId: SessionId }
|
||||
| { type: 'host/session-status'; sessionId: SessionId; running: boolean }
|
||||
@@ -129,6 +145,18 @@ export type HostFrame =
|
||||
* background rather than diffing.
|
||||
*/
|
||||
| { type: 'host/commands-changed' }
|
||||
/**
|
||||
* One blank session was recomposed onto another agent preset (the logged
|
||||
* `agent-preset/selected` commit point, read off the session stream). The
|
||||
* registry-wide `host/commands-changed` cannot stand in for it: recomposing
|
||||
* re-parents that agent's scope without registering anything, so a
|
||||
* preset already mounted for another session produces no registry change
|
||||
* at all. Clients refetch the catalogs this session's composition decides
|
||||
* (`command.list`, `skill.list`) for this sessionId alone, and fold the
|
||||
* preset id into their session row — the RPC echo reaches only the client
|
||||
* that issued the switch, so the row is where every other one learns it.
|
||||
*/
|
||||
| { type: 'host/session-preset-changed'; sessionId: SessionId; agentPreset: string }
|
||||
/**
|
||||
* One settings namespace's resolved value changed (`settings/updated`
|
||||
* passthrough) — an RPC write, an external `settings.yaml` edit, or a
|
||||
|
||||
@@ -17,8 +17,6 @@ export const hostDescribeValueSchema = z.object({
|
||||
provider: z.string().optional(),
|
||||
model: z.string().optional(),
|
||||
attachedSessions: z.number().int().nonnegative(),
|
||||
// Open string, not a literal union: unknown kinds must survive the wire so
|
||||
// a merge-added capability can advertise (the client hides the affordance).
|
||||
}) satisfies z.ZodType<Wire<ResponseValue<'host.describe'>>>
|
||||
|
||||
/** host.pickDirectory request payload (empty object literal). */
|
||||
|
||||
@@ -8,6 +8,7 @@ import type { SessionsApi } from './sessions.ts'
|
||||
import type { HostApi } from './host.ts'
|
||||
import type { WorkspaceApi } from './workspace.ts'
|
||||
import type { CommandsApi } from './commands.ts'
|
||||
import type { AgentPresetsApi } from './agent-presets.ts'
|
||||
import type { SkillsApi } from './skills.ts'
|
||||
import type { SubagentsApi } from './subagents.ts'
|
||||
import type { EventsApi } from './events.ts'
|
||||
@@ -15,9 +16,10 @@ import type { GoalsApi } from './goals.ts'
|
||||
import type { SettingsApi } from './settings.ts'
|
||||
import type { CredentialsApi } from './credentials.ts'
|
||||
import type { LlmApi } from './llm.ts'
|
||||
import type { DownloadsApi } from './downloads.ts'
|
||||
import type { ClientResponse, RpcReceipt } from './rpc.ts'
|
||||
|
||||
/** Root interface of the unified API surface. New client-request domain = one new file pair + one field here + one map row. */
|
||||
/** Root interface of the unified API. New client-request domain = one new file pair + one field here + one map row. */
|
||||
export interface ApiProxy {
|
||||
sessions: SessionsApi
|
||||
subagents: SubagentsApi
|
||||
@@ -25,11 +27,14 @@ export interface ApiProxy {
|
||||
workspace: WorkspaceApi
|
||||
commands: CommandsApi
|
||||
skills: SkillsApi
|
||||
agentPresets: AgentPresetsApi
|
||||
events: EventsApi
|
||||
goals: GoalsApi
|
||||
settings: SettingsApi
|
||||
credentials: CredentialsApi
|
||||
llm: LlmApi
|
||||
/** Host-only download surfaces (GET, no wire envelope); absent from IApiClient. */
|
||||
downloads: DownloadsApi
|
||||
/** Response entry for server-requests (client-response, echoing their rpcId); not a domain method (four-quadrant model). */
|
||||
respond(message: ClientResponse): Promise<RpcReceipt>
|
||||
}
|
||||
@@ -37,22 +42,25 @@ export interface ApiProxy {
|
||||
// ---- Domain interfaces and payload entities ----
|
||||
export type {
|
||||
HistoryEntry, ModelCatalogFailure, ModelCatalogModel, ModelProviderGroup, ModelReasoning,
|
||||
ModelReasoningEffort, ModelSelection, QueueAction, SessionModels, SessionProjectionsBlock, SessionSearchItem,
|
||||
SessionsApi, SessionSummary,
|
||||
ModelReasoningEffort, ModelSelection, PromptContentPart, QueueAction, SessionModels,
|
||||
SessionProjectionsBlock, SessionSearchItem, SessionsApi, SessionSummary,
|
||||
} from './sessions.ts'
|
||||
export type { DirectoryEntry, DirectoryListing, HostApi } from './host.ts'
|
||||
export type {
|
||||
SubagentAddress, SubagentCatalog, SubagentInterruptReceipt, SubagentListEntry,
|
||||
SubagentPromptReceipt, SubagentsApi,
|
||||
} from './subagents.ts'
|
||||
export type { TaskView } from './tasks.ts'
|
||||
export type { WorkspaceApi, WorkspaceId, WorkspaceView } from './workspace.ts'
|
||||
export type { CommandsApi, CommandDescriptor } from './commands.ts'
|
||||
export type { SkillsApi, SkillEntry } from './skills.ts'
|
||||
export type { AgentPresetsApi, AgentPresetEntry } from './agent-presets.ts'
|
||||
export type { EventsApi, MuxFrame, HostFrame, QueuedInboxItem, ToolCallView, ToolEventView, ToolResultView } from './events.ts'
|
||||
export type { GoalsApi, GoalId, GoalRef } from './goals.ts'
|
||||
export type { SettingsApi, SettingsNamespaceView, SettingsPathOpView, SettingsSecretView } from './settings.ts'
|
||||
export type { CredentialsApi, CredentialView } from './credentials.ts'
|
||||
export type { ConfigurableProviderView, DiscoveredModelView, LlmApi } from './llm.ts'
|
||||
export type { DownloadsApi } from './downloads.ts'
|
||||
export type { ApprovalResponsePayload } from './approvals.ts'
|
||||
|
||||
export type { QuestionResponsePayload } from './questions.ts'
|
||||
|
||||
@@ -8,6 +8,7 @@ import type { SessionsApi } from './sessions.ts'
|
||||
import type { HostApi } from './host.ts'
|
||||
import type { WorkspaceApi } from './workspace.ts'
|
||||
import type { CommandsApi } from './commands.ts'
|
||||
import type { AgentPresetsApi } from './agent-presets.ts'
|
||||
import type { SkillsApi } from './skills.ts'
|
||||
import type { GoalsApi } from './goals.ts'
|
||||
import type { SettingsApi } from './settings.ts'
|
||||
@@ -31,6 +32,7 @@ export interface RpcMethodMap {
|
||||
'session.rename': SessionsApi['rename']
|
||||
'session.fork': SessionsApi['fork']
|
||||
'session.prompt': SessionsApi['prompt']
|
||||
'session.attachment': SessionsApi['attachment']
|
||||
'session.updateQueue': SessionsApi['updateQueue']
|
||||
'session.cancel': SessionsApi['cancel']
|
||||
'subagent.list': SubagentsApi['list']
|
||||
@@ -51,6 +53,12 @@ export interface RpcMethodMap {
|
||||
'command.list': CommandsApi['list']
|
||||
'command.execute': CommandsApi['execute']
|
||||
'skill.list': SkillsApi['list']
|
||||
'agentPreset.list': AgentPresetsApi['list']
|
||||
'agentPreset.select': AgentPresetsApi['select']
|
||||
'agentPreset.read': AgentPresetsApi['read']
|
||||
'agentPreset.copy': AgentPresetsApi['copy']
|
||||
'agentPreset.openDocument': AgentPresetsApi['openDocument']
|
||||
'agentPreset.remove': AgentPresetsApi['remove']
|
||||
'goal.create': GoalsApi['create']
|
||||
'goal.edit': GoalsApi['edit']
|
||||
'goal.pause': GoalsApi['pause']
|
||||
|
||||
@@ -23,8 +23,8 @@ export type Wire<T> = T extends readonly (infer E)[] ? Wire<E>[]
|
||||
: T
|
||||
|
||||
/**
|
||||
* RpcId: one brand cast after shape validation (the only cast point in this
|
||||
* file). No min-length: the id is an opaque echo token, and rejecting shapes
|
||||
* RpcId: one brand cast after schema validation (the only cast point in this
|
||||
* file). No min-length: the id is an opaque echo token, and rejecting values
|
||||
* here would only turn a correlatable error report into a client-side parse
|
||||
* failure (the handler substitutes a sentinel when a request's id is unreadable).
|
||||
*/
|
||||
@@ -47,7 +47,13 @@ export const rpcErrorSchema: z.ZodType<RpcError> = z.discriminatedUnion('code',
|
||||
z.object({ code: z.literal('directory-exists'), message: z.string(), details: z.object({ path: z.string() }) }),
|
||||
z.object({ code: z.literal('directory-create-failed'), message: z.string(), details: z.object({ path: z.string() }) }),
|
||||
z.object({ code: z.literal('directory-picker-unavailable'), message: z.string(), details: z.object({ capability: z.string() }) }),
|
||||
z.object({ code: z.literal('agent-preset-read-only'), message: z.string(), details: z.object({ agentPreset: z.string(), reason: z.string() }) }),
|
||||
z.object({ code: z.literal('agent-preset-locked'), message: z.string(), details: z.object({ sessionId: z.string(), agentPreset: z.string() }) }),
|
||||
z.object({ code: z.literal('agent-preset-conflict'), message: z.string(), details: z.object({ sessionId: z.string(), requestedPreset: z.string(), existingPreset: z.string().optional() }) }),
|
||||
z.object({ code: z.literal('agent-preset-not-found'), message: z.string(), details: z.object({ agentPreset: z.string(), available: z.array(z.string()) }) }),
|
||||
z.object({ code: z.literal('agent-preset-invalid'), message: z.string(), details: z.object({ agentPreset: z.string(), reason: z.string() }) }),
|
||||
z.object({ code: z.literal('agent-busy'), message: z.string(), details: z.object({ reason: z.string() }) }),
|
||||
z.object({ code: z.literal('attachment-error'), message: z.string(), details: z.object({ reason: z.string() }) }),
|
||||
z.object({ code: z.literal('queue-item-not-found'), message: z.string(), details: z.object({ itemId: z.string() }) }),
|
||||
z.object({ code: z.literal('steer-unavailable'), message: z.string(), details: z.object({ itemId: z.string() }) }),
|
||||
z.object({ code: z.literal('command-error'), message: z.string(), details: z.object({}) }),
|
||||
|
||||
@@ -45,7 +45,13 @@ export interface RpcErrorDetailsMap {
|
||||
'directory-exists': { path: string }
|
||||
'directory-create-failed': { path: string }
|
||||
'directory-picker-unavailable': { capability: string }
|
||||
'agent-preset-read-only': { agentPreset: string; reason: string }
|
||||
'agent-preset-locked': { sessionId: SessionId; agentPreset: string }
|
||||
'agent-preset-conflict': { sessionId: SessionId; requestedPreset: string; existingPreset?: string }
|
||||
'agent-preset-not-found': { agentPreset: string; available: string[] }
|
||||
'agent-preset-invalid': { agentPreset: string; reason: string }
|
||||
'agent-busy': { reason: string }
|
||||
'attachment-error': { reason: string }
|
||||
'queue-item-not-found': { itemId: MessageId }
|
||||
'steer-unavailable': { itemId: MessageId }
|
||||
/** A known slash command reported a usage/state error; the message is the command's own text. */
|
||||
@@ -111,7 +117,7 @@ export type RpcResult<T> = { ok: true; value: T } | { ok: false; error: RpcError
|
||||
|
||||
/**
|
||||
* Fold a transport exception into the RpcResult error branch (unified error
|
||||
* surface; 'internal' as the catch-all code). Lives with RpcResult so every
|
||||
* API; 'internal' as the catch-all code). Lives with RpcResult so every
|
||||
* carrier consumer folds the same way.
|
||||
* @param error - the thrown value from the carrier.
|
||||
* @returns the error branch of an RpcResult.
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
/**
|
||||
* sessions domain zod schemas (names derived from map keys: sessionListRequestSchema /
|
||||
* sessionListValueSchema). SessionEvent passthrough = strict envelope (type/seq/time) + wide
|
||||
* data: the merge-extensible event surface keeps an unknown-type branch at the union level,
|
||||
* data: the merge-extensible event API keeps an unknown-type branch at the union level,
|
||||
* with no field-level passthrough. SessionId brand cast point: sessionIdSchema, and only there.
|
||||
*/
|
||||
|
||||
@@ -15,6 +15,7 @@ import type {
|
||||
ModelReasoningEffort, ModelSelection, SessionProjectionsBlock, SessionSearchItem, SessionSummary,
|
||||
} from './sessions.ts'
|
||||
import type { ToolEventView } from './events.ts'
|
||||
import type { AttachmentIdType, ImageAttachmentRef } from '@deepseek-ai/dsh-attachment'
|
||||
import type { WorkspaceId } from './workspace.ts'
|
||||
import {
|
||||
SESSION_SEARCH_RESULT_LIMIT,
|
||||
@@ -22,7 +23,7 @@ import {
|
||||
truncateUnicodeCodePoints,
|
||||
} from './session-search.ts'
|
||||
|
||||
/** SessionId: one brand cast after shape validation (the only cast point in this domain). */
|
||||
/** SessionId: one brand cast after schema validation (the only cast point in this domain). */
|
||||
export const sessionIdSchema = z.string().min(1) as unknown as z.ZodType<SessionId>
|
||||
|
||||
/** MessageId: one brand cast after non-empty string validation. */
|
||||
@@ -44,6 +45,7 @@ export const sessionEventSchema = z.object({
|
||||
data: z.unknown(),
|
||||
sourceEventSeqs: z.array(z.number()).optional(),
|
||||
surfaceOp: z.unknown().optional(),
|
||||
ignorable: z.literal(true).optional(),
|
||||
}) as unknown as z.ZodType<SessionEvent>
|
||||
|
||||
/** SessionSummary row of session.list (`projections` reuses the history block's shape and schema). */
|
||||
@@ -55,6 +57,7 @@ export const sessionSummarySchema = z.object({
|
||||
parentSessionId: sessionIdSchema.optional(),
|
||||
origin: z.literal('subagent').optional(),
|
||||
cwd: z.string().optional(),
|
||||
agentPreset: z.string().optional(),
|
||||
projections: z.lazy(() => sessionProjectionsBlockSchema).optional(),
|
||||
}) as unknown as z.ZodType<Wire<SessionSummary>>
|
||||
|
||||
@@ -100,6 +103,7 @@ export const sessionCreateRequestSchema = z.object({
|
||||
workspaceId: workspaceIdSchema.optional(),
|
||||
cwd: z.string().optional(),
|
||||
sessionId: sessionIdSchema.optional(),
|
||||
agentPreset: z.string().optional(),
|
||||
}).refine(
|
||||
payload => payload.workspaceId === undefined || payload.cwd === undefined,
|
||||
{ message: 'session.create accepts workspaceId or cwd, not both' },
|
||||
@@ -108,6 +112,7 @@ export const sessionCreateRequestSchema = z.object({
|
||||
/** session.create response value. */
|
||||
export const sessionCreateValueSchema = z.object({
|
||||
sessionId: sessionIdSchema,
|
||||
agentPreset: z.string().optional(),
|
||||
}) satisfies z.ZodType<Wire<ResponseValue<'session.create'>>>
|
||||
|
||||
/** session.rename request payload (raw title; host-side normalization decides acceptance). */
|
||||
@@ -246,11 +251,25 @@ export const sessionSelectModelValueSchema = z.object({
|
||||
/** ContentBlock passthrough: core is merge-extensible — the type discriminant envelope is strict, the rest stays wide. */
|
||||
export const contentBlockSchema = z.looseObject({ type: z.string() })
|
||||
|
||||
/** Raster image media types accepted by the version-one browser wire. */
|
||||
export const imageMediaTypeSchema = z.union([
|
||||
z.literal('image/png'),
|
||||
z.literal('image/jpeg'),
|
||||
z.literal('image/webp'),
|
||||
z.literal('image/gif'),
|
||||
])
|
||||
|
||||
/** Prompt wire content is intentionally narrower than merge-extensible durable core content. */
|
||||
export const promptContentPartSchema = z.discriminatedUnion('type', [
|
||||
z.object({ type: z.literal('text'), text: z.string() }),
|
||||
z.object({ type: z.literal('image'), mediaType: imageMediaTypeSchema, data: z.string(), name: z.string().optional() }),
|
||||
])
|
||||
|
||||
/** session.prompt request payload, including optional browser-local request provenance. */
|
||||
export const sessionPromptRequestSchema = z.object({
|
||||
sessionId: sessionIdSchema,
|
||||
mode: z.union([z.literal('queue'), z.literal('steer')]),
|
||||
content: z.array(contentBlockSchema),
|
||||
content: z.array(promptContentPartSchema),
|
||||
clientTimeZone: z.string().optional(),
|
||||
}) as unknown as z.ZodType<RequestPayload<'session.prompt'>>
|
||||
|
||||
@@ -263,6 +282,31 @@ export const sessionPromptValueSchema = z.object({
|
||||
}).optional(),
|
||||
}) satisfies z.ZodType<Wire<ResponseValue<'session.prompt'>>>
|
||||
|
||||
/** Opaque attachment id after string-shape validation. */
|
||||
export const attachmentIdSchema = z.string().min(1) as unknown as z.ZodType<AttachmentIdType>
|
||||
|
||||
/** Durable image reference returned from the authenticated session lookup. */
|
||||
export const imageAttachmentRefSchema = z.object({
|
||||
attachmentId: attachmentIdSchema,
|
||||
mediaType: imageMediaTypeSchema,
|
||||
bytes: z.number().int().positive(),
|
||||
width: z.number().int().positive(),
|
||||
height: z.number().int().positive(),
|
||||
name: z.string().optional(),
|
||||
}) as unknown as z.ZodType<ImageAttachmentRef>
|
||||
|
||||
/** session.attachment request payload. */
|
||||
export const sessionAttachmentRequestSchema = z.object({
|
||||
sessionId: sessionIdSchema,
|
||||
attachmentId: attachmentIdSchema,
|
||||
}) satisfies z.ZodType<Wire<RequestPayload<'session.attachment'>>>
|
||||
|
||||
/** session.attachment response value. */
|
||||
export const sessionAttachmentValueSchema = z.object({
|
||||
attachment: imageAttachmentRefSchema,
|
||||
data: z.string(),
|
||||
}) satisfies z.ZodType<Wire<ResponseValue<'session.attachment'>>>
|
||||
|
||||
/** session.updateQueue request payload. */
|
||||
export const sessionUpdateQueueRequestSchema = z.object({
|
||||
sessionId: sessionIdSchema,
|
||||
|
||||
@@ -5,6 +5,7 @@
|
||||
*/
|
||||
|
||||
import type { MessageId } from '@deepseek-ai/dsh-llm/brand'
|
||||
import type { AttachmentIdType, ImageAttachmentRef, ImageMediaType } from '@deepseek-ai/dsh-attachment'
|
||||
import type { ContentBlock } from '@deepseek-ai/dsh-llm/types'
|
||||
import type { SessionEvent, SessionId } from '@deepseek-ai/dsh-session/types'
|
||||
// The pure-type outlet: api/ is browser-importable, and the package root's
|
||||
@@ -54,6 +55,11 @@ export interface SessionProjectionsBlock {
|
||||
values: Partial<SessionProjectionMap>
|
||||
}
|
||||
|
||||
/** Browser-submitted prompt content; the host promotes image bytes to durable references. */
|
||||
export type PromptContentPart =
|
||||
| { type: 'text'; text: string }
|
||||
| { type: 'image'; mediaType: ImageMediaType; data: string; name?: string }
|
||||
|
||||
/** Complete model selection for one session. */
|
||||
export interface ModelSelection {
|
||||
/** Registered provider route. */
|
||||
@@ -166,6 +172,13 @@ export interface SessionSummary {
|
||||
origin?: 'subagent'
|
||||
/** Session working directory (header.cwd passthrough); absent when unrecorded. */
|
||||
cwd?: string
|
||||
/**
|
||||
* Agent preset this session's agent was composed from (header passthrough);
|
||||
* absent when the deployment composes no presets. A surface offering a
|
||||
* switch reads this to show what the session actually runs rather than what
|
||||
* the deployment currently defaults to.
|
||||
*/
|
||||
agentPreset?: string
|
||||
/**
|
||||
* Projection baseline for this row, with zero log loads: attached sessions
|
||||
* read the registry's live watermark cut; cold sessions read the persisted
|
||||
@@ -209,9 +222,16 @@ export interface SessionsApi {
|
||||
* session, while a different cwd fails with `session-conflict`. Workspace
|
||||
* creation attaches the session after publication; an attach failure
|
||||
* returns `workspace-attach-failed` with the published session id.
|
||||
*
|
||||
* `agentPreset` names the composition the new session's agent is built
|
||||
* from; omitted, the effective default applies — the user's stored choice
|
||||
* where one exists, else the deployment's own. The resolved id is stored on
|
||||
* the session header, so a later resume rebuilds the same agent. An unknown
|
||||
* id fails with `agent-preset-not-found`, and a preset whose composition
|
||||
* cannot be mounted fails with `agent-preset-invalid`.
|
||||
*/
|
||||
create(request: RpcRequest<{ workspaceId?: WorkspaceId; cwd?: string; sessionId?: SessionId }>):
|
||||
Promise<RpcResponse<{ sessionId: SessionId }>>
|
||||
create(request: RpcRequest<{ workspaceId?: WorkspaceId; cwd?: string; sessionId?: SessionId; agentPreset?: string }>):
|
||||
Promise<RpcResponse<{ sessionId: SessionId; agentPreset?: string }>>
|
||||
|
||||
/**
|
||||
* Reads a window of history events; page boundaries align to append-origin message
|
||||
@@ -290,7 +310,8 @@ export interface SessionsApi {
|
||||
Promise<RpcResponse<{ sessionId: SessionId }>>
|
||||
|
||||
/**
|
||||
* Sends a message to an ordinary session Agent. Browser callers attach their current IANA zone;
|
||||
* Sends text and temporary image bytes to an ordinary session Agent after durable host admission.
|
||||
* Browser callers attach their current IANA zone;
|
||||
* the Host validates, canonicalizes, and records it on that exact user message. Omission remains
|
||||
* valid for non-browser callers. Session-backed subagents reject with `agent-busy` and use
|
||||
* `subagent.prompt`.
|
||||
@@ -298,11 +319,15 @@ export interface SessionsApi {
|
||||
prompt(request: RpcRequest<{
|
||||
sessionId: SessionId
|
||||
mode: 'queue' | 'steer'
|
||||
content: ContentBlock[]
|
||||
content: PromptContentPart[]
|
||||
clientTimeZone?: string
|
||||
}>):
|
||||
Promise<RpcResponse<{ accepted: true; command?: { kind: 'success'; text?: string } }>>
|
||||
|
||||
/** Reads one durable image after proving that this session's log references its id. */
|
||||
attachment(request: RpcRequest<{ sessionId: SessionId; attachmentId: AttachmentIdType }>):
|
||||
Promise<RpcResponse<{ attachment: ImageAttachmentRef; data: string }>>
|
||||
|
||||
/**
|
||||
* Edits, removes, or strictly steers one pending queued occurrence on an ordinary session.
|
||||
* Session-backed subagents reject with `agent-busy`.
|
||||
|
||||
33
packages/host/apiproxy/src/api/tasks.schema.ts
Normal file
33
packages/host/apiproxy/src/api/tasks.schema.ts
Normal file
@@ -0,0 +1,33 @@
|
||||
/**
|
||||
* tasks domain zod schemas: the branded task id and the wire view carried by
|
||||
* `session/tasks` frames.
|
||||
*/
|
||||
|
||||
import { z } from 'zod'
|
||||
import type { TaskId } from '@deepseek-ai/dsh-tasks/brand'
|
||||
import type { TaskView } from './tasks.ts'
|
||||
import type { Wire } from './rpc.schema.ts'
|
||||
|
||||
/** TaskId: one brand cast after non-empty string validation. */
|
||||
export const taskIdSchema = z.string().min(1) as unknown as z.ZodType<TaskId>
|
||||
|
||||
/**
|
||||
* One wire task view. `kind` stays an open string because producer plugins
|
||||
* extend the registry's kind map by declaration merging, so the closed set is
|
||||
* not knowable at this boundary.
|
||||
*/
|
||||
export const taskViewSchema = z.object({
|
||||
id: taskIdSchema,
|
||||
kind: z.string().min(1),
|
||||
label: z.string().min(1),
|
||||
status: z.union([
|
||||
z.literal('running'),
|
||||
z.literal('stopping'),
|
||||
z.literal('completed'),
|
||||
z.literal('killed'),
|
||||
z.literal('failed'),
|
||||
]),
|
||||
detail: z.string().optional(),
|
||||
startedAt: z.number().int().nonnegative(),
|
||||
finishedAt: z.number().int().nonnegative().optional(),
|
||||
}) satisfies z.ZodType<Wire<TaskView>>
|
||||
36
packages/host/apiproxy/src/api/tasks.ts
Normal file
36
packages/host/apiproxy/src/api/tasks.ts
Normal file
@@ -0,0 +1,36 @@
|
||||
/**
|
||||
* Browser-safe background-task domain contract. The registry's live records
|
||||
* never cross the wire; a view is the subset a human list needs, minted fresh
|
||||
* per push.
|
||||
*/
|
||||
|
||||
import type { TaskId } from '@deepseek-ai/dsh-tasks/brand'
|
||||
|
||||
/**
|
||||
* One background task as the client sees it.
|
||||
*
|
||||
* Three registry fields are deliberately absent. `ownerSession` is redundant
|
||||
* beside the frame's own `sessionId`; `reported` is an internal notice-delivery
|
||||
* bit with no user meaning; `outputLimitBytes` is producer-owned model
|
||||
* presentation policy that never reaches a human surface.
|
||||
*/
|
||||
export interface TaskView {
|
||||
/** Registry-issued `<kind>-N` identity, stable for the task's whole life. */
|
||||
id: TaskId
|
||||
/**
|
||||
* Producer kind (`bash`, `pwsh`, `pty-send`, `subagent`, …). Kept as a bare
|
||||
* string because producer plugins extend the kind map by declaration merging,
|
||||
* so no client build can enumerate the closed set.
|
||||
*/
|
||||
kind: string
|
||||
/** Producer-supplied one-line label: the command, or the delegation description. */
|
||||
label: string
|
||||
/** Current lifecycle state. */
|
||||
status: 'running' | 'stopping' | 'completed' | 'killed' | 'failed'
|
||||
/** Kind-specific status detail ('exit code: 3'), present once the producer supplied one. */
|
||||
detail?: string
|
||||
/** Epoch ms when the task was registered. */
|
||||
startedAt: number
|
||||
/** Epoch ms when the task settled; absent while live. */
|
||||
finishedAt?: number
|
||||
}
|
||||
@@ -31,14 +31,10 @@ export const workspaceListValueSchema = z.object({
|
||||
archivedSessionIds: z.array(sessionIdSchema),
|
||||
}) satisfies z.ZodType<Wire<ResponseValue<'workspace.list'>>>
|
||||
|
||||
/** workspace.create request payload: exactly one of path/name (the contract's create spellings). */
|
||||
/** workspace.create request payload: the existing directory to adopt. */
|
||||
export const workspaceCreateRequestSchema = z.object({
|
||||
path: z.string().optional(),
|
||||
name: z.string().optional(),
|
||||
}).refine(
|
||||
payload => (payload.path === undefined) !== (payload.name === undefined),
|
||||
{ message: 'workspace.create requires exactly one of path / name' },
|
||||
) satisfies z.ZodType<Wire<RequestPayload<'workspace.create'>>>
|
||||
path: z.string(),
|
||||
}) satisfies z.ZodType<Wire<RequestPayload<'workspace.create'>>>
|
||||
|
||||
/** workspace.create response value. */
|
||||
export const workspaceCreateValueSchema = z.object({
|
||||
|
||||
@@ -46,19 +46,14 @@ export interface WorkspaceApi {
|
||||
list(request: RpcRequest<{}>): Promise<RpcResponse<{ items: WorkspaceView[]; archivedSessionIds: SessionId[] }>>
|
||||
|
||||
/**
|
||||
* Creates (or idempotently resolves) a workspace. Exactly one of `path` /
|
||||
* `name` (schema-enforced): `path` registers an EXISTING directory (no
|
||||
* mkdir — a missing or non-directory path fails with `workspace-invalid-path`);
|
||||
* `name` is a single path segment the host mkdirs under its default project
|
||||
* root before registering. Either spelling resolving to a directory already
|
||||
* owned by a workspace returns that workspace (`created: false`) for the
|
||||
* existing-folder spelling. Create-by-name rejects an existing title with
|
||||
* `workspace-name-conflict`; path adoption allows distinct canonical paths
|
||||
* whose basenames produce the same display title.
|
||||
* A new name-created workspace uses `name` as both directory name and title;
|
||||
* a path-created workspace uses the registry's basename title default.
|
||||
* Creates (or idempotently resolves) a workspace over an EXISTING directory
|
||||
* (no mkdir — a missing or non-directory path fails with
|
||||
* `workspace-invalid-path`). A path resolving to a directory already owned
|
||||
* by a workspace returns that workspace (`created: false`). Adoption allows
|
||||
* distinct canonical paths whose basenames produce the same display title;
|
||||
* the registry's basename title default names the new workspace.
|
||||
*/
|
||||
create(request: RpcRequest<{ path?: string; name?: string }>):
|
||||
create(request: RpcRequest<{ path: string }>):
|
||||
Promise<RpcResponse<{ workspace: WorkspaceView; created: boolean }>>
|
||||
|
||||
/**
|
||||
|
||||
@@ -19,6 +19,7 @@ import {
|
||||
} from '../api/host.schema.ts'
|
||||
import {
|
||||
sessionCancelValueSchema,
|
||||
sessionAttachmentValueSchema,
|
||||
sessionCreateValueSchema,
|
||||
sessionForkValueSchema,
|
||||
sessionHistoryValueSchema,
|
||||
@@ -40,6 +41,10 @@ import {
|
||||
} from '../api/workspace.schema.ts'
|
||||
import { commandExecuteValueSchema, commandListValueSchema } from '../api/commands.schema.ts'
|
||||
import { skillListValueSchema } from '../api/skills.schema.ts'
|
||||
import {
|
||||
agentPresetCopyValueSchema, agentPresetListValueSchema, agentPresetOpenDocumentValueSchema,
|
||||
agentPresetReadValueSchema, agentPresetRemoveValueSchema, agentPresetSelectValueSchema,
|
||||
} from '../api/agent-presets.schema.ts'
|
||||
import {
|
||||
goalCreateValueSchema,
|
||||
goalEditValueSchema,
|
||||
@@ -90,6 +95,7 @@ export interface IApiClient {
|
||||
rename(payload: RequestPayload<'session.rename'>, signal?: AbortSignal): Promise<RpcResponse<ResponseValue<'session.rename'>>>
|
||||
fork(payload: RequestPayload<'session.fork'>, signal?: AbortSignal): Promise<RpcResponse<ResponseValue<'session.fork'>>>
|
||||
prompt(payload: RequestPayload<'session.prompt'>, signal?: AbortSignal): Promise<RpcResponse<ResponseValue<'session.prompt'>>>
|
||||
attachment(payload: RequestPayload<'session.attachment'>, signal?: AbortSignal): Promise<RpcResponse<ResponseValue<'session.attachment'>>>
|
||||
updateQueue(payload: RequestPayload<'session.updateQueue'>, signal?: AbortSignal): Promise<RpcResponse<ResponseValue<'session.updateQueue'>>>
|
||||
cancel(payload: RequestPayload<'session.cancel'>, signal?: AbortSignal): Promise<RpcResponse<ResponseValue<'session.cancel'>>>
|
||||
}
|
||||
@@ -121,6 +127,14 @@ export interface IApiClient {
|
||||
skills: {
|
||||
list(payload: RequestPayload<'skill.list'>, signal?: AbortSignal): Promise<RpcResponse<ResponseValue<'skill.list'>>>
|
||||
}
|
||||
agentPresets: {
|
||||
list(payload: RequestPayload<'agentPreset.list'>, signal?: AbortSignal): Promise<RpcResponse<ResponseValue<'agentPreset.list'>>>
|
||||
select(payload: RequestPayload<'agentPreset.select'>, signal?: AbortSignal): Promise<RpcResponse<ResponseValue<'agentPreset.select'>>>
|
||||
read(payload: RequestPayload<'agentPreset.read'>, signal?: AbortSignal): Promise<RpcResponse<ResponseValue<'agentPreset.read'>>>
|
||||
copy(payload: RequestPayload<'agentPreset.copy'>, signal?: AbortSignal): Promise<RpcResponse<ResponseValue<'agentPreset.copy'>>>
|
||||
openDocument(payload: RequestPayload<'agentPreset.openDocument'>, signal?: AbortSignal): Promise<RpcResponse<ResponseValue<'agentPreset.openDocument'>>>
|
||||
remove(payload: RequestPayload<'agentPreset.remove'>, signal?: AbortSignal): Promise<RpcResponse<ResponseValue<'agentPreset.remove'>>>
|
||||
}
|
||||
events: {
|
||||
mux(payload: Parameters<ApiProxy['events']['mux']>[0]['payload'], signal: AbortSignal, onOpen?: () => void): AsyncIterable<RpcRequest<MuxFrame>>
|
||||
host(payload: Parameters<ApiProxy['events']['host']>[0]['payload'], signal: AbortSignal, onOpen?: () => void): AsyncIterable<RpcRequest<HostFrame>>
|
||||
@@ -168,6 +182,7 @@ const UNARY_VALUE_SCHEMAS: { [K in keyof RpcMethodMap]: z.ZodType<Wire<ResponseV
|
||||
'session.rename': sessionRenameValueSchema,
|
||||
'session.fork': sessionForkValueSchema,
|
||||
'session.prompt': sessionPromptValueSchema,
|
||||
'session.attachment': sessionAttachmentValueSchema,
|
||||
'session.updateQueue': sessionUpdateQueueValueSchema,
|
||||
'session.cancel': sessionCancelValueSchema,
|
||||
'subagent.list': subagentListValueSchema,
|
||||
@@ -188,6 +203,12 @@ const UNARY_VALUE_SCHEMAS: { [K in keyof RpcMethodMap]: z.ZodType<Wire<ResponseV
|
||||
'command.list': commandListValueSchema,
|
||||
'command.execute': commandExecuteValueSchema,
|
||||
'skill.list': skillListValueSchema,
|
||||
'agentPreset.list': agentPresetListValueSchema,
|
||||
'agentPreset.select': agentPresetSelectValueSchema,
|
||||
'agentPreset.read': agentPresetReadValueSchema,
|
||||
'agentPreset.copy': agentPresetCopyValueSchema,
|
||||
'agentPreset.openDocument': agentPresetOpenDocumentValueSchema,
|
||||
'agentPreset.remove': agentPresetRemoveValueSchema,
|
||||
'goal.create': goalCreateValueSchema,
|
||||
'goal.edit': goalEditValueSchema,
|
||||
'goal.pause': goalPauseValueSchema,
|
||||
@@ -390,7 +411,7 @@ export abstract class AbstractApiClient implements IApiClient {
|
||||
}
|
||||
}
|
||||
|
||||
// ---- IApiClient surface (arrow properties so destructured/passed references stay bound) ----
|
||||
// ---- IApiClient API (arrow properties so destructured/passed references stay bound) ----
|
||||
|
||||
readonly sessions: IApiClient['sessions'] = {
|
||||
list: (payload, signal) => this.callUnary('session.list', payload, signal),
|
||||
@@ -402,6 +423,7 @@ export abstract class AbstractApiClient implements IApiClient {
|
||||
rename: (payload, signal) => this.callUnary('session.rename', payload, signal),
|
||||
fork: (payload, signal) => this.callUnary('session.fork', payload, signal),
|
||||
prompt: (payload, signal) => this.callUnary('session.prompt', payload, signal),
|
||||
attachment: (payload, signal) => this.callUnary('session.attachment', payload, signal),
|
||||
updateQueue: (payload, signal) => this.callUnary('session.updateQueue', payload, signal),
|
||||
cancel: (payload, signal) => this.callUnary('session.cancel', payload, signal),
|
||||
}
|
||||
@@ -447,6 +469,20 @@ export abstract class AbstractApiClient implements IApiClient {
|
||||
list: (payload, signal) => this.callUnary('skill.list', payload, signal),
|
||||
}
|
||||
|
||||
// Annotated like every sibling, and load-bearing rather than cosmetic:
|
||||
// inferring this member inlines `AgentPresetEntry` into the emitted
|
||||
// declaration by the specifier TS picks — the host `index.ts` — which drags
|
||||
// the whole gateway, and with it the host `Context` merges, into every
|
||||
// Client program that imports this carrier.
|
||||
readonly agentPresets: IApiClient['agentPresets'] = {
|
||||
list: (payload, signal) => this.callUnary('agentPreset.list', payload, signal),
|
||||
select: (payload, signal) => this.callUnary('agentPreset.select', payload, signal),
|
||||
read: (payload, signal) => this.callUnary('agentPreset.read', payload, signal),
|
||||
copy: (payload, signal) => this.callUnary('agentPreset.copy', payload, signal),
|
||||
openDocument: (payload, signal) => this.callUnary('agentPreset.openDocument', payload, signal),
|
||||
remove: (payload, signal) => this.callUnary('agentPreset.remove', payload, signal),
|
||||
}
|
||||
|
||||
readonly goals: IApiClient['goals'] = {
|
||||
create: (payload, signal) => this.callUnary('goal.create', payload, signal),
|
||||
edit: (payload, signal) => this.callUnary('goal.edit', payload, signal),
|
||||
|
||||
@@ -9,6 +9,7 @@
|
||||
import { randomUUID } from 'node:crypto'
|
||||
import type { z } from 'zod'
|
||||
import type { ApiProxy, MuxFrame, HostFrame } from '../api/index.ts'
|
||||
import { sessionLogQuerySchema } from '../api/downloads.schema.ts'
|
||||
import type { RequestPayload, ResponseValue, RpcMethodMap } from '../api/rpc-map.ts'
|
||||
import type { ClientRequest, RpcError, RpcRequest, RpcResponse, ServerRequest, ServerResponse } from '../api/rpc.ts'
|
||||
import { RpcId } from '../api/rpc.ts'
|
||||
@@ -16,6 +17,7 @@ import type { Wire } from '../api/rpc.schema.ts'
|
||||
import { clientRequestSchema, clientResponseSchema } from '../api/rpc.schema.ts'
|
||||
import {
|
||||
sessionCancelRequestSchema,
|
||||
sessionAttachmentRequestSchema,
|
||||
sessionCreateRequestSchema,
|
||||
sessionForkRequestSchema,
|
||||
sessionHistoryRequestSchema,
|
||||
@@ -42,6 +44,10 @@ import {
|
||||
} from '../api/workspace.schema.ts'
|
||||
import { commandExecuteRequestSchema, commandListRequestSchema } from '../api/commands.schema.ts'
|
||||
import { skillListRequestSchema } from '../api/skills.schema.ts'
|
||||
import {
|
||||
agentPresetCopyRequestSchema, agentPresetListRequestSchema, agentPresetOpenDocumentRequestSchema,
|
||||
agentPresetReadRequestSchema, agentPresetRemoveRequestSchema, agentPresetSelectRequestSchema,
|
||||
} from '../api/agent-presets.schema.ts'
|
||||
import {
|
||||
goalCreateRequestSchema,
|
||||
goalEditRequestSchema,
|
||||
@@ -91,6 +97,7 @@ const UNARY_ROUTES: UnaryRoutes = {
|
||||
'session.rename': { schema: sessionRenameRequestSchema, invoke: (api, r) => api.sessions.rename(r) },
|
||||
'session.fork': { schema: sessionForkRequestSchema, invoke: (api, r) => api.sessions.fork(r) },
|
||||
'session.prompt': { schema: sessionPromptRequestSchema, invoke: (api, r) => api.sessions.prompt(r) },
|
||||
'session.attachment': { schema: sessionAttachmentRequestSchema, invoke: (api, r) => api.sessions.attachment(r) },
|
||||
'session.updateQueue': { schema: sessionUpdateQueueRequestSchema, invoke: (api, r) => api.sessions.updateQueue(r) },
|
||||
'session.cancel': { schema: sessionCancelRequestSchema, invoke: (api, r) => api.sessions.cancel(r) },
|
||||
'subagent.list': { schema: subagentListRequestSchema, invoke: (api, r, signal) => api.subagents.list(r, signal) },
|
||||
@@ -111,6 +118,12 @@ const UNARY_ROUTES: UnaryRoutes = {
|
||||
'command.list': { schema: commandListRequestSchema, invoke: (api, r) => api.commands.list(r) },
|
||||
'command.execute': { schema: commandExecuteRequestSchema, invoke: (api, r, signal) => api.commands.execute(r, signal) },
|
||||
'skill.list': { schema: skillListRequestSchema, invoke: (api, r) => api.skills.list(r) },
|
||||
'agentPreset.list': { schema: agentPresetListRequestSchema, invoke: (api, r) => api.agentPresets.list(r) },
|
||||
'agentPreset.select': { schema: agentPresetSelectRequestSchema, invoke: (api, r) => api.agentPresets.select(r) },
|
||||
'agentPreset.read': { schema: agentPresetReadRequestSchema, invoke: (api, r) => api.agentPresets.read(r) },
|
||||
'agentPreset.copy': { schema: agentPresetCopyRequestSchema, invoke: (api, r) => api.agentPresets.copy(r) },
|
||||
'agentPreset.openDocument': { schema: agentPresetOpenDocumentRequestSchema, invoke: (api, r, signal) => api.agentPresets.openDocument(r, signal) },
|
||||
'agentPreset.remove': { schema: agentPresetRemoveRequestSchema, invoke: (api, r) => api.agentPresets.remove(r) },
|
||||
'goal.create': { schema: goalCreateRequestSchema, invoke: (api, r) => api.goals.create(r) },
|
||||
'goal.edit': { schema: goalEditRequestSchema, invoke: (api, r) => api.goals.edit(r) },
|
||||
'goal.pause': { schema: goalPauseRequestSchema, invoke: (api, r) => api.goals.pause(r) },
|
||||
@@ -237,12 +250,23 @@ export function toFetchHandler(api: ApiProxy): { fetch: typeof fetch } {
|
||||
const url = new URL(req.url)
|
||||
const path = url.pathname
|
||||
|
||||
// No-envelope GET channel surface (SSE streams + host-only download):
|
||||
// physical routes that answer directly, without a wire envelope.
|
||||
if (path === '/api/events.mux' && req.method === 'GET') {
|
||||
return sseResponse(api.events.mux({ rpcId: RpcId(randomUUID()), payload: {} }, req.signal))
|
||||
}
|
||||
if (path === '/api/events.host' && req.method === 'GET') {
|
||||
return sseResponse(api.events.host({ rpcId: RpcId(randomUUID()), payload: {} }, req.signal))
|
||||
}
|
||||
if (path === '/api/session.export' && req.method === 'GET') {
|
||||
// Query params are a different boundary from the POST envelope, but
|
||||
// the request still casts its brands only through the domain schema.
|
||||
const parsed = sessionLogQuerySchema.safeParse(Object.fromEntries(url.searchParams))
|
||||
if (!parsed.success) {
|
||||
return new Response('missing or invalid sessionId query parameter', { status: 400 })
|
||||
}
|
||||
return api.downloads.sessionLog(parsed.data, req.signal)
|
||||
}
|
||||
|
||||
if (req.method !== 'POST' || !path.startsWith('/api/')) {
|
||||
return new Response('not found', { status: 404 })
|
||||
|
||||
@@ -8,16 +8,19 @@
|
||||
* routes — physical carriers wrap `ctx.apiProxy` themselves.
|
||||
*
|
||||
* The gateway consumes `ctx.agentDefaultModel`, the transport-independent default
|
||||
* shared with direct front doors. Switching models persists through that
|
||||
* shared with direct entry points. Switching models persists through that
|
||||
* service; sessions that have already logged a selection remain unchanged.
|
||||
*/
|
||||
|
||||
import { resolve } from 'node:path'
|
||||
import { Context, Service } from 'cordis'
|
||||
import z from 'schemastery'
|
||||
import { Context, Service } from '@deepseek-ai/cordis'
|
||||
import z from '@deepseek-ai/schemastery'
|
||||
import type {} from '@deepseek-ai/dsh-agent-default-model'
|
||||
import type { ApiProxy } from './api/index.ts'
|
||||
import { createApiProxy } from './api-proxy.ts'
|
||||
import {
|
||||
DEFAULT_SESSION_LOG_COMPRESSION_LEVEL,
|
||||
type SessionLogCompressionLevel,
|
||||
} from './session-export.ts'
|
||||
|
||||
export type * from './api/index.ts'
|
||||
export { RpcId } from './api/rpc.ts'
|
||||
@@ -27,32 +30,46 @@ export type { IApiClient } from './fetch/client.ts'
|
||||
export { createApiProxy } from './api-proxy.ts'
|
||||
export type { ApiProxyDefaults } from './api-proxy.ts'
|
||||
|
||||
declare module 'cordis' {
|
||||
declare module '@deepseek-ai/cordis' {
|
||||
interface Context {
|
||||
/** The host-side ApiProxy implementation (the transport-agnostic gateway face). */
|
||||
apiProxy: ApiProxy
|
||||
}
|
||||
}
|
||||
|
||||
/** Gateway plugin config: the Host-only Workspace creation root. */
|
||||
/** Gateway plugin configuration. */
|
||||
export interface Config {
|
||||
/** Parent directory for name-created Workspaces; defaults to the Host cwd. */
|
||||
workspaceRoot?: string
|
||||
/**
|
||||
* Whether this deployment can hand paths to a native desktop opener —
|
||||
* the `hasDocument` capability the agent-preset roster reports. Absent,
|
||||
* the platform is asked (macOS/Windows/WSL yes; Linux only with a display
|
||||
* server); set it explicitly where detection misleads, e.g. `false` in a
|
||||
* container whose DISPLAY points nowhere a user can see.
|
||||
*/
|
||||
nativeOpen?: boolean
|
||||
/**
|
||||
* DEFLATE level for every session-log ZIP entry: `0` stores without
|
||||
* compression, `1` favors CPU/latency, and `9` favors archive size.
|
||||
* @default 6
|
||||
*/
|
||||
sessionExportCompressionLevel?: 0 | 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9
|
||||
}
|
||||
|
||||
/**
|
||||
* The API gateway service: implements the ApiProxy contract over the composed
|
||||
* host context and provides it as `ctx.apiProxy`. The Host cwd is the default
|
||||
* project directory and the fallback parent for name-created Workspaces.
|
||||
* project directory.
|
||||
*/
|
||||
export class ApiProxyService extends Service implements ApiProxy {
|
||||
static inject = [
|
||||
'agentDefaultModel', 'agents', 'directoryPicker', 'llm', 'sessions', 'subagents', 'sessionQuery',
|
||||
'agentDefaultModel', 'agents', 'attachments', 'directoryPicker', 'llm', 'sessions', 'subagents', 'sessionQuery',
|
||||
'tools', 'userInteraction', 'workspace',
|
||||
]
|
||||
|
||||
static Config: z<Config> = z.object({
|
||||
workspaceRoot: z.string(),
|
||||
nativeOpen: z.boolean(),
|
||||
sessionExportCompressionLevel: z.number().step(1).min(0).max(9)
|
||||
.default(DEFAULT_SESSION_LOG_COMPRESSION_LEVEL) as z<SessionLogCompressionLevel>,
|
||||
})
|
||||
|
||||
readonly sessions: ApiProxy['sessions']
|
||||
@@ -62,20 +79,24 @@ export class ApiProxyService extends Service implements ApiProxy {
|
||||
readonly commands: ApiProxy['commands']
|
||||
readonly goals: ApiProxy['goals']
|
||||
readonly skills: ApiProxy['skills']
|
||||
readonly agentPresets: ApiProxy['agentPresets']
|
||||
readonly settings: ApiProxy['settings']
|
||||
readonly credentials: ApiProxy['credentials']
|
||||
readonly llm: ApiProxy['llm']
|
||||
readonly events: ApiProxy['events']
|
||||
readonly downloads: ApiProxy['downloads']
|
||||
readonly respond: ApiProxy['respond']
|
||||
|
||||
constructor(ctx: Context, config: Config) {
|
||||
super(ctx, 'apiProxy')
|
||||
const cwd = process.cwd()
|
||||
const api = createApiProxy(ctx, {
|
||||
defaultModelSelection: () => ctx.agentDefaultModel.currentSelection(),
|
||||
saveDefaultModelSelection: selection => ctx.agentDefaultModel.saveSelection(selection),
|
||||
cwd,
|
||||
workspaceRoot: resolve(config.workspaceRoot ?? cwd),
|
||||
cwd: process.cwd(),
|
||||
...config.nativeOpen === undefined ? {} : { canOpenPath: () => config.nativeOpen as boolean },
|
||||
...(config.sessionExportCompressionLevel === undefined
|
||||
? {}
|
||||
: { sessionExportCompressionLevel: config.sessionExportCompressionLevel }),
|
||||
})
|
||||
this.sessions = api.sessions
|
||||
this.subagents = api.subagents
|
||||
@@ -84,10 +105,12 @@ export class ApiProxyService extends Service implements ApiProxy {
|
||||
this.commands = api.commands
|
||||
this.goals = api.goals
|
||||
this.skills = api.skills
|
||||
this.agentPresets = api.agentPresets
|
||||
this.settings = api.settings
|
||||
this.credentials = api.credentials
|
||||
this.llm = api.llm
|
||||
this.events = api.events
|
||||
this.downloads = api.downloads
|
||||
// createApiProxy returns closures (no `this` capture), so the bind is
|
||||
// behavior-neutral.
|
||||
this.respond = api.respond.bind(api)
|
||||
|
||||
@@ -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-host-apiproxy'
|
||||
|
||||
@@ -152,6 +152,25 @@ async function openNativePathWithIntent(
|
||||
throw new Error(`native path opener is unsupported on ${platform}`)
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether {@link openNativePath} plausibly reaches a desktop on this host.
|
||||
*
|
||||
* macOS and Windows always carry a desktop opener; Linux does when it is WSL
|
||||
* (the Windows desktop takes the path) or a display server is announced.
|
||||
* A headless or containerised Linux host answers false, which is what lets a
|
||||
* surface show a path as text instead of offering a button that would spawn
|
||||
* `xdg-open` into nothing.
|
||||
* @param internals - platform and environment seam for deterministic tests.
|
||||
* @returns true when handing a path to the native opener can work at all.
|
||||
*/
|
||||
export function canOpenNativePath(internals: PathOpenerInternals = {}): boolean {
|
||||
const platform = internals.platform ?? process.platform
|
||||
if (platform === 'darwin' || platform === 'win32') return true
|
||||
if (platform !== 'linux') return false
|
||||
const env = internals.env ?? process.env
|
||||
return isWsl(internals) || present(env.DISPLAY) || present(env.WAYLAND_DISPLAY)
|
||||
}
|
||||
|
||||
/**
|
||||
* Open a filesystem path with the operating system's default application, or
|
||||
* with the default browser when the path names a document a browser renders.
|
||||
|
||||
457
packages/host/apiproxy/src/session-export.ts
Normal file
457
packages/host/apiproxy/src/session-export.ts
Normal file
@@ -0,0 +1,457 @@
|
||||
/**
|
||||
* Host-side session-log download: streams one ZIP archive whose files are the
|
||||
* sessions' stored artifact text verbatim plus every referenced media object.
|
||||
* The root artifact sits under its original base name (`session.jsonl`); each
|
||||
* subagent descendant under `subagents/<id>/<filename>`; each image referenced
|
||||
* by any included log under `media/<attachmentId>.<ext>` (content-addressed,
|
||||
* so one archive never duplicates a shared image). No manifest is written —
|
||||
* every file is byte-identical to the backend's durable artifact or attachment
|
||||
* store and self-describing through its own header line or media type. Before
|
||||
* each live session's artifact read, the SessionStore flush barrier makes the
|
||||
* current in-memory log durable; cold sessions need no barrier. Request abort
|
||||
* and response-consumer cancellation share one producer signal and terminate
|
||||
* the active compressor.
|
||||
* Compression runs on the host with fflate's streaming Zip API, so the archive
|
||||
* bytes are produced incrementally and the host never holds the whole archive
|
||||
* in one buffer; production waits for consumer pull whenever the response queue
|
||||
* reaches its byte high-water mark, so a slow consumer bounds accumulation to
|
||||
* the fixed 64 KiB response queue plus one synchronous fflate push.
|
||||
* @module
|
||||
*/
|
||||
|
||||
import { Zip, ZipDeflate } from 'fflate'
|
||||
import type { Context } from '@deepseek-ai/cordis'
|
||||
import type { AttachmentStore, ImageAttachmentRef } from '@deepseek-ai/dsh-attachment'
|
||||
import type { SessionLineageNode, SessionQueryService } from '@deepseek-ai/dsh-session-query'
|
||||
import type { SessionId, SessionStore } from '@deepseek-ai/dsh-session'
|
||||
import type { SessionPersistence, SessionRawArtifact } from '@deepseek-ai/dsh-session-persistence'
|
||||
|
||||
/** Valid fflate DEFLATE levels accepted by session-log export. */
|
||||
export type SessionLogCompressionLevel = 0 | 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9
|
||||
|
||||
/** Balanced default used when a direct createApiProxy caller omits deployment config. */
|
||||
export const DEFAULT_SESSION_LOG_COMPRESSION_LEVEL: SessionLogCompressionLevel = 6
|
||||
|
||||
/** The services a session-log export needs (the live-session store is optional). */
|
||||
export interface SessionLogExportDeps {
|
||||
readonly sessionQuery: SessionQueryService | undefined
|
||||
readonly sessionPersistence: SessionPersistence | undefined
|
||||
readonly attachments: AttachmentStore | undefined
|
||||
readonly sessions: SessionStore | undefined
|
||||
}
|
||||
|
||||
/** The export services narrowed to the mounted ones streaming actually reads. */
|
||||
export interface SessionLogExportReady {
|
||||
readonly sessionQuery: SessionQueryService
|
||||
readonly sessionPersistence: SessionPersistence
|
||||
readonly attachments: AttachmentStore
|
||||
readonly sessions: SessionStore | undefined
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolve the persistence, session-query, and attachment services a log export needs.
|
||||
* @param ctx - the composed host context.
|
||||
* @returns the export services (absent when the deployment does not mount them).
|
||||
*/
|
||||
export function sessionLogExportDeps(ctx: Context): SessionLogExportDeps {
|
||||
return {
|
||||
sessionQuery: ctx.get('sessionQuery'),
|
||||
sessionPersistence: ctx.get('sessionPersistence'),
|
||||
attachments: ctx.get('attachments'),
|
||||
sessions: ctx.get('sessions'),
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Flush one currently live session through the store's authoritative durability
|
||||
* barrier immediately before its raw artifact is read. A cold or absent id has
|
||||
* no in-memory work to flush.
|
||||
* @param deps - export services, including the optional live-session store.
|
||||
* @param id - the session whose artifact is about to be read.
|
||||
* @param signal - optional cancellation observed around the flush barrier.
|
||||
*/
|
||||
export async function flushLiveSessionLog(
|
||||
deps: Pick<SessionLogExportDeps, 'sessions'>,
|
||||
id: SessionId,
|
||||
signal?: AbortSignal,
|
||||
): Promise<void> {
|
||||
signal?.throwIfAborted()
|
||||
const sessions = deps.sessions
|
||||
if (sessions === undefined) return
|
||||
const session = sessions.get(id)
|
||||
if (session === undefined) return
|
||||
await sessions.flush(session)
|
||||
signal?.throwIfAborted()
|
||||
}
|
||||
|
||||
/** One exported file: a stored artifact text or one referenced media object. */
|
||||
export type SessionLogZipEntry =
|
||||
| { readonly path: string; readonly content: string }
|
||||
| { readonly path: string; readonly data: Uint8Array }
|
||||
|
||||
/** Zip extension for each accepted raster media type. */
|
||||
const MEDIA_TYPE_EXTENSIONS: Record<ImageAttachmentRef['mediaType'], string> = {
|
||||
'image/png': 'png',
|
||||
'image/jpeg': 'jpg',
|
||||
'image/webp': 'webp',
|
||||
'image/gif': 'gif',
|
||||
}
|
||||
|
||||
/**
|
||||
* The zip path for one media object: content-addressed by the opaque
|
||||
* attachment id so shared images land once and the id in the log maps back to
|
||||
* the archive entry without a manifest.
|
||||
* @param ref - the durable reference from a session log.
|
||||
* @returns the archive path.
|
||||
*/
|
||||
function mediaEntryPath(ref: ImageAttachmentRef): string {
|
||||
return `media/${String(ref.attachmentId)}.${MEDIA_TYPE_EXTENSIONS[ref.mediaType]}`
|
||||
}
|
||||
|
||||
/**
|
||||
* Collect every image reference inside one content array, descending into
|
||||
* nested tool results the way the live attachment route does.
|
||||
* @param content - an event content array (or nested tool-result content).
|
||||
* @param refs - the dedupe map being filled (keyed by attachment id).
|
||||
*/
|
||||
function collectImageRefs(content: unknown, refs: Map<string, ImageAttachmentRef>): void {
|
||||
if (!Array.isArray(content)) return
|
||||
const pending: unknown[] = []
|
||||
for (const item of content) pending.push(item)
|
||||
while (pending.length > 0) {
|
||||
const value = pending.pop()
|
||||
if (typeof value !== 'object' || value === null || Array.isArray(value)) continue
|
||||
const block = value as { type?: unknown; attachment?: unknown; content?: unknown }
|
||||
if (block.type === 'image' && typeof block.attachment === 'object' && block.attachment !== null) {
|
||||
const ref = block.attachment as ImageAttachmentRef
|
||||
refs.set(String(ref.attachmentId), ref)
|
||||
}
|
||||
if (Array.isArray(block.content)) {
|
||||
for (const item of block.content) pending.push(item)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Collect every image reference one session event carries, across the same
|
||||
* carriers the live attachment route scans (direct content, message content,
|
||||
* inserted messages, and completed assistant chunk blocks).
|
||||
* @param event - one parsed JSONL event object.
|
||||
* @param refs - the dedupe map being filled (keyed by attachment id).
|
||||
*/
|
||||
function collectEventImageRefs(event: unknown, refs: Map<string, ImageAttachmentRef>): void {
|
||||
const data = (event as { data?: unknown }).data
|
||||
if (typeof data !== 'object' || data === null) return
|
||||
const carrier = data as {
|
||||
content?: unknown
|
||||
message?: { content?: unknown }
|
||||
inserted?: Array<{ content?: unknown }>
|
||||
chunk?: { type?: unknown; block?: unknown }
|
||||
}
|
||||
collectImageRefs(carrier.content, refs)
|
||||
if (carrier.message !== undefined) collectImageRefs(carrier.message.content, refs)
|
||||
if (carrier.inserted !== undefined) {
|
||||
for (const message of carrier.inserted) collectImageRefs(message.content, refs)
|
||||
}
|
||||
if (carrier.chunk?.type === 'block-end') collectImageRefs([carrier.chunk.block], refs)
|
||||
}
|
||||
|
||||
/**
|
||||
* Collect the distinct media references one stored artifact text names.
|
||||
* Lines that fail to parse cannot reference media and are skipped (the
|
||||
* artifact text itself is exported verbatim regardless).
|
||||
* @param content - the stored artifact text.
|
||||
* @returns the dedupe map keyed by attachment id.
|
||||
*/
|
||||
function imageRefsInArtifact(content: string): Map<string, ImageAttachmentRef> {
|
||||
const refs = new Map<string, ImageAttachmentRef>()
|
||||
for (const line of content.split('\n')) {
|
||||
if (line === '') continue
|
||||
let event: unknown
|
||||
try {
|
||||
event = JSON.parse(line)
|
||||
} catch {
|
||||
continue
|
||||
}
|
||||
collectEventImageRefs(event, refs)
|
||||
}
|
||||
return refs
|
||||
}
|
||||
|
||||
/**
|
||||
* One safe zip path segment from an untrusted session id. Session ids are
|
||||
* host-controlled, but the brand allows any non-empty string, so `../`, dot
|
||||
* segments, and separator characters are neutralized before they can shape
|
||||
* archive entries. Distinct ids may collapse onto one segment (id collision
|
||||
* is impossible for the host-minted UUIDs, so no uniqueness suffix is kept).
|
||||
* @param id - the raw session id.
|
||||
* @returns a filesystem-safe single path segment.
|
||||
*/
|
||||
function safeSessionIdSegment(id: string): string {
|
||||
return id.replace(/[^A-Za-z0-9_-]/g, '_')
|
||||
}
|
||||
|
||||
/**
|
||||
* The export archive filename for one root session.
|
||||
* @param sessionId - the root session id (sanitized to one safe path segment).
|
||||
* @returns the attachment filename for the session's export archive.
|
||||
*/
|
||||
export function sessionLogZipFilename(sessionId: string): string {
|
||||
return `dsh-session-${safeSessionIdSegment(sessionId)}.zip`
|
||||
}
|
||||
|
||||
/**
|
||||
* Yield the export entries in zip order: the preloaded root artifact first,
|
||||
* then every subagent descendant in lineage order (each flushed when live,
|
||||
* read from the persistence backend right before it is yielded, and dropped
|
||||
* after the consumer moves on), then every distinct media object referenced by any of
|
||||
* the included logs (read and verified from the attachment store, one archive
|
||||
* entry per attachment id). The host holds at most one descendant's artifact
|
||||
* text and one media object at a time beyond the root.
|
||||
* @param deps - the mounted export services (the caller answered 500 before this runs).
|
||||
* @param root - the already-read root artifact (read by the caller so the
|
||||
* missing-session path can answer cleanly before streaming starts).
|
||||
* @param sessionId - the root session id.
|
||||
* @param includeDescendants - whether to include every subagent descendant.
|
||||
* @param signal - optional cancellation forwarded to lineage, persistence, and attachment reads.
|
||||
* @returns the export entries in zip order.
|
||||
*/
|
||||
export async function* sessionLogZipEntries(
|
||||
deps: SessionLogExportReady,
|
||||
root: SessionRawArtifact,
|
||||
sessionId: SessionId,
|
||||
includeDescendants: boolean,
|
||||
signal?: AbortSignal,
|
||||
): AsyncGenerator<SessionLogZipEntry> {
|
||||
const media = new Map<string, ImageAttachmentRef>()
|
||||
const rememberMedia = (content: string): void => {
|
||||
for (const [id, ref] of imageRefsInArtifact(content)) media.set(id, ref)
|
||||
}
|
||||
rememberMedia(root.content)
|
||||
yield { path: root.filename, content: root.content }
|
||||
if (includeDescendants) {
|
||||
const seen = new Set<SessionId>([sessionId])
|
||||
const collect = async function* (
|
||||
nodes: readonly SessionLineageNode[],
|
||||
): AsyncGenerator<SessionLogZipEntry> {
|
||||
for (const node of nodes) {
|
||||
signal?.throwIfAborted()
|
||||
const id = node.session.header.id
|
||||
if (seen.has(id)) continue
|
||||
seen.add(id)
|
||||
await flushLiveSessionLog(deps, id, signal)
|
||||
const raw = await deps.sessionPersistence.readRaw(id, signal)
|
||||
signal?.throwIfAborted()
|
||||
if (raw === undefined) {
|
||||
throw new Error(`subagent "${id}" has no stored log artifact`)
|
||||
}
|
||||
rememberMedia(raw.content)
|
||||
yield {
|
||||
path: `subagents/${safeSessionIdSegment(id)}/${raw.filename}`,
|
||||
content: raw.content,
|
||||
}
|
||||
yield* collect(node.descendants)
|
||||
}
|
||||
}
|
||||
const lineage = await deps.sessionQuery.traceSession(sessionId, signal)
|
||||
signal?.throwIfAborted()
|
||||
yield* collect(lineage.descendants)
|
||||
}
|
||||
for (const ref of media.values()) {
|
||||
signal?.throwIfAborted()
|
||||
const stored = await deps.attachments.readImage(ref, signal)
|
||||
signal?.throwIfAborted()
|
||||
yield { path: mediaEntryPath(ref), data: stored.data }
|
||||
}
|
||||
}
|
||||
|
||||
/** How many code units of artifact text one zip push carries (bounded encode memory). */
|
||||
const PUSH_CHUNK_CODE_UNITS = 1 << 16
|
||||
|
||||
/** How many bytes of media one zip push carries (bounded memory; images are already size-capped). */
|
||||
const PUSH_CHUNK_BYTES = 1 << 16
|
||||
|
||||
/** Byte capacity retained by the response stream before ZIP production waits for pull. */
|
||||
const RESPONSE_HIGH_WATER_MARK_BYTES = 1 << 16
|
||||
|
||||
/** One producer waiter released only when ReadableStream pull restores capacity. */
|
||||
class ResponseCapacityGate {
|
||||
private releasePending: (() => void) | undefined
|
||||
|
||||
/**
|
||||
* Wait until the response queue has positive byte capacity or cancellation wins.
|
||||
* @param controller - response controller whose desired size owns capacity.
|
||||
* @param signal - combined request/consumer cancellation.
|
||||
*/
|
||||
async wait(
|
||||
controller: ReadableStreamDefaultController<Uint8Array>,
|
||||
signal: AbortSignal,
|
||||
): Promise<void> {
|
||||
signal.throwIfAborted()
|
||||
if (controller.desiredSize === null || controller.desiredSize > 0) return
|
||||
await new Promise<void>((resolve) => {
|
||||
const release = (): void => {
|
||||
this.releasePending = undefined
|
||||
signal.removeEventListener('abort', release)
|
||||
resolve()
|
||||
}
|
||||
this.releasePending = release
|
||||
signal.addEventListener('abort', release, { once: true })
|
||||
})
|
||||
signal.throwIfAborted()
|
||||
}
|
||||
|
||||
/** Release the current producer waiter after a consumer pull. */
|
||||
pulled(): void {
|
||||
this.releasePending?.()
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Push one media object's bytes into a deflate stream in bounded chunks,
|
||||
* waiting for consumer capacity between chunks like the artifact path does.
|
||||
* @param deflate - the zip entry's deflate stream.
|
||||
* @param data - the stored image bytes.
|
||||
* @param controller - response queue controller.
|
||||
* @param capacity - pull-driven response-capacity gate.
|
||||
* @param signal - cancellation; throws when aborted.
|
||||
*/
|
||||
async function pushBinaryChunks(
|
||||
deflate: ZipDeflate,
|
||||
data: Uint8Array,
|
||||
controller: ReadableStreamDefaultController<Uint8Array>,
|
||||
capacity: ResponseCapacityGate,
|
||||
signal: AbortSignal,
|
||||
): Promise<void> {
|
||||
let offset = 0
|
||||
do {
|
||||
signal.throwIfAborted()
|
||||
const end = Math.min(offset + PUSH_CHUNK_BYTES, data.byteLength)
|
||||
const finalChunk = end >= data.byteLength
|
||||
deflate.push(data.subarray(offset, end), finalChunk)
|
||||
offset = end
|
||||
await capacity.wait(controller, signal)
|
||||
} while (offset < data.byteLength)
|
||||
}
|
||||
|
||||
/**
|
||||
* Push one artifact's text into a deflate stream in bounded chunks, never
|
||||
* splitting a surrogate pair across a chunk boundary (a lone high surrogate
|
||||
* re-encodes as U+FFFD and would silently corrupt the exported artifact).
|
||||
* @param deflate - the zip entry's deflate stream.
|
||||
* @param content - the artifact text verbatim.
|
||||
* @param controller - response queue controller.
|
||||
* @param capacity - pull-driven response-capacity gate.
|
||||
* @param signal - cancellation; throws when aborted.
|
||||
*/
|
||||
async function pushArtifactChunks(
|
||||
deflate: ZipDeflate,
|
||||
content: string,
|
||||
controller: ReadableStreamDefaultController<Uint8Array>,
|
||||
capacity: ResponseCapacityGate,
|
||||
signal: AbortSignal,
|
||||
): Promise<void> {
|
||||
const encoder = new TextEncoder()
|
||||
let offset = 0
|
||||
let finalChunk: boolean
|
||||
do {
|
||||
signal.throwIfAborted()
|
||||
let end = Math.min(offset + PUSH_CHUNK_CODE_UNITS, content.length)
|
||||
if (end < content.length && end - offset > 1) {
|
||||
// Back off one code unit when the boundary lands inside a surrogate
|
||||
// pair: the pair then starts the next chunk whole.
|
||||
const last = content.charCodeAt(end - 1)
|
||||
if (last >= 0xd800 && last <= 0xdbff) end -= 1
|
||||
}
|
||||
finalChunk = end >= content.length
|
||||
deflate.push(encoder.encode(content.slice(offset, end)), finalChunk)
|
||||
offset = end
|
||||
await capacity.wait(controller, signal)
|
||||
} while (!finalChunk)
|
||||
}
|
||||
|
||||
/**
|
||||
* Stream one session-log ZIP as a WHATWG ReadableStream. The root artifact is
|
||||
* read and validated by the caller before this is called (missing root or
|
||||
* missing services answer cleanly before any byte is produced); each entry is
|
||||
* then encoded and deflated in bounded chunks as it is produced, so the
|
||||
* archive bytes arrive incrementally. A descendant that fails to read errors
|
||||
* the stream (fail-loud, never silent under-export).
|
||||
* @param deps - the mounted export services (the caller answered 500 before this runs).
|
||||
* @param root - the already-read root artifact (first zip entry).
|
||||
* @param sessionId - the root session id.
|
||||
* @param includeDescendants - whether to include every subagent descendant.
|
||||
* @param compressionLevel - validated fflate DEFLATE level for every ZIP entry.
|
||||
* @param signal - request cancellation combined with response-consumer cancellation.
|
||||
* @returns the zip byte stream.
|
||||
*/
|
||||
export function streamSessionLogZip(
|
||||
deps: SessionLogExportReady,
|
||||
root: SessionRawArtifact,
|
||||
sessionId: SessionId,
|
||||
includeDescendants: boolean,
|
||||
compressionLevel: SessionLogCompressionLevel,
|
||||
signal: AbortSignal,
|
||||
): ReadableStream<Uint8Array> {
|
||||
const consumerAbort = new AbortController()
|
||||
const producerSignal = AbortSignal.any([signal, consumerAbort.signal])
|
||||
let zip: Zip | undefined
|
||||
let zipTerminated = false
|
||||
const capacity = new ResponseCapacityGate()
|
||||
const terminateZip = (): void => {
|
||||
if (zip === undefined || zipTerminated) return
|
||||
zipTerminated = true
|
||||
zip.terminate()
|
||||
}
|
||||
return new ReadableStream<Uint8Array>({
|
||||
start(controller) {
|
||||
// fflate invokes the callback synchronously per compressed chunk, so a
|
||||
// single push can enqueue ahead of a slow consumer; the capacity gate
|
||||
// waits for pull between pushes once the byte queue is full, bounding
|
||||
// accumulation to the queue high-water mark plus one synchronous push.
|
||||
const archive = new Zip((error, data, final) => {
|
||||
/* v8 ignore next 3 -- fflate reports only internal zip failures, unreachable for valid inputs */
|
||||
if (error) {
|
||||
controller.error(error)
|
||||
return
|
||||
}
|
||||
/* v8 ignore next -- fflate may emit empty chunks; not controllable from tests */
|
||||
if (data.byteLength > 0) controller.enqueue(data)
|
||||
if (final) controller.close()
|
||||
})
|
||||
zip = archive
|
||||
void (async () => {
|
||||
try {
|
||||
for await (const entry of sessionLogZipEntries(deps, root, sessionId, includeDescendants, producerSignal)) {
|
||||
const deflate = new ZipDeflate(entry.path, { level: compressionLevel })
|
||||
archive.add(deflate)
|
||||
if ('content' in entry) {
|
||||
await pushArtifactChunks(deflate, entry.content, controller, capacity, producerSignal)
|
||||
} else {
|
||||
await pushBinaryChunks(deflate, entry.data, controller, capacity, producerSignal)
|
||||
}
|
||||
}
|
||||
archive.end()
|
||||
} catch (error) {
|
||||
// A mid-stream failure (missing descendant, cancellation, read
|
||||
// error) must fail the download rather than ship a truncated archive.
|
||||
/* v8 ignore next -- typed backends reject with Error, and DOMException is one in Node */
|
||||
terminateZip()
|
||||
controller.error(error instanceof Error ? error : new Error(String(error)))
|
||||
}
|
||||
})()
|
||||
},
|
||||
pull() {
|
||||
capacity.pulled()
|
||||
},
|
||||
cancel(reason) {
|
||||
consumerAbort.abort(
|
||||
reason instanceof Error ? reason : new Error('session log export stream cancelled'),
|
||||
)
|
||||
terminateZip()
|
||||
},
|
||||
}, {
|
||||
highWaterMark: RESPONSE_HIGH_WATER_MARK_BYTES,
|
||||
size: chunk => chunk.byteLength,
|
||||
})
|
||||
}
|
||||
727
packages/host/apiproxy/tests/api-proxy-agent-preset.spec.ts
Normal file
727
packages/host/apiproxy/tests/api-proxy-agent-preset.spec.ts
Normal file
@@ -0,0 +1,727 @@
|
||||
/**
|
||||
* A session's agent preset is fixed at creation. The gateway records the
|
||||
* resolved id on the header and refuses to adopt the identity under a different
|
||||
* one, because the session's history was produced under that preset's tools:
|
||||
* rebuilding it differently would replay tool calls the new agent cannot make.
|
||||
*/
|
||||
|
||||
import { mkdtempSync, realpathSync } from 'node:fs'
|
||||
import { tmpdir } from 'node:os'
|
||||
import { join } from 'node:path'
|
||||
import { Context } from '@deepseek-ai/cordis'
|
||||
import AgentRegistry, { type AgentFactory } from '@deepseek-ai/dsh-agent'
|
||||
import type { Agent } from '@deepseek-ai/dsh-agent'
|
||||
import SessionStore, { SessionId, type Session } from '@deepseek-ai/dsh-session'
|
||||
import UserInteractionService from '@deepseek-ai/dsh-user-interaction'
|
||||
import { RpcId, type RpcRequest } from '../src/api/rpc.ts'
|
||||
import type { HostFrame } from '../src/api/events.ts'
|
||||
import {
|
||||
InvalidPresetIdError, PresetExistsError, resolveSessionPreset, UnknownPresetError,
|
||||
} from '@deepseek-ai/dsh-agent-presets'
|
||||
import { GoalId } from '@deepseek-ai/dsh-goal'
|
||||
import { createApiProxy } from '../src/api-proxy.ts'
|
||||
import { describe, expect, it } from 'vitest'
|
||||
|
||||
let nextRpc = 0
|
||||
function request<P>(payload: P): RpcRequest<P> {
|
||||
return { rpcId: RpcId(`preset-${String(nextRpc++)}`), payload }
|
||||
}
|
||||
|
||||
/** Minimal live agent; the gateway only needs identity and its session. */
|
||||
function stubAgent(session: Session): Agent {
|
||||
return { id: session.id, session, status: 'idle' } as unknown as Agent
|
||||
}
|
||||
|
||||
/**
|
||||
* A roster whose `mount` is a no-op: this spec is about the gateway's identity
|
||||
* rules, and the composition itself is covered by the real-composition test in
|
||||
* `apps/cli`. Ids listed in `userIds` present as locally authored; the rest
|
||||
* ship with the deployment.
|
||||
*/
|
||||
function roster(ids: readonly string[], userIds: readonly string[] = []): unknown {
|
||||
const trustOf = (id: string): 'system' | 'user' => (userIds.includes(id) ? 'user' : 'system')
|
||||
const presetOf = (id: string): object =>
|
||||
({ id, trust: trustOf(id), path: `/presets/${id}/agent.cordis.yml` })
|
||||
return {
|
||||
defaultId: ids[0],
|
||||
list: () => Promise.resolve(ids.map(presetOf)),
|
||||
resolve: (id?: string) => {
|
||||
const wanted = id ?? ids[0] ?? ''
|
||||
if (!ids.includes(wanted)) return Promise.reject(new UnknownPresetError(wanted, ids))
|
||||
return Promise.resolve(presetOf(wanted))
|
||||
},
|
||||
mount: (_ctx: Context, id?: string) => Promise.resolve(presetOf(id ?? ids[0] ?? '')),
|
||||
// What a real mount leaves behind: a service instance only the agent that
|
||||
// mounted it can be used to address. The doubles are per agent so a test
|
||||
// can tell "this session's" from "some session's".
|
||||
serviceFor: (agent: { id: unknown }, name: string) => {
|
||||
const perAgent = services.get(String(agent.id))
|
||||
return perAgent?.[name]
|
||||
},
|
||||
authorable: true,
|
||||
read: (id: string) => Promise.resolve(`# ${id}\n- id: x\n name: y\n`),
|
||||
copy: (from: string, id: string) => {
|
||||
if (!ids.includes(from)) return Promise.reject(new UnknownPresetError(from, ids))
|
||||
if (!/^[a-z0-9][a-z0-9-]*$/.test(id)) return Promise.reject(new InvalidPresetIdError(id))
|
||||
if (ids.includes(id)) return Promise.reject(new PresetExistsError(id))
|
||||
return Promise.resolve()
|
||||
},
|
||||
remove: (id: string) => {
|
||||
if (!ids.includes(id)) return Promise.reject(new UnknownPresetError(id, ids))
|
||||
return Promise.resolve()
|
||||
},
|
||||
recompose: (_ctx: Context, id: string) => {
|
||||
if (!ids.includes(id)) return Promise.reject(new UnknownPresetError(id, ids))
|
||||
return Promise.resolve({ id, trust: 'system', path: `/presets/${id}.yml` })
|
||||
},
|
||||
// The standing scope key a cold transcript read resolves presenters in.
|
||||
standingKeyFor: (id?: string) => {
|
||||
const wanted = id ?? ids[0] ?? ''
|
||||
standingKeyRequests.push(wanted)
|
||||
if (!ids.includes(wanted) || failingStandingKeys.has(wanted)) {
|
||||
return Promise.reject(new UnknownPresetError(wanted, ids))
|
||||
}
|
||||
let key = standingKeys.get(wanted)
|
||||
if (key === undefined) {
|
||||
key = { agentPreset: wanted }
|
||||
standingKeys.set(wanted, key)
|
||||
}
|
||||
return Promise.resolve(key)
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
/** Standing keys the roster double minted, and the ids readers asked for. */
|
||||
const standingKeys = new Map<string, object>()
|
||||
const standingKeyRequests: string[] = []
|
||||
/** Preset ids whose standing mount the double reports as unusable. */
|
||||
const failingStandingKeys = new Set<string>()
|
||||
|
||||
/** Per-agent service instances a mounted preset would own, keyed by session id. */
|
||||
const services = new Map<string, Record<string, unknown>>()
|
||||
|
||||
async function harness(
|
||||
presets?: readonly string[],
|
||||
persistence?: unknown,
|
||||
options: { userIds?: readonly string[]; defaults?: Record<string, unknown> } = {},
|
||||
) {
|
||||
const cwd = realpathSync(mkdtempSync(join(tmpdir(), 'dsh-apiproxy-preset-')))
|
||||
const ctx = new Context()
|
||||
await ctx.plugin(SessionStore)
|
||||
await ctx.plugin(AgentRegistry)
|
||||
await ctx.plugin(UserInteractionService)
|
||||
ctx.provide('sessionPersistence', (persistence ?? { list: () => Promise.resolve([]) }) as never)
|
||||
if (presets !== undefined) ctx.provide('agentPresets', roster(presets, options.userIds) as never)
|
||||
|
||||
const factory: AgentFactory = {
|
||||
async createAgent(_ownerCtx, options) {
|
||||
const session = ctx.sessions.create(
|
||||
options.sessionId,
|
||||
options.meta === undefined ? {} : { meta: options.meta },
|
||||
)
|
||||
const agent = stubAgent(session)
|
||||
// Setup runs before publication against a context that carries the
|
||||
// agent, and the agent reaches back through `agent.ctx` — the pair the
|
||||
// gateway's own `installTarget` relies on.
|
||||
const agentCtx = ctx.extend({ agent })
|
||||
;(agent as { ctx?: Context }).ctx = agentCtx
|
||||
await options.setup?.(agentCtx)
|
||||
const unregister = ctx.agents.register(agent)
|
||||
return { agent, dispose: () => { unregister(); return Promise.resolve() } }
|
||||
},
|
||||
async resume() {
|
||||
throw new Error('test harness has no persisted sessions')
|
||||
},
|
||||
}
|
||||
ctx.agents.setFactory(factory)
|
||||
const api = createApiProxy(ctx, {
|
||||
defaultModelSelection: () => ({ provider: 'test', model: 'test-model' }),
|
||||
cwd,
|
||||
...options.defaults,
|
||||
})
|
||||
return { api, ctx, cwd }
|
||||
}
|
||||
|
||||
describe('session.create with an agent preset', () => {
|
||||
it('records the resolved preset on the session header', async () => {
|
||||
const { api, ctx } = await harness(['standard', 'minimal'])
|
||||
|
||||
const created = await api.sessions.create(request({ sessionId: SessionId('s1'), agentPreset: 'minimal' }))
|
||||
|
||||
expect(created.result.ok).toBe(true)
|
||||
expect(ctx.sessions.get(SessionId('s1'))?.header.agentPreset).toBe('minimal')
|
||||
})
|
||||
|
||||
it('records the default when the caller names none', async () => {
|
||||
const { api, ctx } = await harness(['standard', 'minimal'])
|
||||
|
||||
await api.sessions.create(request({ sessionId: SessionId('s2') }))
|
||||
|
||||
expect(ctx.sessions.get(SessionId('s2'))?.header.agentPreset).toBe('standard')
|
||||
})
|
||||
|
||||
it('rejects an unknown preset and names the ones that exist', async () => {
|
||||
const { api } = await harness(['standard'])
|
||||
|
||||
const response = await api.sessions.create(request({ sessionId: SessionId('s3'), agentPreset: 'nope' }))
|
||||
|
||||
expect(response.result.ok).toBe(false)
|
||||
if (response.result.ok) throw new Error('unreachable')
|
||||
expect(response.result.error.code).toBe('agent-preset-not-found')
|
||||
})
|
||||
|
||||
it('refuses to adopt a live session under a different preset', async () => {
|
||||
const { api } = await harness(['standard', 'minimal'])
|
||||
await api.sessions.create(request({ sessionId: SessionId('s4'), agentPreset: 'minimal' }))
|
||||
|
||||
const response = await api.sessions.create(request({ sessionId: SessionId('s4'), agentPreset: 'standard' }))
|
||||
|
||||
expect(response.result.ok).toBe(false)
|
||||
if (response.result.ok) throw new Error('unreachable')
|
||||
expect(response.result.error.code).toBe('agent-preset-conflict')
|
||||
expect(response.result.error.details).toEqual({
|
||||
sessionId: 's4',
|
||||
requestedPreset: 'standard',
|
||||
existingPreset: 'minimal',
|
||||
})
|
||||
})
|
||||
|
||||
it('adopts a live session under the preset it SWITCHED to', async () => {
|
||||
const { api, ctx } = await harness(['standard', 'minimal'])
|
||||
await api.sessions.create(request({ sessionId: SessionId('s4b'), agentPreset: 'standard' }))
|
||||
// Exactly what `agentPreset.select` leaves behind on a blank session: the
|
||||
// header keeps the creation fact, the log states what the agent runs.
|
||||
ctx.sessions.get(SessionId('s4b'))?.append('agent-preset/selected', { agentPreset: 'minimal' })
|
||||
|
||||
const adopted = await api.sessions.create(request({ sessionId: SessionId('s4b'), agentPreset: 'minimal' }))
|
||||
const stale = await api.sessions.create(request({ sessionId: SessionId('s4b'), agentPreset: 'standard' }))
|
||||
|
||||
// Comparing against the header would invert both answers: the preset the
|
||||
// session actually runs would be refused, and the one it left would pass.
|
||||
expect(adopted.result.ok).toBe(true)
|
||||
// The echo has to name the same preset the adoption just accepted, or the
|
||||
// client labels the session with one it has already left — and disagrees
|
||||
// with the row `session.list` serves for it.
|
||||
if (!adopted.result.ok) throw new Error('unreachable')
|
||||
expect(adopted.result.value).toMatchObject({ agentPreset: 'minimal' })
|
||||
expect(stale.result.ok).toBe(false)
|
||||
if (stale.result.ok) throw new Error('unreachable')
|
||||
expect(stale.result.error.details).toMatchObject({ existingPreset: 'minimal' })
|
||||
})
|
||||
|
||||
it('adopts a live session unchanged when the caller names no preset', async () => {
|
||||
const { api } = await harness(['standard', 'minimal'])
|
||||
await api.sessions.create(request({ sessionId: SessionId('s5'), agentPreset: 'minimal' }))
|
||||
|
||||
// Reconnecting and retrying a create must stay ordinary operations.
|
||||
const response = await api.sessions.create(request({ sessionId: SessionId('s5') }))
|
||||
|
||||
expect(response.result.ok).toBe(true)
|
||||
})
|
||||
|
||||
it('leaves the header preset-less when no roster is composed', async () => {
|
||||
const { api, ctx } = await harness()
|
||||
|
||||
await api.sessions.create(request({ sessionId: SessionId('s6') }))
|
||||
|
||||
expect(ctx.sessions.get(SessionId('s6'))?.header.agentPreset).toBeUndefined()
|
||||
})
|
||||
|
||||
it('says why a preset-less session cannot be adopted under one', async () => {
|
||||
// Two callers reach this: a deployment that composes no roster, and a
|
||||
// session created before one existed. Both record no preset, so naming
|
||||
// any is a conflict rather than an adoption — the history was produced
|
||||
// under a composition this roster cannot name. The message has to say
|
||||
// that, because "already runs agent preset undefined" reads as a bug.
|
||||
const { api } = await harness()
|
||||
await api.sessions.create(request({ sessionId: SessionId('s7') }))
|
||||
|
||||
const response = await api.sessions.create(request({ sessionId: SessionId('s7'), agentPreset: 'standard' }))
|
||||
|
||||
expect(response.result.ok).toBe(false)
|
||||
if (response.result.ok) throw new Error('unreachable')
|
||||
expect(response.result.error.code).toBe('agent-preset-conflict')
|
||||
expect(response.result.error.message).toContain('records no agent preset')
|
||||
expect(response.result.error.details).toEqual({
|
||||
sessionId: 's7',
|
||||
requestedPreset: 'standard',
|
||||
existingPreset: undefined,
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
/**
|
||||
* A capability a preset mounts is reachable from nowhere the host normally
|
||||
* looks: an `isolate` realm is what makes it per session. The gateway serves
|
||||
* requests that are ABOUT a session from OUTSIDE it, so it addresses the
|
||||
* instance through the agent instead of reading a root-realm singleton.
|
||||
*/
|
||||
describe('a capability the session\'s preset mounts', () => {
|
||||
it('serves the goal RPC from the session\'s own goal service', async () => {
|
||||
const { api } = await harness(['standard'])
|
||||
await api.sessions.create(request({ sessionId: SessionId('g1'), agentPreset: 'standard' }))
|
||||
const ref = { id: GoalId('goal-1'), revision: 1 }
|
||||
const paused: unknown[] = []
|
||||
services.set('g1', {
|
||||
goals: { pause: (agent: { id: unknown }, r: unknown) => { paused.push([String(agent.id), r]); return ref } },
|
||||
})
|
||||
|
||||
const response = await api.goals.pause(request({ sessionId: SessionId('g1'), ref }))
|
||||
|
||||
expect(response.result).toMatchObject({ ok: true, value: { ref } })
|
||||
// Reached the instance this session mounted, and was handed its own agent.
|
||||
expect(paused).toEqual([['g1', ref]])
|
||||
services.delete('g1')
|
||||
})
|
||||
|
||||
it('serves the skill catalog from the session\'s own registry', async () => {
|
||||
const { api } = await harness(['standard'])
|
||||
await api.sessions.create(request({ sessionId: SessionId('k1'), agentPreset: 'standard' }))
|
||||
services.set('k1', {
|
||||
skills: {
|
||||
list: () => Promise.resolve([{
|
||||
name: 'preset-owned',
|
||||
description: 'ships inside the preset directory',
|
||||
invocation: { modelInvocable: true, userInvocable: true },
|
||||
}]),
|
||||
},
|
||||
})
|
||||
|
||||
const response = await api.skills.list(request({ sessionId: SessionId('k1') }))
|
||||
|
||||
// A preset ships its own skill directory, so the catalog IS the
|
||||
// session's; reading a host singleton would answer for the wrong one.
|
||||
expect(response.result).toMatchObject({ ok: true, value: { skills: [{ name: 'preset-owned' }] } })
|
||||
services.delete('k1')
|
||||
})
|
||||
|
||||
it('says so when no composition mounts the capability at all', async () => {
|
||||
const { api } = await harness(['standard'])
|
||||
await api.sessions.create(request({ sessionId: SessionId('n1'), agentPreset: 'standard' }))
|
||||
|
||||
const response = await api.skills.list(request({ sessionId: SessionId('n1') }))
|
||||
|
||||
// Absent means absent — not "this session has none", which is what a
|
||||
// root-realm read used to report for every presetd session.
|
||||
expect(response.result.ok).toBe(false)
|
||||
const failure = response.result as { ok: false; error: { message: string } }
|
||||
expect(failure.error.message).toContain('neither this session')
|
||||
})
|
||||
})
|
||||
|
||||
describe('agentPreset.list', () => {
|
||||
it('marks the default and carries each preset\'s trust', async () => {
|
||||
const { api } = await harness(['standard', 'minimal'])
|
||||
|
||||
const response = await api.agentPresets.list(request({}))
|
||||
|
||||
expect(response.result.ok).toBe(true)
|
||||
if (!response.result.ok) throw new Error('unreachable')
|
||||
expect(response.result.value.presets).toEqual([
|
||||
{ id: 'standard', trust: 'system', isDefault: true },
|
||||
{ id: 'minimal', trust: 'system', isDefault: false },
|
||||
])
|
||||
expect(response.result.value.authorable).toBe(true)
|
||||
})
|
||||
|
||||
it('answers with an empty roster when the deployment composes no presets', async () => {
|
||||
const { api } = await harness()
|
||||
|
||||
const response = await api.agentPresets.list(request({}))
|
||||
|
||||
// Composing no presets is a valid deployment, not an error: every session
|
||||
// then shares the host composition and the browser offers no choice.
|
||||
expect(response.result.ok).toBe(true)
|
||||
if (!response.result.ok) throw new Error('unreachable')
|
||||
expect(response.result.value.presets).toEqual([])
|
||||
// Nothing to write to either, so a surface offering "new preset" knows to
|
||||
// stay hidden rather than offering a button whose save always fails.
|
||||
expect(response.result.value.authorable).toBe(false)
|
||||
})
|
||||
})
|
||||
|
||||
describe('agentPreset.select', () => {
|
||||
it('recomposes a blank session', async () => {
|
||||
const { api } = await harness(['standard', 'minimal'])
|
||||
await api.sessions.create(request({ sessionId: SessionId('sel-1'), agentPreset: 'standard' }))
|
||||
|
||||
const response = await api.agentPresets.select(
|
||||
request({ sessionId: SessionId('sel-1'), agentPreset: 'minimal' }))
|
||||
|
||||
expect(response.result.ok).toBe(true)
|
||||
if (!response.result.ok) throw new Error('unreachable')
|
||||
expect(response.result.value.agentPreset).toBe('minimal')
|
||||
})
|
||||
|
||||
it('records the switch in the log, and the list reads it back', async () => {
|
||||
const { api, ctx } = await harness(['standard', 'minimal'])
|
||||
await api.sessions.create(request({ sessionId: SessionId('sel-log'), agentPreset: 'standard' }))
|
||||
|
||||
await api.agentPresets.select(
|
||||
request({ sessionId: SessionId('sel-log'), agentPreset: 'minimal' }))
|
||||
|
||||
// The header is written once at creation, so the switch lives in the log —
|
||||
// this is what a restart replays and what every projection resolves from.
|
||||
// Asserting only the RPC's echo would miss a switch that never persisted.
|
||||
const session = ctx.sessions.get(SessionId('sel-log'))
|
||||
if (session === undefined) throw new Error('unreachable')
|
||||
expect(session.header.agentPreset).toBe('standard')
|
||||
expect(resolveSessionPreset(session)).toBe('minimal')
|
||||
const listed = await api.sessions.list(request({}))
|
||||
if (!listed.result.ok) throw new Error('unreachable')
|
||||
expect(listed.result.value.items.find(item => item.sessionId === 'sel-log')?.agentPreset)
|
||||
.toBe('minimal')
|
||||
})
|
||||
|
||||
it('frames the committed switch so clients can drop that session\'s catalogs', async () => {
|
||||
const { api, ctx } = await harness(['standard', 'minimal'])
|
||||
await api.sessions.create(request({ sessionId: SessionId('sel-frame'), agentPreset: 'standard' }))
|
||||
// The host-stream opener reads the committed-workspace baseline; this
|
||||
// spec owns preset identity, so the stub suffices (api-proxy-commands
|
||||
// precedent).
|
||||
ctx.provide('workspace', { list: () => [] } as never)
|
||||
const abort = new AbortController()
|
||||
const frames: HostFrame[] = []
|
||||
const stream = api.events.host(request({}), abort.signal)
|
||||
const consume = (async () => {
|
||||
for await (const frame of stream) {
|
||||
if (frame.payload.type === 'host/session-preset-changed') frames.push(frame.payload)
|
||||
}
|
||||
})()
|
||||
|
||||
await api.agentPresets.select(
|
||||
request({ sessionId: SessionId('sel-frame'), agentPreset: 'minimal' }))
|
||||
// The queue push rides the synchronous append, so one turn of the loop is
|
||||
// enough to deliver it; closing the stream bounds the read either way.
|
||||
await new Promise(resolve => setTimeout(resolve, 0))
|
||||
abort.abort()
|
||||
await consume
|
||||
|
||||
// Recomposing registers nothing, so this frame — not the registry-wide
|
||||
// commands one — is what tells a client its cached catalogs are stale.
|
||||
expect(frames).toEqual([
|
||||
{ type: 'host/session-preset-changed', sessionId: 'sel-frame', agentPreset: 'minimal' },
|
||||
])
|
||||
})
|
||||
|
||||
it('serializes two concurrent selects on one session', async () => {
|
||||
const { api, ctx } = await harness(['standard', 'minimal'])
|
||||
await api.sessions.create(request({ sessionId: SessionId('sel-race'), agentPreset: 'standard' }))
|
||||
|
||||
// Both pass the blank check; unserialized, the second unmount finds no
|
||||
// record because the first already removed it, and two compositions end up
|
||||
// in one agent layer. The client's busy flag is not enforcement.
|
||||
const [first, second] = await Promise.all([
|
||||
api.agentPresets.select(request({ sessionId: SessionId('sel-race'), agentPreset: 'minimal' })),
|
||||
api.agentPresets.select(request({ sessionId: SessionId('sel-race'), agentPreset: 'standard' })),
|
||||
])
|
||||
|
||||
expect(first.result.ok).toBe(true)
|
||||
expect(second.result.ok).toBe(true)
|
||||
const session = ctx.sessions.get(SessionId('sel-race'))
|
||||
if (session === undefined) throw new Error('unreachable')
|
||||
// One winner, and the log agrees with it: the last committed switch.
|
||||
expect(resolveSessionPreset(session)).toBe('standard')
|
||||
})
|
||||
|
||||
it('refuses once the conversation has started', async () => {
|
||||
const { api, ctx } = await harness(['standard', 'minimal'])
|
||||
await api.sessions.create(request({ sessionId: SessionId('sel-2'), agentPreset: 'standard' }))
|
||||
// One turn is enough: the history from here on was produced under
|
||||
// `standard`'s tools, and a swap would strand those tool calls.
|
||||
ctx.sessions.get(SessionId('sel-2'))?.append('turn/start', { turn: 0 })
|
||||
|
||||
const response = await api.agentPresets.select(
|
||||
request({ sessionId: SessionId('sel-2'), agentPreset: 'minimal' }))
|
||||
|
||||
expect(response.result.ok).toBe(false)
|
||||
if (response.result.ok) throw new Error('unreachable')
|
||||
expect(response.result.error.code).toBe('agent-preset-locked')
|
||||
})
|
||||
|
||||
it('reports an unknown preset without disturbing the session', async () => {
|
||||
const { api } = await harness(['standard'])
|
||||
await api.sessions.create(request({ sessionId: SessionId('sel-3') }))
|
||||
|
||||
const response = await api.agentPresets.select(
|
||||
request({ sessionId: SessionId('sel-3'), agentPreset: 'nope' }))
|
||||
|
||||
expect(response.result.ok).toBe(false)
|
||||
if (response.result.ok) throw new Error('unreachable')
|
||||
expect(response.result.error.code).toBe('agent-preset-not-found')
|
||||
})
|
||||
|
||||
it('reports a deployment that composes no presets', async () => {
|
||||
const { api } = await harness()
|
||||
await api.sessions.create(request({ sessionId: SessionId('sel-4') }))
|
||||
|
||||
const response = await api.agentPresets.select(
|
||||
request({ sessionId: SessionId('sel-4'), agentPreset: 'anything' }))
|
||||
|
||||
expect(response.result.ok).toBe(false)
|
||||
if (response.result.ok) throw new Error('unreachable')
|
||||
expect(response.result.error.code).toBe('agent-preset-not-found')
|
||||
})
|
||||
})
|
||||
|
||||
describe('authoring over the wire', () => {
|
||||
it('reads a composition with its trust', async () => {
|
||||
const { api } = await harness(['standard'])
|
||||
|
||||
const response = await api.agentPresets.read(request({ agentPreset: 'standard' }))
|
||||
|
||||
expect(response.result.ok).toBe(true)
|
||||
if (!response.result.ok) throw new Error('unreachable')
|
||||
// The shipped set is readable: it is the known-good composition a copy
|
||||
// starts from, and trust is what tells a surface to say so.
|
||||
expect(response.result.value.trust).toBe('system')
|
||||
expect(response.result.value.content).toContain('- id: x')
|
||||
})
|
||||
|
||||
it('copies a preset under a new id', async () => {
|
||||
const { api } = await harness(['standard'])
|
||||
|
||||
const response = await api.agentPresets.copy(
|
||||
request({ from: 'standard', agentPreset: 'mine', name: '我的模式' }))
|
||||
|
||||
expect(response.result.ok).toBe(true)
|
||||
if (!response.result.ok) throw new Error('unreachable')
|
||||
expect(response.result.value.agentPreset).toBe('mine')
|
||||
})
|
||||
|
||||
it('rejects a copy target that could escape the preset root', async () => {
|
||||
const { api } = await harness(['standard'])
|
||||
|
||||
const response = await api.agentPresets.copy(request({ from: 'standard', agentPreset: '../escape' }))
|
||||
|
||||
expect(response.result.ok).toBe(false)
|
||||
if (response.result.ok) throw new Error('unreachable')
|
||||
expect(response.result.error.code).toBe('agent-preset-invalid')
|
||||
})
|
||||
|
||||
it('rejects a copy target the roster already supplies', async () => {
|
||||
const { api } = await harness(['standard', 'minimal'])
|
||||
|
||||
const response = await api.agentPresets.copy(request({ from: 'standard', agentPreset: 'minimal' }))
|
||||
|
||||
expect(response.result.ok).toBe(false)
|
||||
if (response.result.ok) throw new Error('unreachable')
|
||||
expect(response.result.error.code).toBe('agent-preset-invalid')
|
||||
expect(response.result.error.message).toMatch(/already exists/)
|
||||
})
|
||||
|
||||
it('rejects a copy whose source is unknown', async () => {
|
||||
const { api } = await harness(['standard'])
|
||||
|
||||
const response = await api.agentPresets.copy(request({ from: 'never-existed', agentPreset: 'mine' }))
|
||||
|
||||
expect(response.result.ok).toBe(false)
|
||||
if (response.result.ok) throw new Error('unreachable')
|
||||
expect(response.result.error.code).toBe('agent-preset-not-found')
|
||||
})
|
||||
|
||||
it('reports a deployment that composes no presets', async () => {
|
||||
const { api } = await harness()
|
||||
|
||||
const response = await api.agentPresets.read(request({ agentPreset: 'anything' }))
|
||||
|
||||
expect(response.result.ok).toBe(false)
|
||||
if (response.result.ok) throw new Error('unreachable')
|
||||
expect(response.result.error.code).toBe('agent-preset-not-found')
|
||||
})
|
||||
|
||||
it('reports an unknown id on delete rather than succeeding silently', async () => {
|
||||
const { api } = await harness(['standard'])
|
||||
|
||||
const response = await api.agentPresets.remove(request({ agentPreset: 'never-existed' }))
|
||||
|
||||
expect(response.result.ok).toBe(false)
|
||||
if (response.result.ok) throw new Error('unreachable')
|
||||
expect(response.result.error.code).toBe('agent-preset-not-found')
|
||||
})
|
||||
})
|
||||
|
||||
describe('opening a preset directory', () => {
|
||||
it('hands the resolved directory to the native opener', async () => {
|
||||
const opened: string[] = []
|
||||
const { api } = await harness(['standard', 'my-preset'], undefined, {
|
||||
userIds: ['my-preset'],
|
||||
defaults: { openPath: (path: string) => { opened.push(path); return Promise.resolve() } },
|
||||
})
|
||||
|
||||
const response = await api.agentPresets.openDocument(
|
||||
request({ agentPreset: 'my-preset' }), new AbortController().signal)
|
||||
|
||||
expect(response.result.ok).toBe(true)
|
||||
if (!response.result.ok) throw new Error('unreachable')
|
||||
expect(response.result.value).toEqual({ opened: true })
|
||||
// The id selected the directory; the browser supplied no path.
|
||||
expect(opened).toEqual(['/presets/my-preset'])
|
||||
})
|
||||
|
||||
it('answers the path as text where the deployment has no opener', async () => {
|
||||
const { api } = await harness(['standard', 'my-preset'], undefined, {
|
||||
userIds: ['my-preset'],
|
||||
defaults: { canOpenPath: () => false },
|
||||
})
|
||||
|
||||
const response = await api.agentPresets.openDocument(
|
||||
request({ agentPreset: 'my-preset' }), new AbortController().signal)
|
||||
|
||||
expect(response.result.ok).toBe(true)
|
||||
if (!response.result.ok) throw new Error('unreachable')
|
||||
expect(response.result.value).toEqual({ opened: false, path: '/presets/my-preset' })
|
||||
})
|
||||
|
||||
it('refuses a preset that ships with the deployment', async () => {
|
||||
const opened: string[] = []
|
||||
const { api } = await harness(['standard'], undefined, {
|
||||
defaults: { openPath: (path: string) => { opened.push(path); return Promise.resolve() } },
|
||||
})
|
||||
|
||||
const response = await api.agentPresets.openDocument(
|
||||
request({ agentPreset: 'standard' }), new AbortController().signal)
|
||||
|
||||
// Pointing an editor into the install invites edits an upgrade will
|
||||
// silently overwrite; the refusal mirrors copy/remove.
|
||||
expect(response.result.ok).toBe(false)
|
||||
if (response.result.ok) throw new Error('unreachable')
|
||||
expect(response.result.error.code).toBe('agent-preset-read-only')
|
||||
expect(opened).toEqual([])
|
||||
})
|
||||
|
||||
it('reports the roster capability on list', async () => {
|
||||
const openable = await harness(['standard'], undefined, {
|
||||
defaults: { canOpenPath: () => true },
|
||||
})
|
||||
const headless = await harness(['standard'], undefined, {
|
||||
defaults: { canOpenPath: () => false },
|
||||
})
|
||||
|
||||
const yes = await openable.api.agentPresets.list(request({}))
|
||||
const no = await headless.api.agentPresets.list(request({}))
|
||||
|
||||
expect(yes.result.ok && yes.result.value.hasDocument).toBe(true)
|
||||
expect(no.result.ok && no.result.value.hasDocument).toBe(false)
|
||||
})
|
||||
|
||||
it('counts an injected opener as openable', async () => {
|
||||
const { api } = await harness(['standard'], undefined, {
|
||||
defaults: { openPath: () => Promise.resolve() },
|
||||
})
|
||||
|
||||
const response = await api.agentPresets.list(request({}))
|
||||
|
||||
expect(response.result.ok && response.result.value.hasDocument).toBe(true)
|
||||
})
|
||||
})
|
||||
|
||||
describe('skills over the layered host registry', () => {
|
||||
it('passes the live agent as the view scope to the host registry', async () => {
|
||||
const { api, ctx } = await harness(['standard'])
|
||||
const seen: unknown[] = []
|
||||
ctx.provide('skills', {
|
||||
list: (options: { scope?: unknown }) => {
|
||||
seen.push(options.scope)
|
||||
return Promise.resolve([])
|
||||
},
|
||||
} as never)
|
||||
await api.sessions.create(request({ sessionId: SessionId('h1'), agentPreset: 'standard' }))
|
||||
|
||||
const response = await api.skills.list(request({ sessionId: SessionId('h1') }))
|
||||
|
||||
expect(response.result).toMatchObject({ ok: true, value: { skills: [] } })
|
||||
expect(seen).toEqual([ctx.agents.get(SessionId('h1'))])
|
||||
})
|
||||
|
||||
it('resolves a cold session to its recorded preset standing key', async () => {
|
||||
const { api, ctx } = await harness(['standard', 'minimal'])
|
||||
const seen: unknown[] = []
|
||||
ctx.provide('skills', {
|
||||
list: (options: { scope?: unknown }) => {
|
||||
seen.push(options.scope)
|
||||
return Promise.resolve([])
|
||||
},
|
||||
} as never)
|
||||
ctx.sessions.create(SessionId('h2'), { meta: { cwd: '/workspace/cold', agentPreset: 'minimal' } })
|
||||
|
||||
const response = await api.skills.list(request({ sessionId: SessionId('h2') }))
|
||||
|
||||
expect(response.result).toMatchObject({ ok: true, value: { skills: [] } })
|
||||
expect(seen).toEqual([standingKeys.get('minimal')])
|
||||
})
|
||||
|
||||
it('serves the global view when the roster no longer supplies the recorded preset', async () => {
|
||||
const { api, ctx } = await harness(['standard'])
|
||||
const seen: unknown[] = []
|
||||
ctx.provide('skills', {
|
||||
list: (options: { scope?: unknown }) => {
|
||||
seen.push(options.scope)
|
||||
return Promise.resolve([])
|
||||
},
|
||||
} as never)
|
||||
ctx.sessions.create(SessionId('h3'), { meta: { cwd: '/workspace/cold', agentPreset: 'gone' } })
|
||||
|
||||
const response = await api.skills.list(request({ sessionId: SessionId('h3') }))
|
||||
|
||||
expect(response.result).toMatchObject({ ok: true, value: { skills: [] } })
|
||||
expect(seen).toEqual([undefined])
|
||||
})
|
||||
})
|
||||
|
||||
describe('session.history presenter scope', () => {
|
||||
it('asks the roster for the RECORDED preset\'s standing key on a cold read', async () => {
|
||||
const { api } = await harness(['standard', 'minimal'])
|
||||
await api.sessions.create(request({ sessionId: SessionId('p1'), agentPreset: 'minimal' }))
|
||||
// Cold: creation registered a live agent in this harness, so simulate the
|
||||
// cold path by asking for a session only persistence knows... the harness
|
||||
// has no persistence, so read the live one and assert no roster query.
|
||||
standingKeyRequests.length = 0
|
||||
const live = await api.sessions.history(request({ sessionId: SessionId('p1') }))
|
||||
expect(live.result.ok).toBe(true)
|
||||
// A live agent IS the presenter scope; the roster is not consulted.
|
||||
expect(standingKeyRequests).toEqual([])
|
||||
})
|
||||
|
||||
it('resolves a switched session from the LOG, not its creation header', async () => {
|
||||
// The header is a creation fact; a switch while blank is a logged event,
|
||||
// and every turn after it ran under the newer composition. Reading the
|
||||
// header would render that history through the older preset's layer,
|
||||
// where the tools it is made of have no presenter at all.
|
||||
const meta = { id: SessionId('p4'), createdAt: 1, cwd: '/tmp/p4', agentPreset: 'standard' }
|
||||
const { api } = await harness(['standard', 'minimal'], {
|
||||
list: () => Promise.resolve([meta]),
|
||||
inspect: () => Promise.resolve({
|
||||
meta,
|
||||
events: [{ type: 'agent-preset/selected', seq: 1, time: 0, data: { agentPreset: 'minimal' } }],
|
||||
}),
|
||||
})
|
||||
|
||||
standingKeyRequests.length = 0
|
||||
const response = await api.sessions.history(request({ sessionId: SessionId('p4') }))
|
||||
|
||||
expect(response.result.ok).toBe(true)
|
||||
expect(standingKeyRequests).toEqual(['minimal'])
|
||||
})
|
||||
|
||||
it('serves a COLD transcript whose standing mount is no longer usable', async () => {
|
||||
// A genuinely cold session: persistence knows it, no live agent exists.
|
||||
const meta = { id: SessionId('p3'), createdAt: 1, cwd: '/tmp/p3', agentPreset: 'standard' }
|
||||
const { api } = await harness(['standard'], {
|
||||
list: () => Promise.resolve([meta]),
|
||||
inspect: () => Promise.resolve({ meta, events: [] }),
|
||||
})
|
||||
// The preset broke after the session ran: the roster rejects the mount.
|
||||
failingStandingKeys.add('standard')
|
||||
try {
|
||||
standingKeyRequests.length = 0
|
||||
const response = await api.sessions.history(request({ sessionId: SessionId('p3') }))
|
||||
// Degraded, never failed: the roster WAS asked, and the transcript
|
||||
// still serves — with the generic cards a viewless entry renders.
|
||||
expect(standingKeyRequests).toEqual(['standard'])
|
||||
expect(response.result.ok).toBe(true)
|
||||
} finally {
|
||||
failingStandingKeys.delete('standard')
|
||||
}
|
||||
})
|
||||
})
|
||||
@@ -7,7 +7,7 @@
|
||||
*/
|
||||
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { Context } from 'cordis'
|
||||
import { Context } from '@deepseek-ai/cordis'
|
||||
import AgentRegistry from '@deepseek-ai/dsh-agent'
|
||||
import type { Agent } from '@deepseek-ai/dsh-agent'
|
||||
import SessionStore from '@deepseek-ai/dsh-session'
|
||||
@@ -27,7 +27,7 @@ async function harness(): Promise<{ ctx: Context; api: ApiProxy }> {
|
||||
await ctx.plugin(UserInteractionService)
|
||||
await ctx.plugin(AgentRegistry)
|
||||
await ctx.plugin(ApprovalService)
|
||||
const api = createApiProxy(ctx, { defaultModelSelection: () => ({ provider: 'p', model: 'm' }), cwd: '/tmp', workspaceRoot: '/tmp' })
|
||||
const api = createApiProxy(ctx, { defaultModelSelection: () => ({ provider: 'p', model: 'm' }), cwd: '/tmp' })
|
||||
return { ctx, api }
|
||||
}
|
||||
|
||||
@@ -217,7 +217,7 @@ describe('approval pending registry', () => {
|
||||
await ctx.plugin(ApprovalService)
|
||||
let api!: ApiProxy
|
||||
const fiber = ctx.plugin(Object.assign((fiberCtx: Context) => {
|
||||
api = createApiProxy(fiberCtx, { defaultModelSelection: () => ({ provider: 'p', model: 'm' }), cwd: '/tmp', workspaceRoot: '/tmp' })
|
||||
api = createApiProxy(fiberCtx, { defaultModelSelection: () => ({ provider: 'p', model: 'm' }), cwd: '/tmp' })
|
||||
}, { inject: ['sessions', 'agents', 'userInteraction', 'approval'] }))
|
||||
await fiber.await()
|
||||
const abort = new AbortController()
|
||||
|
||||
@@ -8,7 +8,7 @@
|
||||
*/
|
||||
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { Context } from 'cordis'
|
||||
import { Context } from '@deepseek-ai/cordis'
|
||||
import AgentRegistry from '@deepseek-ai/dsh-agent'
|
||||
import type { Agent } from '@deepseek-ai/dsh-agent'
|
||||
import SessionStore from '@deepseek-ai/dsh-session'
|
||||
@@ -35,7 +35,7 @@ async function harness(): Promise<{ ctx: Context; api: ApiProxy; attach: (sessio
|
||||
await ctx.plugin(AgentRegistry)
|
||||
return {
|
||||
ctx,
|
||||
api: createApiProxy(ctx, { defaultModelSelection: () => ({ provider: 'p', model: 'm' }), cwd: '/tmp', workspaceRoot: '/tmp' }),
|
||||
api: createApiProxy(ctx, { defaultModelSelection: () => ({ provider: 'p', model: 'm' }), cwd: '/tmp' }),
|
||||
attach: (session) => {
|
||||
ctx.agents.register({ id: session.id, session, status: 'idle', ctx } as Agent)
|
||||
},
|
||||
|
||||
@@ -8,7 +8,7 @@ import { mkdtempSync, writeFileSync, utimesSync } from 'node:fs'
|
||||
import { tmpdir } from 'node:os'
|
||||
import { join } from 'node:path'
|
||||
import { describe, expect, it, vi } from 'vitest'
|
||||
import { Context } from 'cordis'
|
||||
import { Context } from '@deepseek-ai/cordis'
|
||||
import SessionStore from '@deepseek-ai/dsh-session'
|
||||
import AgentRegistry from '@deepseek-ai/dsh-agent'
|
||||
import { TypeRTLookupFailure } from '@deepseek-ai/dsh-type-meta'
|
||||
@@ -64,7 +64,7 @@ describe('sessions.list cold merge', () => {
|
||||
return undefined
|
||||
},
|
||||
})
|
||||
const api = createApiProxy(ctx, { defaultModelSelection: () => ({ provider: 'p', model: 'm' }), cwd: '/tmp', workspaceRoot: '/tmp' })
|
||||
const api = createApiProxy(ctx, { defaultModelSelection: () => ({ provider: 'p', model: 'm' }), cwd: '/tmp' })
|
||||
|
||||
const response = await api.sessions.list(request({}))
|
||||
expect(response.result.ok).toBe(true)
|
||||
@@ -92,7 +92,7 @@ describe('attached updatedAt excludes end-seed', () => {
|
||||
await ctx.plugin(SessionStore)
|
||||
await ctx.plugin(UserInteractionService)
|
||||
await ctx.plugin(AgentRegistry)
|
||||
const api = createApiProxy(ctx, { defaultModelSelection: () => ({ provider: 'p', model: 'm' }), cwd: '/tmp', workspaceRoot: '/tmp' })
|
||||
const api = createApiProxy(ctx, { defaultModelSelection: () => ({ provider: 'p', model: 'm' }), cwd: '/tmp' })
|
||||
|
||||
// Old work, resumed just now: the log tail would report the pickup.
|
||||
const worked = 1_000_000
|
||||
@@ -150,7 +150,7 @@ describe('cold history recovery view', () => {
|
||||
inspect: (id: SessionId, signal?: AbortSignal) => coordinator.inspect(id, signal),
|
||||
locate: () => undefined,
|
||||
} as never)
|
||||
const api = createApiProxy(ctx, { defaultModelSelection: () => ({ provider: 'p', model: 'm' }), cwd: '/tmp', workspaceRoot: '/tmp' })
|
||||
const api = createApiProxy(ctx, { defaultModelSelection: () => ({ provider: 'p', model: 'm' }), cwd: '/tmp' })
|
||||
|
||||
const history = await api.sessions.history(request({ sessionId, beforeSeq: 2, maxMessages: 10 }))
|
||||
if (!history.result.ok) throw new Error('history failed')
|
||||
@@ -206,7 +206,7 @@ describe('Remote Agent and Session lookup policy', () => {
|
||||
})
|
||||
const defaultAgentLookup = ctx.typert.lookups.get('agent')
|
||||
const defaultSessionLookup = ctx.typert.lookups.get('session')
|
||||
createApiProxy(ctx, { defaultModelSelection: () => ({ provider: 'p', model: 'm' }), cwd: '/tmp', workspaceRoot: '/tmp' })
|
||||
createApiProxy(ctx, { defaultModelSelection: () => ({ provider: 'p', model: 'm' }), cwd: '/tmp' })
|
||||
await vi.waitFor(() => {
|
||||
expect(ctx.typert.lookups.get('agent')).not.toBe(defaultAgentLookup)
|
||||
expect(ctx.typert.lookups.get('session')).not.toBe(defaultSessionLookup)
|
||||
@@ -250,7 +250,7 @@ describe('Remote Agent and Session lookup policy', () => {
|
||||
const resume = vi.spyOn(ctx.agents, 'resume')
|
||||
const defaultAgentLookup = ctx.typert.lookups.get('agent')
|
||||
const defaultSessionLookup = ctx.typert.lookups.get('session')
|
||||
createApiProxy(ctx, { defaultModelSelection: () => ({ provider: 'p', model: 'm' }), cwd: '/tmp', workspaceRoot: '/tmp' })
|
||||
createApiProxy(ctx, { defaultModelSelection: () => ({ provider: 'p', model: 'm' }), cwd: '/tmp' })
|
||||
await vi.waitFor(() => {
|
||||
expect(ctx.typert.lookups.get('agent')).not.toBe(defaultAgentLookup)
|
||||
expect(ctx.typert.lookups.get('session')).not.toBe(defaultSessionLookup)
|
||||
@@ -312,7 +312,7 @@ describe('subagent ownership fence', () => {
|
||||
locate: () => undefined,
|
||||
} as never)
|
||||
const resume = vi.spyOn(ctx.agents, 'resume')
|
||||
const api = createApiProxy(ctx, { defaultModelSelection: () => ({ provider: 'p', model: 'm' }), cwd: '/tmp', workspaceRoot: '/tmp' })
|
||||
const api = createApiProxy(ctx, { defaultModelSelection: () => ({ provider: 'p', model: 'm' }), cwd: '/tmp' })
|
||||
|
||||
const history = await api.sessions.history(request({ sessionId }))
|
||||
expect(history.result.ok).toBe(true)
|
||||
@@ -371,7 +371,7 @@ describe('subagent ownership fence', () => {
|
||||
// answering `agent-busy`.
|
||||
const resume = vi.spyOn(ctx.agents, 'resume')
|
||||
.mockRejectedValue(new Error('registry unavailable in this bench'))
|
||||
const api = createApiProxy(ctx, { defaultModelSelection: () => ({ provider: 'p', model: 'm' }), cwd: '/tmp', workspaceRoot: '/tmp' })
|
||||
const api = createApiProxy(ctx, { defaultModelSelection: () => ({ provider: 'p', model: 'm' }), cwd: '/tmp' })
|
||||
|
||||
const prompt = await api.sessions.prompt(request({
|
||||
sessionId,
|
||||
@@ -412,7 +412,7 @@ describe('subagent ownership fence', () => {
|
||||
})
|
||||
const startingChild = { id: startingSession.id, session: startingSession, status: 'idle', ctx } as Agent
|
||||
ctx.agents.enter(startingChild, parent)
|
||||
const api = createApiProxy(ctx, { defaultModelSelection: () => ({ provider: 'p', model: 'm' }), cwd: '/tmp', workspaceRoot: '/tmp' })
|
||||
const api = createApiProxy(ctx, { defaultModelSelection: () => ({ provider: 'p', model: 'm' }), cwd: '/tmp' })
|
||||
|
||||
const stopped = await api.sessions.cancel(request({ sessionId: originChild.id }))
|
||||
expect(stopped.result.ok).toBe(false)
|
||||
@@ -458,7 +458,7 @@ describe('subagent ownership fence', () => {
|
||||
const followup = vi.fn()
|
||||
const agent = { id: session.id, session, status: 'idle', ctx, followup } as unknown as Agent
|
||||
ctx.agents.register(agent)
|
||||
const api = createApiProxy(ctx, { defaultModelSelection: () => ({ provider: 'p', model: 'm' }), cwd: '/tmp', workspaceRoot: '/tmp' })
|
||||
const api = createApiProxy(ctx, { defaultModelSelection: () => ({ provider: 'p', model: 'm' }), cwd: '/tmp' })
|
||||
|
||||
const response = await api.sessions.prompt(request({
|
||||
sessionId: agent.id,
|
||||
@@ -481,7 +481,6 @@ describe('subagent ownership fence', () => {
|
||||
const api = createApiProxy(ctx, {
|
||||
defaultModelSelection: () => ({ provider: 'p', model: 'm' }),
|
||||
cwd: '/tmp',
|
||||
workspaceRoot: '/tmp',
|
||||
})
|
||||
|
||||
const alias = 'US/Pacific'
|
||||
@@ -551,7 +550,7 @@ describe('degenerate composition (no persistence, no factory)', () => {
|
||||
await ctx.plugin(SessionStore)
|
||||
await ctx.plugin(AgentRegistry)
|
||||
await ctx.plugin(UserInteractionService)
|
||||
const api = createApiProxy(ctx, { defaultModelSelection: () => ({ provider: 'p', model: 'm' }), cwd: '/tmp', workspaceRoot: '/tmp' })
|
||||
const api = createApiProxy(ctx, { defaultModelSelection: () => ({ provider: 'p', model: 'm' }), cwd: '/tmp' })
|
||||
|
||||
const listed = await api.sessions.list(request({}))
|
||||
expect(listed.result.ok).toBe(true)
|
||||
@@ -576,7 +575,7 @@ describe('degenerate composition (no persistence, no factory)', () => {
|
||||
list: () => Promise.resolve([]),
|
||||
inspect,
|
||||
} as never)
|
||||
const api = createApiProxy(ctx, { defaultModelSelection: () => ({ provider: 'p', model: 'm' }), cwd: '/tmp', workspaceRoot: '/tmp' })
|
||||
const api = createApiProxy(ctx, { defaultModelSelection: () => ({ provider: 'p', model: 'm' }), cwd: '/tmp' })
|
||||
|
||||
const response = await api.sessions.history(request({ sessionId: sid('session-missing') }))
|
||||
expect(response.result.ok).toBe(false)
|
||||
@@ -602,7 +601,7 @@ describe('sessions.prompt synchronous rejection', () => {
|
||||
followup: () => { throw new Error('agent "session-throwing" lifecycle disposed') },
|
||||
steer: () => { throw new Error('agent "session-throwing" lifecycle disposed') },
|
||||
} as unknown as Agent)
|
||||
const api = createApiProxy(ctx, { defaultModelSelection: () => ({ provider: 'p', model: 'm' }), cwd: '/tmp', workspaceRoot: '/tmp' })
|
||||
const api = createApiProxy(ctx, { defaultModelSelection: () => ({ provider: 'p', model: 'm' }), cwd: '/tmp' })
|
||||
|
||||
for (const mode of ['queue', 'steer'] as const) {
|
||||
const response = await api.sessions.prompt(request({
|
||||
@@ -646,7 +645,7 @@ describe('sessions.prompt synchronous rejection', () => {
|
||||
ctx.agents.register(child)
|
||||
throw new Error('session id already published')
|
||||
})
|
||||
const api = createApiProxy(ctx, { defaultModelSelection: () => ({ provider: 'p', model: 'm' }), cwd: '/tmp', workspaceRoot: '/tmp' })
|
||||
const api = createApiProxy(ctx, { defaultModelSelection: () => ({ provider: 'p', model: 'm' }), cwd: '/tmp' })
|
||||
|
||||
const models = await api.sessions.models(request({ sessionId }))
|
||||
expect(models.result.ok).toBe(false)
|
||||
|
||||
@@ -10,7 +10,7 @@ import { MessageId, freezeMessage } from '@deepseek-ai/dsh-llm'
|
||||
*/
|
||||
|
||||
import { describe, expect, it, vi } from 'vitest'
|
||||
import { Context } from 'cordis'
|
||||
import { Context } from '@deepseek-ai/cordis'
|
||||
import AgentRegistry, { Inbox } from '@deepseek-ai/dsh-agent'
|
||||
import type { Agent } from '@deepseek-ai/dsh-agent'
|
||||
import SessionStore from '@deepseek-ai/dsh-session'
|
||||
@@ -25,7 +25,7 @@ import type { RpcRequest, RpcResponse } from '../src/api/rpc.ts'
|
||||
import { RpcId } from '../src/api/rpc.ts'
|
||||
import { createApiProxy } from '../src/api-proxy.ts'
|
||||
|
||||
const DEFAULTS = { defaultModelSelection: () => ({ provider: 'p', model: 'm' }), cwd: '/tmp', workspaceRoot: '/tmp' }
|
||||
const DEFAULTS = { defaultModelSelection: () => ({ provider: 'p', model: 'm' }), cwd: '/tmp' }
|
||||
|
||||
function request<P>(payload: P): RpcRequest<P> {
|
||||
return { rpcId: RpcId(`req-${String(nextRpc++)}`), payload }
|
||||
|
||||
@@ -6,8 +6,8 @@
|
||||
*/
|
||||
|
||||
import { describe, expect, it, vi } from 'vitest'
|
||||
import { Context } from 'cordis'
|
||||
import z from 'schemastery'
|
||||
import { Context } from '@deepseek-ai/cordis'
|
||||
import z from '@deepseek-ai/schemastery'
|
||||
import AgentRegistry from '@deepseek-ai/dsh-agent'
|
||||
import SessionStore from '@deepseek-ai/dsh-session'
|
||||
import SystemPrompt from '@deepseek-ai/dsh-system-prompt'
|
||||
@@ -25,7 +25,7 @@ import { RpcId } from '../src/api/rpc.ts'
|
||||
import { AGENT_DEFAULT_MODEL_SETTINGS_NAMESPACE } from '@deepseek-ai/dsh-agent-default-model'
|
||||
import { createApiProxy } from '../src/api-proxy.ts'
|
||||
|
||||
const DEFAULTS = { defaultModelSelection: () => ({ provider: 'p', model: 'm' }), cwd: '/tmp', workspaceRoot: '/tmp' }
|
||||
const DEFAULTS = { defaultModelSelection: () => ({ provider: 'p', model: 'm' }), cwd: '/tmp' }
|
||||
|
||||
let nextRpc = 1
|
||||
function request<P>(payload: P): RpcRequest<P> {
|
||||
@@ -309,8 +309,8 @@ describe('settings domain', () => {
|
||||
// The settings seam is general: any plugin may register a namespace for
|
||||
// its own configuration. The Web configuration plane remains opt-in, so a
|
||||
// future internal plugin cannot become remotely configurable just by
|
||||
// registering; permission and the product onboarding namespace are the
|
||||
// non-model namespaces intentionally admitted by this surface.
|
||||
// registering; locale, permission, conversation, theme, and the product
|
||||
// onboarding namespace are intentionally admitted by this surface.
|
||||
const ctx = await harness()
|
||||
ctx.settings.register(NS, AdapterConfig)
|
||||
ctx.settings.register(settingsNamespace('some-other-plugin'), z.object({ secretPath: z.string() }))
|
||||
@@ -319,15 +319,41 @@ describe('settings domain', () => {
|
||||
}), {
|
||||
base: { defaultPreset: 'read-only' },
|
||||
})
|
||||
ctx.settings.register(settingsNamespace('ui-theme'), z.object({
|
||||
preference: z.union(['light', 'dark', 'system']).default('system'),
|
||||
}))
|
||||
ctx.settings.register(settingsNamespace('locale'), z.object({
|
||||
preference: z.union(['zh', 'en']).required(false),
|
||||
}))
|
||||
ctx.settings.register(settingsNamespace('ui-conversation'), z.object({
|
||||
busyEnter: z.union(['queue', 'steer']).default('queue'),
|
||||
}))
|
||||
const api = createApiProxy(ctx, DEFAULTS)
|
||||
|
||||
const value = expectOk(await api.settings.describe(request({})))
|
||||
expect(value.namespaces.map(view => view.ns)).toEqual(['llm-deepseek', 'permission'])
|
||||
expect(value.namespaces.map(view => view.ns)).toEqual([
|
||||
'llm-deepseek', 'permission', 'ui-theme', 'locale', 'ui-conversation',
|
||||
])
|
||||
const permission = expectOk(await api.settings.mutate(request({
|
||||
ns: 'permission',
|
||||
ops: [{ op: 'set', path: ['defaultPreset'], value: 'workspace-write' }],
|
||||
})))
|
||||
expect(permission.value).toEqual({ defaultPreset: 'workspace-write' })
|
||||
const theme = expectOk(await api.settings.mutate(request({
|
||||
ns: 'ui-theme',
|
||||
ops: [{ op: 'set', path: ['preference'], value: 'dark' }],
|
||||
})))
|
||||
expect(theme.value).toEqual({ preference: 'dark' })
|
||||
const locale = expectOk(await api.settings.mutate(request({
|
||||
ns: 'locale',
|
||||
ops: [{ op: 'set', path: ['preference'], value: 'en' }],
|
||||
})))
|
||||
expect(locale.value).toEqual({ preference: 'en' })
|
||||
const conversation = expectOk(await api.settings.mutate(request({
|
||||
ns: 'ui-conversation',
|
||||
ops: [{ op: 'set', path: ['busyEnter'], value: 'steer' }],
|
||||
})))
|
||||
expect(conversation.value).toEqual({ busyEnter: 'steer' })
|
||||
|
||||
for (const response of [
|
||||
await api.settings.update(request({ ns: 'some-other-plugin', patch: { secretPath: '/etc/shadow' } })),
|
||||
@@ -341,19 +367,44 @@ describe('settings domain', () => {
|
||||
expect(ctx.settings.describe().find(d => String(d.ns) === 'some-other-plugin')?.value).toEqual({})
|
||||
})
|
||||
|
||||
it('serves the product onboarding namespace without invalidating the model catalog', async () => {
|
||||
it('serves product preference namespaces without invalidating the model catalog', async () => {
|
||||
const ctx = await harness()
|
||||
ctx.settings.register(settingsNamespace('ui-onboarding'), z.object({ welcomeNoticeVersion: z.string() }))
|
||||
ctx.settings.register(settingsNamespace('ui-theme'), z.object({
|
||||
preference: z.union(['light', 'dark', 'system']).default('system'),
|
||||
}))
|
||||
const api = createApiProxy(ctx, DEFAULTS)
|
||||
expect(expectOk(await api.settings.describe(request({}))).namespaces.map(view => view.ns))
|
||||
.toEqual(['ui-onboarding'])
|
||||
const frames = await collectHost(api, ['host/settings-changed'], 1, async () => {
|
||||
.toEqual(['ui-onboarding', 'ui-theme'])
|
||||
const frames = await collectHost(api, ['host/settings-changed'], 2, async () => {
|
||||
expectOk(await api.settings.mutate(request({
|
||||
ns: 'ui-onboarding',
|
||||
ops: [{ op: 'set', path: ['welcomeNoticeVersion'], value: 'v1' }],
|
||||
})))
|
||||
expectOk(await api.settings.mutate(request({
|
||||
ns: 'ui-theme',
|
||||
ops: [{ op: 'set', path: ['preference'], value: 'dark' }],
|
||||
})))
|
||||
})
|
||||
expect(frames).toEqual([{ type: 'host/settings-changed', ns: 'ui-onboarding' }])
|
||||
expect(frames).toEqual([
|
||||
{ type: 'host/settings-changed', ns: 'ui-onboarding' },
|
||||
{ type: 'host/settings-changed', ns: 'ui-theme' },
|
||||
])
|
||||
})
|
||||
|
||||
it('serves the agent-preset namespace, so a browser preset picker can persist its choice', async () => {
|
||||
const ctx = await harness()
|
||||
ctx.settings.register(settingsNamespace('agent-presets'), z.object({ default: z.string() }))
|
||||
const api = createApiProxy(ctx, DEFAULTS)
|
||||
|
||||
expectOk(await api.settings.update(request({ ns: 'agent-presets', patch: { default: 'minimal' } })))
|
||||
|
||||
// Both browser surfaces that offer the choice — the General row and the
|
||||
// management section — write the default through `settings.update`, so a
|
||||
// namespace outside this boundary makes the picker move and then silently
|
||||
// forget, which is worse than refusing the control.
|
||||
expect(ctx.settings.describe().find(view => String(view.ns) === 'agent-presets')?.value)
|
||||
.toEqual({ default: 'minimal' })
|
||||
})
|
||||
|
||||
it('refuses even a model-provider namespace once its directory entry is gone', async () => {
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
/** Session-fork boundaries, lineage, and inherited model routing. */
|
||||
|
||||
import { describe, expect, it, vi } from 'vitest'
|
||||
import { Context } from 'cordis'
|
||||
import { Context } from '@deepseek-ai/cordis'
|
||||
import AgentRegistry, { agentEvents } from '@deepseek-ai/dsh-agent'
|
||||
import type { Agent, AgentHandle, CreateAgentOptions } from '@deepseek-ai/dsh-agent'
|
||||
import { createUserMessage, ReasoningEffortId } from '@deepseek-ai/dsh-llm'
|
||||
@@ -84,7 +84,6 @@ function liveAgent(
|
||||
const api = (ctx: Context) => createApiProxy(ctx, {
|
||||
defaultModelSelection: () => ({ provider: 'default-provider', model: 'default-model' }),
|
||||
cwd: '/tmp',
|
||||
workspaceRoot: '/tmp',
|
||||
})
|
||||
|
||||
describe('sessions.fork', () => {
|
||||
|
||||
@@ -5,14 +5,15 @@
|
||||
* boundary for a running selection change.
|
||||
*/
|
||||
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { Context } from 'cordis'
|
||||
import { describe, expect, it, vi } from 'vitest'
|
||||
import { Context } from '@deepseek-ai/cordis'
|
||||
import AgentRegistry, { agentEvents } from '@deepseek-ai/dsh-agent'
|
||||
import type { Agent } from '@deepseek-ai/dsh-agent'
|
||||
import LlmService, { LlmAdapter, ReasoningEffortId } from '@deepseek-ai/dsh-llm'
|
||||
import type {
|
||||
GenerateOptions, LlmCallConfig, LlmModelInfo, LlmModelReasoningInfo, LlmProviderInfo,
|
||||
LlmResolvedModelInfo, StreamChunk,
|
||||
UserMessage,
|
||||
} from '@deepseek-ai/dsh-llm'
|
||||
import SessionStore from '@deepseek-ai/dsh-session'
|
||||
import type { SessionId } from '@deepseek-ai/dsh-session'
|
||||
@@ -108,7 +109,8 @@ async function harness(logged?: {
|
||||
session,
|
||||
status: 'running',
|
||||
ctx,
|
||||
} as Agent
|
||||
inbox: { nextTurn: [], nextStep: [] },
|
||||
} as unknown as Agent
|
||||
ctx.agents.register(agent)
|
||||
return { ctx, agent, sessionId: session.id }
|
||||
}
|
||||
@@ -118,14 +120,161 @@ function expectValue<T>(response: { result: { ok: true; value: T } | { ok: false
|
||||
return response.result.value
|
||||
}
|
||||
|
||||
function registerTextOnly(ctx: Context): void {
|
||||
ctx.llm.registerAdapter(['text-only'], new class extends CatalogAdapter {
|
||||
override resolveModel(provider: string, model: string): Promise<LlmResolvedModelInfo> {
|
||||
return Promise.resolve({ provider, id: model, name: model, inputModalities: ['text'] })
|
||||
}
|
||||
}('Text Only', []))
|
||||
}
|
||||
|
||||
describe('Web session model selection', () => {
|
||||
it('validates an ordered image batch before persisting any member', async () => {
|
||||
const { ctx, agent, sessionId } = await harness()
|
||||
const validateImage = vi.fn((_input: { data: Uint8Array }) => Promise.resolve())
|
||||
const saveImage = vi.fn((input: { data: Uint8Array; mediaType: 'image/png'; name?: string }) => Promise.resolve({
|
||||
attachmentId: `att-${String(input.data[0])}`,
|
||||
mediaType: input.mediaType,
|
||||
bytes: input.data.byteLength,
|
||||
width: 1,
|
||||
height: 1,
|
||||
...input.name === undefined ? {} : { name: input.name },
|
||||
}))
|
||||
ctx.provide('attachments', {
|
||||
imageLimits: {
|
||||
maxImageBytes: 4,
|
||||
maxImagesPerMessage: 2,
|
||||
maxMessageImageBytes: 4,
|
||||
maxImagePixels: 4,
|
||||
mediaTypes: ['image/png'],
|
||||
},
|
||||
validateImage,
|
||||
saveImage,
|
||||
} as never)
|
||||
const followup = vi.fn()
|
||||
Object.assign(agent, { followup })
|
||||
const api = createApiProxy(ctx, {
|
||||
defaultModelSelection: () => ({ provider: 'deepseek-official', model: 'deepseek-chat' }),
|
||||
cwd: '/tmp',
|
||||
})
|
||||
|
||||
const result = await api.sessions.prompt(request({
|
||||
sessionId,
|
||||
mode: 'queue' as const,
|
||||
content: [
|
||||
{ type: 'image' as const, mediaType: 'image/png' as const, data: 'AQ==', name: 'first.png' },
|
||||
{ type: 'text' as const, text: 'compare' },
|
||||
{ type: 'image' as const, mediaType: 'image/png' as const, data: 'Ag==' },
|
||||
],
|
||||
}))
|
||||
expect(result.result.ok).toBe(true)
|
||||
expect(validateImage.mock.calls.map(([input]) => [...input.data])).toEqual([[1], [2]])
|
||||
expect(saveImage.mock.calls.map(([input]) => [...input.data])).toEqual([[1], [2]])
|
||||
expect((followup.mock.calls[0]?.[0] as UserMessage).content).toEqual([
|
||||
{
|
||||
type: 'image',
|
||||
attachment: {
|
||||
attachmentId: 'att-1', mediaType: 'image/png', bytes: 1, width: 1, height: 1, name: 'first.png',
|
||||
},
|
||||
},
|
||||
{ type: 'text', text: 'compare' },
|
||||
{ type: 'image', attachment: { attachmentId: 'att-2', mediaType: 'image/png', bytes: 1, width: 1, height: 1 } },
|
||||
])
|
||||
|
||||
const denied = await api.sessions.prompt(request({
|
||||
sessionId,
|
||||
mode: 'queue' as const,
|
||||
content: Array.from({ length: 3 }, () => ({
|
||||
type: 'image' as const, mediaType: 'image/png' as const, data: 'AQ==',
|
||||
})),
|
||||
}))
|
||||
expect(denied.result).toMatchObject({
|
||||
ok: false,
|
||||
error: { code: 'attachment-error', details: { reason: 'TOO_MANY_IMAGES' } },
|
||||
})
|
||||
expect(saveImage).toHaveBeenCalledTimes(2)
|
||||
await ctx.fiber.dispose()
|
||||
})
|
||||
|
||||
it('refuses a text-only selection while durable or pending image content remains visible', async () => {
|
||||
const { ctx, agent, sessionId } = await harness()
|
||||
registerTextOnly(ctx)
|
||||
const api = createApiProxy(ctx, {
|
||||
defaultModelSelection: () => ({ provider: 'deepseek-official', model: 'deepseek-chat' }),
|
||||
cwd: '/tmp',
|
||||
})
|
||||
const image = {
|
||||
type: 'image' as const,
|
||||
attachment: { attachmentId: 'att-history', mediaType: 'image/png' as const, bytes: 1, width: 1, height: 1 },
|
||||
}
|
||||
agent.session.append('user/message', {
|
||||
id: 'image-message', role: 'user', source: { kind: 'user' }, content: [image],
|
||||
} as never, { surfaceOp: 'append' })
|
||||
expect((await api.sessions.selectModel(request({
|
||||
sessionId, provider: 'text-only', model: 'plain',
|
||||
}))).result).toMatchObject({ ok: false, error: { code: 'model-unavailable' } })
|
||||
|
||||
agent.session.append('user/message', {
|
||||
id: 'summary', role: 'user', source: { kind: 'plugin', plugin: 'compact' },
|
||||
content: [{ type: 'text', text: 'image summarized' }],
|
||||
} as never, {
|
||||
surfaceOp: { op: 'replace', start: 0, end: agent.session.events.length - 1 },
|
||||
sourceEventSeqs: agent.session.events.map(event => event.seq),
|
||||
})
|
||||
;(agent.inbox.nextTurn as UserMessage[]).push({
|
||||
id: 'pending-image', role: 'user', source: { kind: 'user' }, content: [image],
|
||||
} as never)
|
||||
expect((await api.sessions.selectModel(request({
|
||||
sessionId, provider: 'text-only', model: 'plain',
|
||||
}))).result.ok).toBe(false)
|
||||
;(agent.inbox.nextTurn as UserMessage[]).length = 0
|
||||
expect(expectValue(await api.sessions.selectModel(request({
|
||||
sessionId, provider: 'text-only', model: 'plain',
|
||||
}))).selected).toEqual({ provider: 'text-only', model: 'plain' })
|
||||
await ctx.fiber.dispose()
|
||||
})
|
||||
|
||||
it('authorizes attachment bytes only when the session event stream references the id', async () => {
|
||||
const { ctx, agent, sessionId } = await harness()
|
||||
const ref = {
|
||||
attachmentId: 'att-authorized', mediaType: 'image/png' as const, bytes: 2, width: 1, height: 1,
|
||||
}
|
||||
const readImage = vi.fn(() => Promise.resolve({ ref, data: Uint8Array.of(1, 2) }))
|
||||
ctx.provide('attachments', { readImage } as never)
|
||||
const api = createApiProxy(ctx, {
|
||||
defaultModelSelection: () => ({ provider: 'deepseek-official', model: 'deepseek-chat' }),
|
||||
cwd: '/tmp',
|
||||
})
|
||||
agent.session.append('agent/inbox/spliced', {
|
||||
target: 'next-turn',
|
||||
start: 0,
|
||||
inserted: [{
|
||||
id: 'queued-image', role: 'user', source: { kind: 'user' },
|
||||
content: [{ type: 'image', attachment: ref }],
|
||||
}],
|
||||
} as never)
|
||||
|
||||
const allowed = await api.sessions.attachment(request({
|
||||
sessionId, attachmentId: 'att-authorized' as never,
|
||||
}))
|
||||
expect(allowed.result).toMatchObject({ ok: true, value: { attachment: ref, data: 'AQI=' } })
|
||||
const denied = await api.sessions.attachment(request({
|
||||
sessionId, attachmentId: 'att-other' as never,
|
||||
}))
|
||||
expect(denied.result).toMatchObject({
|
||||
ok: false,
|
||||
error: { code: 'attachment-error', details: { reason: 'ATTACHMENT_NOT_REFERENCED' } },
|
||||
})
|
||||
expect(readImage).toHaveBeenCalledOnce()
|
||||
await ctx.fiber.dispose()
|
||||
})
|
||||
it('groups successful providers and leaves an unlisted current selection out of the catalog', async () => {
|
||||
const { ctx, sessionId } = await harness({
|
||||
provider: 'deepseek-official',
|
||||
model: 'private-preview',
|
||||
reasoningEffort: ReasoningEffortId('max'),
|
||||
})
|
||||
const api = createApiProxy(ctx, { defaultModelSelection: () => ({ provider: 'deepseek-official', model: 'deepseek-chat' }), cwd: '/tmp', workspaceRoot: '/tmp' })
|
||||
const api = createApiProxy(ctx, { defaultModelSelection: () => ({ provider: 'deepseek-official', model: 'deepseek-chat' }), cwd: '/tmp' })
|
||||
|
||||
const catalog = expectValue(await api.sessions.models(request({ sessionId })))
|
||||
expect(catalog.current).toEqual({
|
||||
@@ -160,7 +309,7 @@ describe('Web session model selection', () => {
|
||||
|
||||
it('accepts an advisory-unlisted model, rejects an unavailable provider, and switches only after the next assembly', async () => {
|
||||
const { ctx, agent, sessionId } = await harness()
|
||||
const api = createApiProxy(ctx, { defaultModelSelection: () => ({ provider: 'deepseek-official', model: 'deepseek-chat' }), cwd: '/tmp', workspaceRoot: '/tmp' })
|
||||
const api = createApiProxy(ctx, { defaultModelSelection: () => ({ provider: 'deepseek-official', model: 'deepseek-chat' }), cwd: '/tmp' })
|
||||
const seed: LlmCallConfig = { provider: 'seed', model: 'seed', temperature: 0.2 }
|
||||
const signal = new AbortController().signal
|
||||
|
||||
@@ -232,7 +381,6 @@ describe('Web session model selection', () => {
|
||||
const api = createApiProxy(ctx, {
|
||||
defaultModelSelection: () => stored,
|
||||
cwd: '/tmp',
|
||||
workspaceRoot: '/tmp',
|
||||
})
|
||||
|
||||
expect(expectValue(await api.sessions.models(request({ sessionId }))).current)
|
||||
@@ -257,7 +405,6 @@ describe('Web session model selection', () => {
|
||||
const api = createApiProxy(ctx, {
|
||||
defaultModelSelection: () => stored,
|
||||
cwd: '/tmp',
|
||||
workspaceRoot: '/tmp',
|
||||
})
|
||||
|
||||
stored = { provider: 'duplicate', model: 'same' }
|
||||
@@ -277,7 +424,6 @@ describe('Web session model selection', () => {
|
||||
return reject ? Promise.reject(new Error('read-only document')) : Promise.resolve()
|
||||
},
|
||||
cwd: '/tmp',
|
||||
workspaceRoot: '/tmp',
|
||||
})
|
||||
|
||||
expectValue(await api.sessions.selectModel(request({
|
||||
@@ -308,7 +454,6 @@ describe('Web session model selection', () => {
|
||||
const api = createApiProxy(ctx, {
|
||||
defaultModelSelection: () => ({ provider: 'deleted-gateway', model: 'deleted-model' }),
|
||||
cwd: '/tmp',
|
||||
workspaceRoot: '/tmp',
|
||||
})
|
||||
|
||||
// The client disabling its input is an affordance; this method stays
|
||||
@@ -341,7 +486,6 @@ describe('Web session model selection', () => {
|
||||
// names the route the user last picked, and nothing serves it.
|
||||
defaultModelSelection: () => ({ provider: 'deleted-gateway', model: 'deleted-model' }),
|
||||
cwd: '/tmp',
|
||||
workspaceRoot: '/tmp',
|
||||
})
|
||||
|
||||
const catalog = expectValue(await api.sessions.models(request({ sessionId })))
|
||||
|
||||
@@ -8,7 +8,7 @@
|
||||
*/
|
||||
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { Context } from 'cordis'
|
||||
import { Context } from '@deepseek-ai/cordis'
|
||||
import { z } from 'zod'
|
||||
import AgentRegistry, { Inbox } from '@deepseek-ai/dsh-agent'
|
||||
import type { Agent } from '@deepseek-ai/dsh-agent'
|
||||
@@ -68,7 +68,7 @@ function seedMessages(session: Session, count: number): void {
|
||||
}
|
||||
}
|
||||
|
||||
const api = (ctx: Context) => createApiProxy(ctx, { defaultModelSelection: () => ({ provider: 'p', model: 'm' }), cwd: '/tmp', workspaceRoot: '/tmp' })
|
||||
const api = (ctx: Context) => createApiProxy(ctx, { defaultModelSelection: () => ({ provider: 'p', model: 'm' }), cwd: '/tmp' })
|
||||
|
||||
describe('session.history projections block', () => {
|
||||
it('serves the unit value on the tail page with asOfSeq = last event seq', async () => {
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { Context } from 'cordis'
|
||||
import { Context } from '@deepseek-ai/cordis'
|
||||
import AgentRegistry, { type Agent } from '@deepseek-ai/dsh-agent'
|
||||
import SessionStore from '@deepseek-ai/dsh-session'
|
||||
import UserInteractionService from '@deepseek-ai/dsh-user-interaction'
|
||||
@@ -14,7 +14,7 @@ async function harness(): Promise<{ ctx: Context; api: ApiProxy }> {
|
||||
await ctx.plugin(UserInteractionService)
|
||||
return {
|
||||
ctx,
|
||||
api: createApiProxy(ctx, { defaultModelSelection: () => ({ provider: 'p', model: 'm' }), cwd: '/tmp', workspaceRoot: '/tmp' }),
|
||||
api: createApiProxy(ctx, { defaultModelSelection: () => ({ provider: 'p', model: 'm' }), cwd: '/tmp' }),
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -8,7 +8,7 @@
|
||||
*/
|
||||
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { Context } from 'cordis'
|
||||
import { Context } from '@deepseek-ai/cordis'
|
||||
import SessionStore from '@deepseek-ai/dsh-session'
|
||||
import AgentRegistry from '@deepseek-ai/dsh-agent'
|
||||
import type { Agent, AgentHandle, CreateAgentOptions } from '@deepseek-ai/dsh-agent'
|
||||
@@ -68,7 +68,7 @@ function liveAgent(ctx: Context, id: string, turns: number): Session {
|
||||
return session
|
||||
}
|
||||
|
||||
const api = (ctx: Context) => createApiProxy(ctx, { defaultModelSelection: () => ({ provider: 'p', model: 'm' }), cwd: '/tmp', workspaceRoot: '/tmp' })
|
||||
const api = (ctx: Context) => createApiProxy(ctx, { defaultModelSelection: () => ({ provider: 'p', model: 'm' }), cwd: '/tmp' })
|
||||
|
||||
describe('sessions.rename', () => {
|
||||
it('accepts through the composed title service: normalized user-source event, echoed seq', async () => {
|
||||
|
||||
@@ -5,7 +5,7 @@
|
||||
*/
|
||||
|
||||
import { describe, expect, it, vi } from 'vitest'
|
||||
import { Context } from 'cordis'
|
||||
import { Context } from '@deepseek-ai/cordis'
|
||||
import { stat } from 'node:fs/promises'
|
||||
import AgentRegistry from '@deepseek-ai/dsh-agent'
|
||||
import { createUserMessage } from '@deepseek-ai/dsh-llm'
|
||||
@@ -27,7 +27,7 @@ vi.mock('node:fs/promises', async (importOriginal) => {
|
||||
})
|
||||
|
||||
const sid = (value: string): SessionId => value as SessionId
|
||||
const defaults = { defaultModelSelection: () => ({ provider: 'p', model: 'm' }), cwd: '/tmp', workspaceRoot: '/tmp' }
|
||||
const defaults = { defaultModelSelection: () => ({ provider: 'p', model: 'm' }), cwd: '/tmp' }
|
||||
|
||||
function request(query: string): RpcRequest<{ query: string }> {
|
||||
return { rpcId: RpcId(`search-${query}`), payload: { query } }
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { describe, expect, it, vi } from 'vitest'
|
||||
import { Context } from 'cordis'
|
||||
import { Context } from '@deepseek-ai/cordis'
|
||||
import type { SessionEvent, SessionHeader, SessionId } from '@deepseek-ai/dsh-session'
|
||||
import { SubagentError } from '@deepseek-ai/dsh-subagent'
|
||||
import { RpcId } from '../src/api/rpc.ts'
|
||||
@@ -98,7 +98,7 @@ function bench(options: {
|
||||
ctx.provide('sessionProjections', { snapshot, restore, onChanged: () => () => {} })
|
||||
ctx.provide('userInteraction', { registerProvider: () => () => {} })
|
||||
const api = createApiProxy(ctx, {
|
||||
defaultModelSelection: () => ({ provider: 'p', model: 'm' }), cwd: '/tmp', workspaceRoot: '/tmp',
|
||||
defaultModelSelection: () => ({ provider: 'p', model: 'm' }), cwd: '/tmp',
|
||||
})
|
||||
return { api, getAgent, listChildren, inspect, snapshot, restore, followup, interrupt, parent }
|
||||
}
|
||||
|
||||
263
packages/host/apiproxy/tests/api-proxy-tasks.spec.ts
Normal file
263
packages/host/apiproxy/tests/api-proxy-tasks.spec.ts
Normal file
@@ -0,0 +1,263 @@
|
||||
/**
|
||||
* Background-task carrier paths of the host ApiProxy: the subscription
|
||||
* baseline is sent only for a session that has tasks, every registry change
|
||||
* pushes that owner's whole set, an unowned change fans out to every
|
||||
* subscribed session, the projection drops the three internal snapshot
|
||||
* fields, a composition without `ctx.tasks` emits nothing, and listing never
|
||||
* resumes a cold session.
|
||||
*/
|
||||
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { Context } from '@deepseek-ai/cordis'
|
||||
import AgentRegistry, { Inbox } from '@deepseek-ai/dsh-agent'
|
||||
import type { Agent } from '@deepseek-ai/dsh-agent'
|
||||
import SessionStore, { SessionId } from '@deepseek-ai/dsh-session'
|
||||
import type { Session } from '@deepseek-ai/dsh-session'
|
||||
import UserInteractionService from '@deepseek-ai/dsh-user-interaction'
|
||||
import LocalTaskService from '@deepseek-ai/dsh-tasks-local'
|
||||
import type { TaskOutcome } from '@deepseek-ai/dsh-tasks'
|
||||
import type { MuxFrame, RpcRequest } from '@deepseek-ai/dsh-host-apiproxy/api'
|
||||
import { RpcId } from '@deepseek-ai/dsh-host-apiproxy/api/rpc'
|
||||
import { createApiProxy } from '@deepseek-ai/dsh-host-apiproxy'
|
||||
|
||||
type TaskFrame = Extract<MuxFrame, { type: 'session/tasks' }>
|
||||
|
||||
/**
|
||||
* A producer whose settlement the test drives. `cancel` deliberately does not
|
||||
* settle, so a kill is observable as the distinct `stopping` step before the
|
||||
* test supplies the terminal outcome and its detail.
|
||||
*/
|
||||
function producer(label = 'sleep 60') {
|
||||
let settle!: (outcome: TaskOutcome) => void
|
||||
// A stream producer, so the carrier CAN consume the cursor if it ever calls
|
||||
// `read()`; `reads` is what proves it never does.
|
||||
const reads = { count: 0 }
|
||||
const spec = {
|
||||
kind: 'bash' as const,
|
||||
label,
|
||||
run: () => ({
|
||||
cancel: () => {},
|
||||
done: new Promise<TaskOutcome>((resolve) => { settle = resolve }),
|
||||
readOutput: () => { reads.count += 1; return 'stolen output' },
|
||||
}),
|
||||
}
|
||||
return { spec, reads, settle: (outcome: TaskOutcome) => { settle(outcome) } }
|
||||
}
|
||||
|
||||
async function harness(withRegistry: boolean): Promise<{ ctx: Context; session: Session; agent: Agent }> {
|
||||
const ctx = new Context()
|
||||
await ctx.plugin(SessionStore)
|
||||
await ctx.plugin(UserInteractionService)
|
||||
await ctx.plugin(AgentRegistry)
|
||||
if (withRegistry) {
|
||||
await ctx.plugin(LocalTaskService)
|
||||
ctx.tasks.attachController('api-proxy-test')
|
||||
}
|
||||
const session = ctx.sessions.create()
|
||||
const agent = {
|
||||
id: session.id,
|
||||
session,
|
||||
inbox: new Inbox(session, { inserted: () => {}, discarded: () => {}, claimed: () => {} }),
|
||||
status: 'idle',
|
||||
ctx,
|
||||
} as Agent
|
||||
ctx.agents.register(agent)
|
||||
return { ctx, session, agent }
|
||||
}
|
||||
|
||||
const api = (ctx: Context) => createApiProxy(ctx, { defaultModelSelection: () => ({ provider: 'p', model: 'm' }), cwd: '/tmp' })
|
||||
|
||||
/** Drain the mux until `count` session/tasks frames arrived, then abort. */
|
||||
async function collect(
|
||||
iterable: AsyncIterable<RpcRequest<MuxFrame>>,
|
||||
count: number,
|
||||
abort: AbortController,
|
||||
): Promise<TaskFrame[]> {
|
||||
const frames: MuxFrame[] = []
|
||||
for await (const envelope of iterable) {
|
||||
frames.push(envelope.payload)
|
||||
if (frames.filter(frame => frame.type === 'session/tasks').length >= count) abort.abort()
|
||||
}
|
||||
return frames.filter((frame): frame is TaskFrame => frame.type === 'session/tasks')
|
||||
}
|
||||
|
||||
describe('session/tasks subscription baseline', () => {
|
||||
it('is omitted for a session with no tasks — absence is the empty set', async () => {
|
||||
const { ctx, session } = await harness(true)
|
||||
const abort = new AbortController()
|
||||
const stream = api(ctx).events.mux({ rpcId: RpcId('t-tasks-empty'), payload: {} }, abort.signal)
|
||||
const frames: MuxFrame[] = []
|
||||
const drained = (async () => {
|
||||
for await (const envelope of stream) {
|
||||
frames.push(envelope.payload)
|
||||
if (frames.some(frame => frame.type === 'session/subscribed')) abort.abort()
|
||||
}
|
||||
})()
|
||||
await drained
|
||||
expect(frames.some(frame => frame.type === 'session/tasks')).toBe(false)
|
||||
expect(frames.some(frame => frame.type === 'session/subscribed')).toBe(true)
|
||||
void session
|
||||
})
|
||||
|
||||
it('carries the live set for a session that already has tasks when the stream opens', async () => {
|
||||
const { ctx, session, agent } = await harness(true)
|
||||
ctx.tasks.start({ ...producer('pnpm run build').spec, owner: agent })
|
||||
const abort = new AbortController()
|
||||
const stream = api(ctx).events.mux({ rpcId: RpcId('t-tasks-baseline'), payload: {} }, abort.signal)
|
||||
const [baseline] = await collect(stream, 1, abort)
|
||||
expect(baseline?.sessionId).toBe(session.id)
|
||||
expect(baseline?.tasks).toHaveLength(1)
|
||||
const [task] = baseline?.tasks ?? []
|
||||
expect(task?.startedAt).toBeTypeOf('number')
|
||||
expect({ ...task, startedAt: 0 }).toEqual({
|
||||
id: 'bash-1',
|
||||
kind: 'bash',
|
||||
label: 'pnpm run build',
|
||||
status: 'running',
|
||||
startedAt: 0,
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
describe('session/tasks change pushes', () => {
|
||||
it('pushes the owner\'s whole set on registration, stopping, and settlement', async () => {
|
||||
const { ctx, session, agent } = await harness(true)
|
||||
const proxy = api(ctx)
|
||||
const abort = new AbortController()
|
||||
const stream = proxy.events.mux({ rpcId: RpcId('t-tasks-changes'), payload: {} }, abort.signal)
|
||||
const collected = collect(stream, 3, abort)
|
||||
|
||||
const p = producer()
|
||||
const id = ctx.tasks.start({ ...p.spec, owner: agent })
|
||||
ctx.tasks.kill(id, agent, 'test')
|
||||
p.settle({ status: 'killed', detail: 'signal: SIGTERM' })
|
||||
|
||||
const frames = await collected
|
||||
expect(frames.map(frame => frame.sessionId)).toEqual([session.id, session.id, session.id])
|
||||
expect(frames.map(frame => frame.tasks[0]?.status)).toEqual(['running', 'stopping', 'killed'])
|
||||
// Terminal detail rides the same whole-set push; no separate signal.
|
||||
expect(frames[2]?.tasks[0]?.detail).toBe('signal: SIGTERM')
|
||||
expect(frames[2]?.tasks[0]?.finishedAt).toBeTypeOf('number')
|
||||
})
|
||||
|
||||
it('drops ownerSession, reported, and outputLimitBytes from the wire view', async () => {
|
||||
const { ctx, agent } = await harness(true)
|
||||
const proxy = api(ctx)
|
||||
const abort = new AbortController()
|
||||
const stream = proxy.events.mux({ rpcId: RpcId('t-tasks-fields'), payload: {} }, abort.signal)
|
||||
const collected = collect(stream, 1, abort)
|
||||
ctx.tasks.start({ ...producer().spec, owner: agent, outputLimitBytes: 1_024 })
|
||||
|
||||
const [frame] = await collected
|
||||
const fields: readonly string[] = Object.keys(frame?.tasks[0] ?? {})
|
||||
expect([...fields].sort()).toEqual(['id', 'kind', 'label', 'startedAt', 'status'])
|
||||
})
|
||||
|
||||
it('fans an unowned change out to every subscribed session', async () => {
|
||||
const { ctx } = await harness(true)
|
||||
const second = ctx.sessions.create()
|
||||
const proxy = api(ctx)
|
||||
const abort = new AbortController()
|
||||
const stream = proxy.events.mux({ rpcId: RpcId('t-tasks-unowned'), payload: {} }, abort.signal)
|
||||
const collected = collect(stream, 2, abort)
|
||||
|
||||
ctx.tasks.start(producer('open to every caller').spec)
|
||||
|
||||
const frames = await collected
|
||||
expect(new Set(frames.map(frame => frame.sessionId)).size).toBe(2)
|
||||
expect(frames.some(frame => frame.sessionId === second.id)).toBe(true)
|
||||
for (const frame of frames) expect(frame.tasks[0]?.label).toBe('open to every caller')
|
||||
})
|
||||
|
||||
it('serves a cold session the unowned set without resuming it', async () => {
|
||||
const { ctx } = await harness(true)
|
||||
const coldId = SessionId('session-cold-tasks')
|
||||
let loaded = false
|
||||
ctx.provide('sessionPersistence', {
|
||||
list: async () => [{ version: 0, id: coldId, createdAt: 5, cwd: '/tmp' }],
|
||||
locate: () => undefined,
|
||||
load: () => { loaded = true; throw new Error('task listing must not load a cold log') },
|
||||
} as never)
|
||||
const proxy = api(ctx)
|
||||
const abort = new AbortController()
|
||||
const stream = proxy.events.mux({ rpcId: RpcId('t-tasks-cold'), payload: {} }, abort.signal)
|
||||
const collected = collect(stream, 1, abort)
|
||||
|
||||
ctx.tasks.start(producer().spec)
|
||||
await collected
|
||||
expect(loaded).toBe(false)
|
||||
expect(ctx.agents.get(coldId)).toBeUndefined()
|
||||
})
|
||||
})
|
||||
|
||||
describe('session/tasks without the registry', () => {
|
||||
it('emits no frames at all, so the client renders no entry point', async () => {
|
||||
const { ctx, session } = await harness(false)
|
||||
const proxy = api(ctx)
|
||||
const abort = new AbortController()
|
||||
const stream = proxy.events.mux({ rpcId: RpcId('t-tasks-absent'), payload: {} }, abort.signal)
|
||||
const frames: MuxFrame[] = []
|
||||
const drained = (async () => {
|
||||
for await (const envelope of stream) {
|
||||
frames.push(envelope.payload)
|
||||
if (frames.filter(frame => frame.type === 'session/event').length >= 1) abort.abort()
|
||||
}
|
||||
})()
|
||||
session.append('turn/start', { turn: 1 })
|
||||
await drained
|
||||
expect(frames.some(frame => frame.type === 'session/tasks')).toBe(false)
|
||||
})
|
||||
})
|
||||
|
||||
describe('session/tasks never consumes model output', () => {
|
||||
it('drives the whole lifecycle without calling the single consuming cursor', async () => {
|
||||
// `ctx.tasks.read()` consumes the one output cursor, so a carrier read
|
||||
// silently takes bytes the model's `task_output` will never see. The
|
||||
// failure is invisible at the call site, which is why this asserts the
|
||||
// count rather than trusting review.
|
||||
const { ctx, agent } = await harness(true)
|
||||
const proxy = api(ctx)
|
||||
const abort = new AbortController()
|
||||
const stream = proxy.events.mux({ rpcId: RpcId('t-tasks-no-read'), payload: {} }, abort.signal)
|
||||
const collected = collect(stream, 3, abort)
|
||||
|
||||
const p = producer()
|
||||
const id = ctx.tasks.start({ ...p.spec, owner: agent })
|
||||
ctx.tasks.kill(id, agent, 'test')
|
||||
p.settle({ status: 'killed', detail: 'signal: SIGTERM' })
|
||||
await collected
|
||||
|
||||
expect(p.reads.count).toBe(0)
|
||||
})
|
||||
|
||||
it('reads nothing while minting the subscription baseline either', async () => {
|
||||
const { ctx, agent } = await harness(true)
|
||||
const p = producer()
|
||||
ctx.tasks.start({ ...p.spec, owner: agent })
|
||||
|
||||
const abort = new AbortController()
|
||||
const stream = api(ctx).events.mux({ rpcId: RpcId('t-tasks-no-read-baseline'), payload: {} }, abort.signal)
|
||||
const [baseline] = await collect(stream, 1, abort)
|
||||
|
||||
expect(baseline?.tasks).toHaveLength(1)
|
||||
expect(p.reads.count).toBe(0)
|
||||
})
|
||||
})
|
||||
|
||||
describe('session/tasks baseline for a session born after the stream opened', () => {
|
||||
it('carries the already-visible unowned set to the new session', async () => {
|
||||
const { ctx } = await harness(true)
|
||||
const proxy = api(ctx)
|
||||
const abort = new AbortController()
|
||||
const stream = proxy.events.mux({ rpcId: RpcId('t-tasks-late-session'), payload: {} }, abort.signal)
|
||||
|
||||
// One unowned task exists before the new session is created; the subscribe
|
||||
// frame clears the client mirror, so the baseline has to follow it.
|
||||
ctx.tasks.start(producer('visible to every caller').spec)
|
||||
const created = ctx.sessions.create()
|
||||
|
||||
const frames = await collect(stream, 2, abort)
|
||||
const forNew = frames.filter(frame => frame.sessionId === created.id)
|
||||
expect(forNew.at(-1)?.tasks[0]?.label).toBe('visible to every caller')
|
||||
})
|
||||
})
|
||||
@@ -8,7 +8,7 @@
|
||||
*/
|
||||
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { Context } from 'cordis'
|
||||
import { Context } from '@deepseek-ai/cordis'
|
||||
import AgentRegistry from '@deepseek-ai/dsh-agent'
|
||||
import type { Agent } from '@deepseek-ai/dsh-agent'
|
||||
import SessionStore from '@deepseek-ai/dsh-session'
|
||||
@@ -105,7 +105,7 @@ async function collect(iterable: AsyncIterable<RpcRequest<MuxFrame>>, count: num
|
||||
describe('mux live view computation', () => {
|
||||
it('attaches the three standard card views, omits view without a presenter, soft-falls on throw', async () => {
|
||||
const { ctx } = await harness()
|
||||
const api = createApiProxy(ctx, { defaultModelSelection: () => ({ provider: 'p', model: 'm' }), cwd: '/tmp', workspaceRoot: '/tmp' })
|
||||
const api = createApiProxy(ctx, { defaultModelSelection: () => ({ provider: 'p', model: 'm' }), cwd: '/tmp' })
|
||||
const abort = new AbortController()
|
||||
const stream = api.events.mux({ rpcId: RpcId('t-mux'), payload: {} }, abort.signal)
|
||||
const collected = collect(stream, 9, abort)
|
||||
@@ -170,7 +170,7 @@ describe('mux live view computation', () => {
|
||||
|
||||
it('serves history entries with call/result views, backscan pairing, and soft-falls', async () => {
|
||||
const { ctx } = await harness()
|
||||
const api = createApiProxy(ctx, { defaultModelSelection: () => ({ provider: 'p', model: 'm' }), cwd: '/tmp', workspaceRoot: '/tmp' })
|
||||
const api = createApiProxy(ctx, { defaultModelSelection: () => ({ provider: 'p', model: 'm' }), cwd: '/tmp' })
|
||||
const session = ctx.sessions.create()
|
||||
// history resolves the agent first; a live structural stub is enough (only
|
||||
// .session is read on this path).
|
||||
@@ -238,7 +238,7 @@ describe('mux live view computation', () => {
|
||||
|
||||
it('counts only append-origin messages toward maxMessages and keeps each compaction summary with its replacement', async () => {
|
||||
const { ctx } = await harness()
|
||||
const api = createApiProxy(ctx, { defaultModelSelection: () => ({ provider: 'p', model: 'm' }), cwd: '/tmp', workspaceRoot: '/tmp' })
|
||||
const api = createApiProxy(ctx, { defaultModelSelection: () => ({ provider: 'p', model: 'm' }), cwd: '/tmp' })
|
||||
const session = ctx.sessions.create()
|
||||
ctx.agents.register({ id: session.id, session, status: 'idle', ctx } as Agent)
|
||||
session.append('turn/start', { turn: 1 })
|
||||
@@ -287,7 +287,7 @@ describe('mux live view computation', () => {
|
||||
|
||||
it('drops a disposed session from the live open-call table (result after dispose gets no view)', async () => {
|
||||
const { ctx } = await harness()
|
||||
const api = createApiProxy(ctx, { defaultModelSelection: () => ({ provider: 'p', model: 'm' }), cwd: '/tmp', workspaceRoot: '/tmp' })
|
||||
const api = createApiProxy(ctx, { defaultModelSelection: () => ({ provider: 'p', model: 'm' }), cwd: '/tmp' })
|
||||
const abort = new AbortController()
|
||||
const stream = api.events.mux({ rpcId: RpcId('t-mux3'), payload: {} }, abort.signal)
|
||||
|
||||
@@ -308,7 +308,7 @@ describe('mux live view computation', () => {
|
||||
|
||||
it('pairs a result after turn/end via the in-memory backscan fallback', async () => {
|
||||
const { ctx } = await harness()
|
||||
const api = createApiProxy(ctx, { defaultModelSelection: () => ({ provider: 'p', model: 'm' }), cwd: '/tmp', workspaceRoot: '/tmp' })
|
||||
const api = createApiProxy(ctx, { defaultModelSelection: () => ({ provider: 'p', model: 'm' }), cwd: '/tmp' })
|
||||
const abort = new AbortController()
|
||||
const stream = api.events.mux({ rpcId: RpcId('t-mux2'), payload: {} }, abort.signal)
|
||||
const collected = collect(stream, 4, abort)
|
||||
|
||||
@@ -2,7 +2,7 @@ import { existsSync, mkdirSync, mkdtempSync, realpathSync } from 'node:fs'
|
||||
import { tmpdir } from 'node:os'
|
||||
import { join } from 'node:path'
|
||||
import { describe, expect, it, vi } from 'vitest'
|
||||
import { Context } from 'cordis'
|
||||
import { Context } from '@deepseek-ai/cordis'
|
||||
import AgentRegistry, { Inbox } from '@deepseek-ai/dsh-agent'
|
||||
import type { Agent, AgentFactory } from '@deepseek-ai/dsh-agent'
|
||||
import SessionStore, { SessionId } from '@deepseek-ai/dsh-session'
|
||||
@@ -59,7 +59,7 @@ function stubAgent(session: Session): Agent {
|
||||
|
||||
/** Compose the API over real Session, Agent, Storage, Domain, and Workspace services. */
|
||||
async function harness(
|
||||
workspaceRoot = realpathSync(mkdtempSync(join(tmpdir(), 'dsh-apiproxy-workspace-'))),
|
||||
root = realpathSync.native(mkdtempSync(join(tmpdir(), 'dsh-apiproxy-workspace-'))),
|
||||
picker: DirectoryPickerCapability = { kind: 'native', pick: async () => null },
|
||||
extras: { openPath?: (path: string, signal: AbortSignal) => Promise<void> } = {},
|
||||
) {
|
||||
@@ -101,11 +101,17 @@ async function harness(
|
||||
ctx.provide('directoryPicker', { capability: () => picker } as never)
|
||||
const api = createApiProxy(ctx, {
|
||||
defaultModelSelection: () => ({ provider: 'test', model: 'test-model' }),
|
||||
cwd: workspaceRoot,
|
||||
workspaceRoot,
|
||||
cwd: root,
|
||||
...extras.openPath === undefined ? {} : { openPath: extras.openPath },
|
||||
})
|
||||
return { api, ctx, storageDomain, workspaceRoot }
|
||||
return { api, ctx, storageDomain, root }
|
||||
}
|
||||
|
||||
/** Stage one directory under the harness root for path adoption. */
|
||||
function stageDir(root: string, name: string): string {
|
||||
const path = join(root, name)
|
||||
mkdirSync(path)
|
||||
return path
|
||||
}
|
||||
|
||||
describe('host.pickDirectory', () => {
|
||||
@@ -243,31 +249,25 @@ describe('host.openPath', () => {
|
||||
})
|
||||
|
||||
describe('workspace.create', () => {
|
||||
it('serializes concurrent names and rejects the duplicate', async () => {
|
||||
const { api, workspaceRoot } = await harness()
|
||||
it('serializes concurrent creates of one path into a single registration', async () => {
|
||||
const { api, root } = await harness()
|
||||
const target = stageDir(root, 'alpha')
|
||||
const responses = await Promise.all([
|
||||
api.workspace.create(request({ name: 'alpha' })),
|
||||
api.workspace.create(request({ name: 'alpha' })),
|
||||
api.workspace.create(request({ path: target })),
|
||||
api.workspace.create(request({ path: target })),
|
||||
])
|
||||
const created = responses.find(response => response.result.ok)
|
||||
const duplicate = responses.find(response => !response.result.ok)
|
||||
const values = responses.map(response => expectOk(response))
|
||||
const created = values.find(value => value.created)
|
||||
const resolved = values.find(value => !value.created)
|
||||
|
||||
expect(created).toBeDefined()
|
||||
expect(expectOk(created!)).toMatchObject({
|
||||
created: true,
|
||||
workspace: { path: join(workspaceRoot, 'alpha'), title: 'alpha' },
|
||||
})
|
||||
expect(duplicate?.result).toMatchObject({
|
||||
ok: false,
|
||||
error: { code: 'workspace-name-conflict', details: { name: 'alpha' } },
|
||||
})
|
||||
expect(existsSync(join(workspaceRoot, 'alpha'))).toBe(true)
|
||||
expect(created).toMatchObject({ workspace: { path: target, title: 'alpha' } })
|
||||
expect(resolved?.workspace.workspaceId).toBe(created?.workspace.workspaceId)
|
||||
expect(expectOk(await api.workspace.list(request({}))).items).toHaveLength(1)
|
||||
})
|
||||
|
||||
it('adopts only existing directories and rejects unsafe names', async () => {
|
||||
const { api, workspaceRoot } = await harness()
|
||||
const existing = join(workspaceRoot, 'existing')
|
||||
mkdirSync(existing)
|
||||
it('adopts only existing directories', async () => {
|
||||
const { api, root } = await harness()
|
||||
const existing = stageDir(root, 'existing')
|
||||
const first = expectOk(await api.workspace.create(request({ path: existing })))
|
||||
const repeated = expectOk(await api.workspace.create(request({ path: existing })))
|
||||
expect(first).toMatchObject({ created: true, workspace: { path: existing, title: 'existing' } })
|
||||
@@ -280,21 +280,16 @@ describe('workspace.create', () => {
|
||||
const reopened = expectOk(await api.workspace.create(request({ path: existing })))
|
||||
expect(reopened.workspace.title).toBe('renamed-existing')
|
||||
|
||||
const missing = join(workspaceRoot, 'missing')
|
||||
const missing = join(root, 'missing')
|
||||
const missingResult = await api.workspace.create(request({ path: missing }))
|
||||
expect(missingResult.result).toMatchObject({ ok: false, error: { code: 'workspace-invalid-path' } })
|
||||
expect(existsSync(missing)).toBe(false)
|
||||
|
||||
for (const name of ['', '.', '..', 'a/b', 'a\\b']) {
|
||||
const invalid = await api.workspace.create(request({ name }))
|
||||
expect(invalid.result).toMatchObject({ ok: false, error: { code: 'workspace-invalid-path' } })
|
||||
}
|
||||
})
|
||||
|
||||
it('adopts different paths that derive the same Workspace title', async () => {
|
||||
const { api, workspaceRoot } = await harness()
|
||||
const first = join(workspaceRoot, 'one', 'project')
|
||||
const second = join(workspaceRoot, 'two', 'project')
|
||||
const { api, root } = await harness()
|
||||
const first = join(root, 'one', 'project')
|
||||
const second = join(root, 'two', 'project')
|
||||
mkdirSync(first, { recursive: true })
|
||||
mkdirSync(second, { recursive: true })
|
||||
const firstResult = expectOk(await api.workspace.create(request({ path: first })))
|
||||
@@ -315,8 +310,8 @@ describe('workspace.create', () => {
|
||||
|
||||
describe('session creation and Workspace membership', () => {
|
||||
it('attaches a preallocated idempotent session while cwd-only sessions stay ungrouped', async () => {
|
||||
const { api, ctx } = await harness()
|
||||
const workspace = expectOk(await api.workspace.create(request({ name: 'project' }))).workspace
|
||||
const { api, ctx, root } = await harness()
|
||||
const workspace = expectOk(await api.workspace.create(request({ path: stageDir(root, 'project') }))).workspace
|
||||
const sessionId = SessionId('session-workspace-preallocated')
|
||||
|
||||
expectOk(await api.sessions.create(request({ workspaceId: workspace.workspaceId, sessionId })))
|
||||
@@ -342,8 +337,8 @@ describe('session creation and Workspace membership', () => {
|
||||
})
|
||||
|
||||
it('retains a published session when attachment fails and repairs it on retry', async () => {
|
||||
const { api, ctx } = await harness()
|
||||
const created = expectOk(await api.workspace.create(request({ name: 'project' }))).workspace
|
||||
const { api, ctx, root } = await harness()
|
||||
const created = expectOk(await api.workspace.create(request({ path: stageDir(root, 'project') }))).workspace
|
||||
const workspace = ctx.workspace.list()[0]
|
||||
if (workspace === undefined) throw new Error('workspace missing from registry')
|
||||
vi.spyOn(workspace, 'attachSession').mockRejectedValueOnce(new Error('simulated write failure'))
|
||||
@@ -393,7 +388,7 @@ describe('Host Workspace increments', () => {
|
||||
})
|
||||
|
||||
it('streams committed Workspace and Session increments after empty baselines', async () => {
|
||||
const { api } = await harness()
|
||||
const { api, root } = await harness()
|
||||
expect(expectOk(await api.workspace.list(request({}))).items).toEqual([])
|
||||
expect(expectOk(await api.sessions.list(request({}))).items).toEqual([])
|
||||
|
||||
@@ -401,7 +396,7 @@ describe('Host Workspace increments', () => {
|
||||
const stream: AsyncIterator<RpcRequest<HostFrame>> =
|
||||
api.events.host(request({}), abort.signal)[Symbol.asyncIterator]()
|
||||
const workspaceIncrement = nextHostFrame(stream)
|
||||
const workspace = expectOk(await api.workspace.create(request({ name: 'project' }))).workspace
|
||||
const workspace = expectOk(await api.workspace.create(request({ path: stageDir(root, 'project') }))).workspace
|
||||
expect(await workspaceIncrement).toMatchObject({
|
||||
payload: { type: 'host/workspace-changed', workspace: { workspaceId: workspace.workspaceId } },
|
||||
})
|
||||
@@ -429,7 +424,7 @@ describe('Host Workspace increments', () => {
|
||||
})
|
||||
|
||||
it('does not publish a Workspace whose registry-order commit fails', async () => {
|
||||
const { api, storageDomain } = await harness()
|
||||
const { api, storageDomain, root } = await harness()
|
||||
const domain = storageDomain.get('workspace')
|
||||
if (domain === undefined) throw new Error('workspace domain is not open')
|
||||
vi.spyOn(domain.global, 'set').mockRejectedValueOnce(new Error('simulated registry order failure'))
|
||||
@@ -438,7 +433,7 @@ describe('Host Workspace increments', () => {
|
||||
api.events.host(request({}), abort.signal)[Symbol.asyncIterator]()
|
||||
const next = stream.next()
|
||||
|
||||
const failed = await api.workspace.create(request({ name: 'ghost' }))
|
||||
const failed = await api.workspace.create(request({ path: stageDir(root, 'ghost') }))
|
||||
expect(failed.result.ok).toBe(false)
|
||||
expect(expectOk(await api.workspace.list(request({}))).items).toEqual([])
|
||||
abort.abort()
|
||||
@@ -446,8 +441,8 @@ describe('Host Workspace increments', () => {
|
||||
})
|
||||
|
||||
it('deletes the registration, keeps its session and folder, and streams one removal', async () => {
|
||||
const { api, ctx } = await harness()
|
||||
const workspace = expectOk(await api.workspace.create(request({ name: 'delete-me' }))).workspace
|
||||
const { api, ctx, root } = await harness()
|
||||
const workspace = expectOk(await api.workspace.create(request({ path: stageDir(root, 'delete-me') }))).workspace
|
||||
const sessionId = SessionId('session-kept-after-workspace-delete')
|
||||
expectOk(await api.sessions.create(request({ workspaceId: workspace.workspaceId, sessionId })))
|
||||
|
||||
@@ -479,8 +474,8 @@ describe('Host Workspace increments', () => {
|
||||
})
|
||||
|
||||
it('archives a session into the global set, keeps its accounting, and streams the set once', async () => {
|
||||
const { api } = await harness()
|
||||
const workspace = expectOk(await api.workspace.create(request({ name: 'archive-home' }))).workspace
|
||||
const { api, root } = await harness()
|
||||
const workspace = expectOk(await api.workspace.create(request({ path: stageDir(root, 'archive-home') }))).workspace
|
||||
const sessionId = SessionId('session-to-archive')
|
||||
expectOk(await api.sessions.create(request({ workspaceId: workspace.workspaceId, sessionId })))
|
||||
expect(expectOk(await api.workspace.list(request({}))).archivedSessionIds).toEqual([])
|
||||
|
||||
@@ -23,6 +23,7 @@ function scriptedApi(overrides: {
|
||||
host?: Partial<ApiProxy['host']>
|
||||
commands?: Partial<ApiProxy['commands']>
|
||||
skills?: Partial<ApiProxy['skills']>
|
||||
agentPresets?: Partial<ApiProxy['agentPresets']>
|
||||
events?: Partial<ApiProxy['events']>
|
||||
goals?: Partial<ApiProxy['goals']>
|
||||
settings?: Partial<ApiProxy['settings']>
|
||||
@@ -55,6 +56,10 @@ function scriptedApi(overrides: {
|
||||
rename: r => ok(r, { title: 'renamed', seq: 0 }),
|
||||
fork: r => ok(r, { sessionId: sid('s-fork') }),
|
||||
prompt: r => ok(r, { accepted: true as const }),
|
||||
attachment: r => ok(r, {
|
||||
attachment: { attachmentId: 'a' as never, mediaType: 'image/png', bytes: 1, width: 1, height: 1 },
|
||||
data: 'AA==',
|
||||
}),
|
||||
updateQueue: r => ok(r, { accepted: true as const }),
|
||||
cancel: r => ok(r, { accepted: true as const }),
|
||||
...overrides.sessions,
|
||||
@@ -88,6 +93,15 @@ function scriptedApi(overrides: {
|
||||
...overrides.commands,
|
||||
},
|
||||
skills: { list: r => ok(r, { skills: [] }), ...overrides.skills },
|
||||
agentPresets: {
|
||||
list: r => ok(r, { presets: [], authorable: false, hasDocument: false }),
|
||||
select: r => ok(r, { agentPreset: r.payload.agentPreset }),
|
||||
read: r => ok(r, { agentPreset: r.payload.agentPreset, trust: 'user' as const, content: '' }),
|
||||
copy: r => ok(r, { agentPreset: r.payload.agentPreset }),
|
||||
openDocument: r => ok(r, { opened: true as const }),
|
||||
remove: r => ok(r, {}),
|
||||
...overrides.agentPresets,
|
||||
},
|
||||
goals: {
|
||||
create: err,
|
||||
edit: err,
|
||||
@@ -119,6 +133,7 @@ function scriptedApi(overrides: {
|
||||
},
|
||||
events: { mux: () => empty<MuxFrame>(), host: () => empty<HostFrame>(), ...overrides.events },
|
||||
respond: overrides.respond ?? (() => Promise.resolve({ accepted: false as const, reason: 'not-pending' as const })),
|
||||
downloads: { sessionLog: async () => new Response('stub', { status: 404 }) },
|
||||
}
|
||||
}
|
||||
|
||||
@@ -222,6 +237,18 @@ describe('unary round trip', () => {
|
||||
expect(appended.result.ok).toBe(true)
|
||||
})
|
||||
|
||||
it('routes the agent-preset roster and switch through the wire', async () => {
|
||||
const c = client(scriptedApi())
|
||||
|
||||
const listed = await c.agentPresets.list({})
|
||||
expect(listed.result).toEqual({ ok: true, value: { presets: [], authorable: false, hasDocument: false } })
|
||||
|
||||
// The switch carries the session it is about: the host refuses one whose
|
||||
// conversation has started, and it can only know which by id.
|
||||
const selected = await c.agentPresets.select({ sessionId: sid('s1'), agentPreset: 'standard' })
|
||||
expect(selected.result).toEqual({ ok: true, value: { agentPreset: 'standard' } })
|
||||
})
|
||||
|
||||
it('passes business errors through as 200 + err result, not a throw', async () => {
|
||||
const api = scriptedApi({
|
||||
sessions: {
|
||||
@@ -406,8 +433,8 @@ describe('workspace domain round trip', () => {
|
||||
expect(archivedResponse.result).toEqual({ ok: true, value: { archivedSessionIds: ['s-arch'] } })
|
||||
})
|
||||
|
||||
it('rejects a create payload violating the exactly-one refine at the handler', async () => {
|
||||
const response = await client(scriptedApi()).workspace.create({})
|
||||
it('rejects a pathless create payload at the handler schema', async () => {
|
||||
const response = await client(scriptedApi()).workspace.create({} as never)
|
||||
expect(response.result.ok).toBe(false)
|
||||
if (!response.result.ok) expect(response.result.error.code).toBe('bad-request')
|
||||
})
|
||||
|
||||
@@ -97,6 +97,12 @@ function fakeApi(overrides: Partial<{ muxFrames: MuxFrame[]; hostFrames: HostFra
|
||||
async prompt(request) {
|
||||
return { rpcId: request.rpcId, result: { ok: true, value: { accepted: true as const } } }
|
||||
},
|
||||
async attachment(request) {
|
||||
return {
|
||||
rpcId: request.rpcId,
|
||||
result: { ok: true, value: { attachment: { attachmentId: 'a' as never, mediaType: 'image/png' as const, bytes: 1, width: 1, height: 1 }, data: 'AA==' } },
|
||||
}
|
||||
},
|
||||
async updateQueue(request) {
|
||||
return { rpcId: request.rpcId, result: { ok: true, value: { accepted: true as const } } }
|
||||
},
|
||||
@@ -197,6 +203,32 @@ function fakeApi(overrides: Partial<{ muxFrames: MuxFrame[]; hostFrames: HostFra
|
||||
return { rpcId: request.rpcId, result: { ok: true, value: { matched: false } } }
|
||||
},
|
||||
},
|
||||
agentPresets: {
|
||||
list(request: RpcRequest<{}>) {
|
||||
return Promise.resolve({
|
||||
rpcId: request.rpcId,
|
||||
result: { ok: true as const, value: { presets: [], authorable: false, hasDocument: false } },
|
||||
})
|
||||
},
|
||||
select(request: RpcRequest<{ agentPreset: string }>) {
|
||||
const value = { agentPreset: request.payload.agentPreset }
|
||||
return Promise.resolve({ rpcId: request.rpcId, result: { ok: true as const, value } })
|
||||
},
|
||||
read(request: RpcRequest<{ agentPreset: string }>) {
|
||||
const value = { agentPreset: request.payload.agentPreset, trust: 'user' as const, content: '' }
|
||||
return Promise.resolve({ rpcId: request.rpcId, result: { ok: true as const, value } })
|
||||
},
|
||||
copy(request: RpcRequest<{ from: string; agentPreset: string }>) {
|
||||
const value = { agentPreset: request.payload.agentPreset }
|
||||
return Promise.resolve({ rpcId: request.rpcId, result: { ok: true as const, value } })
|
||||
},
|
||||
openDocument(request: RpcRequest<{ agentPreset: string }>) {
|
||||
return Promise.resolve({ rpcId: request.rpcId, result: { ok: true as const, value: { opened: true as const } } })
|
||||
},
|
||||
remove(request: RpcRequest<{ agentPreset: string }>) {
|
||||
return Promise.resolve({ rpcId: request.rpcId, result: { ok: true as const, value: {} } })
|
||||
},
|
||||
},
|
||||
skills: {
|
||||
async list(request) {
|
||||
return { rpcId: request.rpcId, result: { ok: true, value: { skills: [{ name: 'commit-helper', description: 'Git commits', modelInvocable: true }] } } }
|
||||
@@ -268,6 +300,11 @@ function fakeApi(overrides: Partial<{ muxFrames: MuxFrame[]; hostFrames: HostFra
|
||||
async respond(message: ClientResponse): Promise<RpcReceipt> {
|
||||
return message.rpcId === 'known' ? { accepted: true } : { accepted: false, reason: 'not-pending' }
|
||||
},
|
||||
downloads: {
|
||||
async sessionLog() {
|
||||
return new Response('stub', { status: 404 })
|
||||
},
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
@@ -331,6 +368,7 @@ describe('unary round trip (handler ⇄ client, no network)', () => {
|
||||
const renamed = await c.sessions.rename({ sessionId: 's' as never, title: 'named' })
|
||||
expect(renamed.result).toMatchObject({ ok: true, value: { title: 'named', seq: 0 } })
|
||||
expect((await c.sessions.prompt({ sessionId: 's' as never, mode: 'queue', content: [{ type: 'text', text: 'x' }] })).result.ok).toBe(true)
|
||||
expect((await c.sessions.attachment({ sessionId: 's' as never, attachmentId: 'a' as never })).result.ok).toBe(true)
|
||||
expect((await c.sessions.updateQueue({
|
||||
sessionId: 's' as never,
|
||||
itemId: 'item-1' as never,
|
||||
@@ -340,6 +378,28 @@ describe('unary round trip (handler ⇄ client, no network)', () => {
|
||||
expect((await c.host.describe({})).result.ok).toBe(true)
|
||||
})
|
||||
|
||||
it('round-trips every agent-preset method, authoring included', async () => {
|
||||
const c = client()
|
||||
|
||||
// The whole domain crosses the carrier: the roster a picker reads, the
|
||||
// per-session switch, and the authoring calls the settings page makes.
|
||||
// Each has its own request schema, so a registration missing from either
|
||||
// half fails here rather than in the browser.
|
||||
expect((await c.agentPresets.list({})).result).toEqual({
|
||||
ok: true, value: { presets: [], authorable: false, hasDocument: false },
|
||||
})
|
||||
expect((await c.agentPresets.select({ sessionId: 's' as never, agentPreset: 'minimal' })).result)
|
||||
.toEqual({ ok: true, value: { agentPreset: 'minimal' } })
|
||||
expect((await c.agentPresets.read({ agentPreset: 'mine' })).result).toEqual({
|
||||
ok: true, value: { agentPreset: 'mine', trust: 'user', content: '' },
|
||||
})
|
||||
expect((await c.agentPresets.copy({ from: 'standard', agentPreset: 'mine' })).result)
|
||||
.toEqual({ ok: true, value: { agentPreset: 'mine' } })
|
||||
expect((await c.agentPresets.openDocument({ agentPreset: 'mine' })).result)
|
||||
.toEqual({ ok: true, value: { opened: true } })
|
||||
expect((await c.agentPresets.remove({ agentPreset: 'mine' })).result).toEqual({ ok: true, value: {} })
|
||||
})
|
||||
|
||||
it('round-trips the native picker without the default unary timeout', async () => {
|
||||
const api = fakeApi()
|
||||
api.host.pickDirectory = async (request) => {
|
||||
|
||||
@@ -16,7 +16,7 @@ vi.mock('node:child_process', () => ({ execFile: execFileMock }))
|
||||
|
||||
import { release as osRelease } from 'node:os'
|
||||
import { describe, expect, it, vi } from 'vitest'
|
||||
import { openNativePath, openNativeTextFile, type PathOpenerRunner } from '../src/native-path-opener.ts'
|
||||
import { canOpenNativePath, openNativePath, openNativeTextFile, type PathOpenerRunner } from '../src/native-path-opener.ts'
|
||||
|
||||
const signal = () => new AbortController().signal
|
||||
|
||||
@@ -287,3 +287,35 @@ describe('browser-renderable documents', () => {
|
||||
])
|
||||
})
|
||||
})
|
||||
|
||||
describe('canOpenNativePath', () => {
|
||||
it('always answers yes where the desktop is part of the platform', () => {
|
||||
expect(canOpenNativePath({ platform: 'darwin', env: {} })).toBe(true)
|
||||
expect(canOpenNativePath({ platform: 'win32', env: {} })).toBe(true)
|
||||
})
|
||||
|
||||
it('requires a display server or WSL interop on linux', () => {
|
||||
const linux = { platform: 'linux' as const, osRelease: '6.8.0-generic' }
|
||||
// Headless is the case the capability exists for: `xdg-open` would spawn
|
||||
// into nothing, so a surface should show the path as text instead.
|
||||
expect(canOpenNativePath({ ...linux, env: {} })).toBe(false)
|
||||
expect(canOpenNativePath({ ...linux, env: { DISPLAY: ':0' } })).toBe(true)
|
||||
expect(canOpenNativePath({ ...linux, env: { WAYLAND_DISPLAY: 'wayland-0' } })).toBe(true)
|
||||
expect(canOpenNativePath({
|
||||
platform: 'linux', osRelease: '5.15.153.1-microsoft-standard-WSL2', env: {},
|
||||
})).toBe(true)
|
||||
})
|
||||
|
||||
it('answers no on a platform the opener does not support', () => {
|
||||
expect(canOpenNativePath({ platform: 'freebsd', env: {} })).toBe(false)
|
||||
})
|
||||
|
||||
it('samples the ambient environment when no override is supplied', () => {
|
||||
const env = process.env
|
||||
const marked = (value: string | undefined): boolean => value !== undefined && value !== ''
|
||||
const expected = marked(env.WSL_DISTRO_NAME) || marked(env.WSL_INTEROP)
|
||||
|| marked(env.DISPLAY) || marked(env.WAYLAND_DISPLAY)
|
||||
|
||||
expect(canOpenNativePath({ platform: 'linux', osRelease: '6.8.0-generic' })).toBe(expected)
|
||||
})
|
||||
})
|
||||
|
||||
@@ -32,6 +32,9 @@ import {
|
||||
commandListRequestSchema, commandListValueSchema,
|
||||
} from '../src/api/commands.schema.ts'
|
||||
import { skillEntrySchema, skillListRequestSchema, skillListValueSchema } from '../src/api/skills.schema.ts'
|
||||
import {
|
||||
agentPresetEntrySchema, agentPresetListValueSchema, agentPresetOpenDocumentValueSchema,
|
||||
} from '../src/api/agent-presets.schema.ts'
|
||||
import { hostFrameSchema, muxFrameSchema, askUserQuestionItemSchema } from '../src/api/events.schema.ts'
|
||||
import { approvalRequestIdSchema, approvalResponsePayloadSchema } from '../src/api/approvals.schema.ts'
|
||||
import { askUserQuestionAnswerSchema, questionResponsePayloadSchema } from '../src/api/questions.schema.ts'
|
||||
@@ -356,11 +359,11 @@ describe('workspace domain schemas', () => {
|
||||
expect(() => workspaceArchiveSessionValueSchema.parse({ archivedSessionIds: 's1' })).toThrow()
|
||||
})
|
||||
|
||||
it('create requires exactly one of path/name (both refine arms)', () => {
|
||||
it('create requires a path', () => {
|
||||
expect(workspaceCreateRequestSchema.parse({ path: '/p' }).path).toBe('/p')
|
||||
expect(workspaceCreateRequestSchema.parse({ name: 'n' }).name).toBe('n')
|
||||
expect(() => workspaceCreateRequestSchema.parse({})).toThrow(/exactly one/)
|
||||
expect(() => workspaceCreateRequestSchema.parse({ path: '/p', name: 'n' })).toThrow(/exactly one/)
|
||||
expect(() => workspaceCreateRequestSchema.parse({})).toThrow()
|
||||
// The retired create-by-name spelling stays a clean schema rejection.
|
||||
expect(() => workspaceCreateRequestSchema.parse({ name: 'n' })).toThrow()
|
||||
expect(workspaceCreateValueSchema.parse({ workspace: view, created: false }).created).toBe(false)
|
||||
})
|
||||
|
||||
@@ -462,6 +465,11 @@ describe('events frame schemas', () => {
|
||||
},
|
||||
] },
|
||||
{ type: 'session/projection', sessionId: 's', key: 'todos', value: [{ content: 'x', status: 'pending' }], seq: 7 },
|
||||
{ type: 'session/tasks', sessionId: 's', tasks: [] },
|
||||
{ type: 'session/tasks', sessionId: 's', tasks: [
|
||||
{ id: 'bash-1', kind: 'bash', label: 'pnpm run build', status: 'running', startedAt: 5 },
|
||||
{ id: 'pty-send-2', kind: 'pty-send', label: 'send keys', status: 'failed', detail: 'exit code: 3', startedAt: 5, finishedAt: 9 },
|
||||
] },
|
||||
{ type: 'stream/error', error: { code: 'internal', message: 'm', details: {} } },
|
||||
]
|
||||
for (const frame of frames) expect(muxFrameSchema.parse(frame)).toMatchObject({ type: frame.type })
|
||||
@@ -470,6 +478,14 @@ describe('events frame schemas', () => {
|
||||
{ type: 'session/projection', sessionId: 's', key: '', value: null, seq: 0 },
|
||||
{ type: 'session/projection', sessionId: 's', key: 'todos', value: null, seq: -1 },
|
||||
{ type: 'session/projection', sessionId: 's', key: 'todos', value: null, seq: 0.5 },
|
||||
// A producer kind stays an open string, but the closed status set and
|
||||
// the identity/label bounds are the carrier's own wire contract.
|
||||
{ type: 'session/tasks', sessionId: 's', tasks: [{ id: '', kind: 'bash', label: 'l', status: 'running', startedAt: 0 }] },
|
||||
{ type: 'session/tasks', sessionId: 's', tasks: [{ id: 'bash-1', kind: '', label: 'l', status: 'running', startedAt: 0 }] },
|
||||
{ type: 'session/tasks', sessionId: 's', tasks: [{ id: 'bash-1', kind: 'bash', label: '', status: 'running', startedAt: 0 }] },
|
||||
{ type: 'session/tasks', sessionId: 's', tasks: [{ id: 'bash-1', kind: 'bash', label: 'l', status: 'pending', startedAt: 0 }] },
|
||||
{ type: 'session/tasks', sessionId: 's', tasks: [{ id: 'bash-1', kind: 'bash', label: 'l', status: 'running', startedAt: -1 }] },
|
||||
{ type: 'session/tasks', sessionId: 's', tasks: [{ id: 'bash-1', kind: 'bash', label: 'l', status: 'completed', startedAt: 0, finishedAt: 0.5 }] },
|
||||
]) expect(() => muxFrameSchema.parse(invalid)).toThrow()
|
||||
expect(askUserQuestionItemSchema.parse({ id: 'q', question: 'Q?' }).id).toBe('q')
|
||||
})
|
||||
@@ -519,6 +535,7 @@ describe('events frame schemas', () => {
|
||||
} },
|
||||
{ type: 'host/workspace-removed', workspaceId: 'w' },
|
||||
{ type: 'host/commands-changed' },
|
||||
{ type: 'host/session-preset-changed', sessionId: 's', agentPreset: 'minimal' },
|
||||
{ type: 'stream/error', error: { code: 'internal', message: 'm', details: {} } },
|
||||
]
|
||||
for (const frame of frames) expect(hostFrameSchema.parse(frame)).toMatchObject({ type: frame.type })
|
||||
@@ -537,3 +554,27 @@ describe('respond payload schemas', () => {
|
||||
expect(payload.sessionId).toBe('s')
|
||||
})
|
||||
})
|
||||
|
||||
describe('agent-preset schemas', () => {
|
||||
it('accepts a roster row and rejects an unknown trust', () => {
|
||||
expect(agentPresetEntrySchema.parse({ id: 'standard', trust: 'system', isDefault: true }))
|
||||
.toEqual({ id: 'standard', trust: 'system', isDefault: true })
|
||||
expect(() => agentPresetEntrySchema.parse({ id: 'x', trust: 'root', isDefault: false })).toThrow()
|
||||
expect(() => agentPresetEntrySchema.parse({ id: '', trust: 'user', isDefault: false })).toThrow()
|
||||
})
|
||||
|
||||
it('accepts an empty roster', () => {
|
||||
// A deployment composing no presets still reports its authoring and
|
||||
// native-open capabilities, so a surface knows what to offer.
|
||||
expect(agentPresetListValueSchema.parse({ presets: [], authorable: false, hasDocument: false }))
|
||||
.toEqual({ presets: [], authorable: false, hasDocument: false })
|
||||
})
|
||||
|
||||
it('answers the open-document union by its discriminant', () => {
|
||||
expect(agentPresetOpenDocumentValueSchema.parse({ opened: true })).toEqual({ opened: true })
|
||||
expect(agentPresetOpenDocumentValueSchema.parse({ opened: false, path: '/presets/mine' }))
|
||||
.toEqual({ opened: false, path: '/presets/mine' })
|
||||
// A closed reply must carry the path the surface shows instead.
|
||||
expect(() => agentPresetOpenDocumentValueSchema.parse({ opened: false })).toThrow()
|
||||
})
|
||||
})
|
||||
|
||||
684
packages/host/apiproxy/tests/session-export.spec.ts
Normal file
684
packages/host/apiproxy/tests/session-export.spec.ts
Normal file
@@ -0,0 +1,684 @@
|
||||
/**
|
||||
* session.export host path: the GET download endpoint streams a ZIP whose
|
||||
* files are the stored artifacts verbatim (root + optional descendants), and
|
||||
* the degenerate compositions fail loudly (missing services → 500, missing
|
||||
* root → 404, missing descendant → errored stream).
|
||||
*/
|
||||
|
||||
import { randomBytes } from 'node:crypto'
|
||||
import { describe, expect, it, vi } from 'vitest'
|
||||
import { Context } from '@deepseek-ai/cordis'
|
||||
import { unzipSync, strFromU8 } from 'fflate'
|
||||
import type { ImageAttachmentRef } from '@deepseek-ai/dsh-attachment'
|
||||
import UserInteractionService from '@deepseek-ai/dsh-user-interaction'
|
||||
import type { SessionHeader, SessionId } from '@deepseek-ai/dsh-session'
|
||||
import type { SessionLineageNode } from '@deepseek-ai/dsh-session-query'
|
||||
import type { SessionRawArtifact } from '@deepseek-ai/dsh-session-persistence'
|
||||
import ApiProxyService, { createApiProxy, toFetchHandler } from '@deepseek-ai/dsh-host-apiproxy'
|
||||
|
||||
const sid = (id: string): SessionId => id as SessionId
|
||||
|
||||
function header(id: string, parentSession?: SessionId): SessionHeader {
|
||||
return {
|
||||
version: 0,
|
||||
id: sid(id),
|
||||
createdAt: 1000,
|
||||
cwd: '/proj',
|
||||
...parentSession === undefined ? {} : { parentSession },
|
||||
delegationDepth: parentSession === undefined ? 0 : 1,
|
||||
}
|
||||
}
|
||||
|
||||
function artifact(id: string, parentSession?: SessionId, content?: string): SessionRawArtifact {
|
||||
return {
|
||||
meta: header(id, parentSession),
|
||||
filename: 'session.jsonl',
|
||||
content: content ?? `{"type":"session","version":0,"id":"${id}","createdAt":1000}\n{"type":"turn/start","seq":0,"time":2000,"data":{"turn":1}}\n`,
|
||||
}
|
||||
}
|
||||
|
||||
function node(id: string, ...descendants: SessionLineageNode[]): SessionLineageNode {
|
||||
return { session: { header: header(id, sid('session-root')), live: false, persisted: true }, descendants }
|
||||
}
|
||||
|
||||
/** One durable image object served by the fake attachment store. */
|
||||
function storedImage(id: string, mediaType: ImageAttachmentRef['mediaType'] = 'image/png') {
|
||||
return {
|
||||
ref: { attachmentId: sid(id), mediaType, bytes: 4, width: 2, height: 2 } as unknown as ImageAttachmentRef,
|
||||
data: new Uint8Array([1, 2, 3, 4]),
|
||||
}
|
||||
}
|
||||
|
||||
/** A user/message event line carrying one image reference. */
|
||||
function imageEventLine(id: string, mediaType: ImageAttachmentRef['mediaType'] = 'image/png'): string {
|
||||
return `{"type":"user/message","seq":1,"time":1000,"data":{"content":[{"type":"image","attachment":{"attachmentId":"${id}","mediaType":"${mediaType}","bytes":4,"width":2,"height":2}}]}}`
|
||||
}
|
||||
|
||||
async function buildApi(
|
||||
artifacts: Record<string, SessionRawArtifact>,
|
||||
descendants: SessionLineageNode[] = [],
|
||||
services: {
|
||||
query?: boolean
|
||||
persistence?: boolean | 'throw' | 'unsupported'
|
||||
attachments?: boolean | ((ref: ImageAttachmentRef, signal?: AbortSignal) => Promise<ReturnType<typeof storedImage>>)
|
||||
sessions?: {
|
||||
get(id: SessionId): { readonly id: SessionId } | undefined
|
||||
flush(session: { readonly id: SessionId }): Promise<boolean>
|
||||
}
|
||||
readRaw?: (id: SessionId, signal?: AbortSignal) => Promise<SessionRawArtifact | undefined>
|
||||
traceSession?: (id: SessionId, signal?: AbortSignal) => Promise<{
|
||||
target: { header: SessionHeader; live: boolean; persisted: boolean }
|
||||
ancestors: readonly SessionLineageNode[]
|
||||
complete: boolean
|
||||
root: { header: SessionHeader; live: boolean; persisted: boolean }
|
||||
descendants: readonly SessionLineageNode[]
|
||||
}>
|
||||
compressionLevel?: 0 | 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9
|
||||
} = {},
|
||||
) {
|
||||
const ctx = new Context()
|
||||
await ctx.plugin(UserInteractionService)
|
||||
const query = services.query ?? true
|
||||
const persistence = services.persistence ?? true
|
||||
if (query) {
|
||||
ctx.provide('sessionQuery', {
|
||||
traceSession: services.traceSession ?? (async () => ({
|
||||
target: { header: header('session-root'), live: false, persisted: true },
|
||||
ancestors: [],
|
||||
complete: true,
|
||||
root: { header: header('session-root'), live: false, persisted: true },
|
||||
descendants,
|
||||
})),
|
||||
} as never)
|
||||
}
|
||||
if (persistence) {
|
||||
ctx.provide('sessionPersistence', {
|
||||
supportsRawArtifacts: persistence !== 'unsupported',
|
||||
readRaw: services.readRaw ?? (async (id: SessionId) => {
|
||||
if (persistence === 'throw') throw new Error('/host/private/session.jsonl')
|
||||
return artifacts[id]
|
||||
}),
|
||||
} as never)
|
||||
}
|
||||
if (services.attachments !== false) {
|
||||
const readImage = typeof services.attachments === 'function'
|
||||
? services.attachments
|
||||
: async (ref: ImageAttachmentRef) => storedImage(String(ref.attachmentId), ref.mediaType)
|
||||
ctx.provide('attachments', {
|
||||
imageLimits: {} as never,
|
||||
validateImage: async () => {},
|
||||
saveImage: async () => { throw new Error('export never saves images') },
|
||||
readImage,
|
||||
} as never)
|
||||
}
|
||||
if (services.sessions !== undefined) ctx.provide('sessions', services.sessions as never)
|
||||
return createApiProxy(ctx, {
|
||||
defaultModelSelection: () => ({ provider: 'p', model: 'm' }),
|
||||
cwd: '/tmp',
|
||||
...services.compressionLevel === undefined
|
||||
? {}
|
||||
: { sessionExportCompressionLevel: services.compressionLevel },
|
||||
})
|
||||
}
|
||||
|
||||
async function responseBytes(response: Response): Promise<Uint8Array> {
|
||||
return new Uint8Array(await response.arrayBuffer())
|
||||
}
|
||||
|
||||
describe('session export compression config', () => {
|
||||
it('defaults to level 6 and rejects values outside the integer 0-9 range', () => {
|
||||
expect(ApiProxyService.Config({})).toEqual({ sessionExportCompressionLevel: 6 })
|
||||
expect(ApiProxyService.Config({ sessionExportCompressionLevel: 0 }))
|
||||
.toEqual({ sessionExportCompressionLevel: 0 })
|
||||
expect(ApiProxyService.Config({ sessionExportCompressionLevel: 9 }))
|
||||
.toEqual({ sessionExportCompressionLevel: 9 })
|
||||
for (const value of [-1, 10, 1.5]) {
|
||||
expect(() => ApiProxyService.Config({ sessionExportCompressionLevel: value } as never)).toThrow()
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
describe('session.export download endpoint', () => {
|
||||
it('streams a ZIP with the root artifact verbatim under its original filename', async () => {
|
||||
const api = await buildApi({ 'session-root': artifact('session-root') })
|
||||
const response = await toFetchHandler(api).fetch(
|
||||
new Request('http://host/api/session.export?sessionId=session-root'),
|
||||
)
|
||||
expect(response.status).toBe(200)
|
||||
expect(response.headers.get('content-type')).toBe('application/zip')
|
||||
expect(response.headers.get('content-disposition')).toContain('dsh-session-session-root.zip')
|
||||
const files = unzipSync(await responseBytes(response))
|
||||
expect(Object.keys(files)).toEqual(['session.jsonl'])
|
||||
expect(strFromU8(files['session.jsonl'] as Uint8Array)).toBe(artifact('session-root').content)
|
||||
})
|
||||
|
||||
it('uses the resolved compression level for ZIP entries', async () => {
|
||||
const root = artifact('session-root', undefined, 'compressible\n'.repeat(32 * 1024))
|
||||
const storedApi = await buildApi({ 'session-root': root }, [], { compressionLevel: 0 })
|
||||
const compressedApi = await buildApi({ 'session-root': root }, [], { compressionLevel: 9 })
|
||||
const stored = await storedApi.downloads.sessionLog(
|
||||
{ sessionId: sid('session-root'), includeDescendants: false },
|
||||
new AbortController().signal,
|
||||
)
|
||||
const compressed = await compressedApi.downloads.sessionLog(
|
||||
{ sessionId: sid('session-root'), includeDescendants: false },
|
||||
new AbortController().signal,
|
||||
)
|
||||
const storedBytes = await responseBytes(stored)
|
||||
const compressedBytes = await responseBytes(compressed)
|
||||
expect(compressedBytes.byteLength).toBeLessThan(storedBytes.byteLength)
|
||||
expect(strFromU8(unzipSync(compressedBytes)['session.jsonl'] as Uint8Array)).toBe(root.content)
|
||||
})
|
||||
|
||||
it('includes descendant artifacts under subagents/<id>/ when requested', async () => {
|
||||
const api = await buildApi({
|
||||
'session-root': artifact('session-root'),
|
||||
'child-a': artifact('child-a', sid('session-root')),
|
||||
'grandchild-a': artifact('grandchild-a', sid('child-a')),
|
||||
}, [
|
||||
node('child-a', node('grandchild-a')),
|
||||
])
|
||||
const response = await toFetchHandler(api).fetch(
|
||||
new Request('http://host/api/session.export?sessionId=session-root&includeDescendants=true'),
|
||||
)
|
||||
expect(response.status).toBe(200)
|
||||
const files = unzipSync(await responseBytes(response))
|
||||
expect(Object.keys(files).sort()).toEqual([
|
||||
'session.jsonl',
|
||||
'subagents/child-a/session.jsonl',
|
||||
'subagents/grandchild-a/session.jsonl',
|
||||
])
|
||||
expect(strFromU8(files['subagents/child-a/session.jsonl'] as Uint8Array))
|
||||
.toBe(artifact('child-a').content)
|
||||
})
|
||||
|
||||
it('flushes each live root and descendant immediately before reading its artifact', async () => {
|
||||
const stored: Record<string, SessionRawArtifact> = {
|
||||
'session-root': artifact('session-root', undefined, 'stale root'),
|
||||
'child-a': artifact('child-a', sid('session-root'), 'stale child'),
|
||||
}
|
||||
const durable: Record<string, SessionRawArtifact> = {
|
||||
'session-root': artifact('session-root', undefined, 'durable root'),
|
||||
'child-a': artifact('child-a', sid('session-root'), 'durable child'),
|
||||
}
|
||||
const flushed: SessionId[] = []
|
||||
const api = await buildApi(stored, [node('child-a')], {
|
||||
sessions: {
|
||||
get: id => durable[id] === undefined ? undefined : { id },
|
||||
flush: async (session) => {
|
||||
const artifactAfterFlush = durable[session.id]
|
||||
if (artifactAfterFlush === undefined) throw new Error('unexpected session')
|
||||
flushed.push(session.id)
|
||||
stored[session.id] = artifactAfterFlush
|
||||
return true
|
||||
},
|
||||
},
|
||||
})
|
||||
const response = await toFetchHandler(api).fetch(
|
||||
new Request('http://host/api/session.export?sessionId=session-root&includeDescendants=true'),
|
||||
)
|
||||
const files = unzipSync(await responseBytes(response))
|
||||
expect(flushed).toEqual([sid('session-root'), sid('child-a')])
|
||||
expect(strFromU8(files['session.jsonl'] as Uint8Array)).toBe('durable root')
|
||||
expect(strFromU8(files['subagents/child-a/session.jsonl'] as Uint8Array)).toBe('durable child')
|
||||
})
|
||||
|
||||
it('reads a cold artifact without asking the live-session store to flush', async () => {
|
||||
const flush = vi.fn(async () => true)
|
||||
const root = artifact('session-root')
|
||||
const api = await buildApi({ 'session-root': root }, [], {
|
||||
sessions: {
|
||||
get: () => undefined,
|
||||
flush,
|
||||
},
|
||||
})
|
||||
const response = await api.downloads.sessionLog(
|
||||
{ sessionId: sid('session-root'), includeDescendants: false },
|
||||
new AbortController().signal,
|
||||
)
|
||||
const files = unzipSync(await responseBytes(response))
|
||||
expect(flush).not.toHaveBeenCalled()
|
||||
expect(strFromU8(files['session.jsonl'] as Uint8Array)).toBe(root.content)
|
||||
})
|
||||
|
||||
it('answers 404 for a missing root session', async () => {
|
||||
const api = await buildApi({})
|
||||
const response = await toFetchHandler(api).fetch(
|
||||
new Request('http://host/api/session.export?sessionId=session-root'),
|
||||
)
|
||||
expect(response.status).toBe(404)
|
||||
})
|
||||
|
||||
it('answers 501 when the persistence backend has no per-session raw artifacts', async () => {
|
||||
const api = await buildApi({}, [], { persistence: 'unsupported' })
|
||||
const response = await toFetchHandler(api).fetch(
|
||||
new Request('http://host/api/session.export?sessionId=session-root'),
|
||||
)
|
||||
expect(response.status).toBe(501)
|
||||
expect(await response.text()).toContain('does not expose per-session raw artifacts')
|
||||
})
|
||||
|
||||
it('answers 400 when the sessionId query parameter is absent', async () => {
|
||||
const api = await buildApi({ 'session-root': artifact('session-root') })
|
||||
const response = await toFetchHandler(api).fetch(
|
||||
new Request('http://host/api/session.export?includeDescendants=true'),
|
||||
)
|
||||
expect(response.status).toBe(400)
|
||||
})
|
||||
|
||||
it('answers 400 for an includeDescendants value other than true or false', async () => {
|
||||
const api = await buildApi({ 'session-root': artifact('session-root') })
|
||||
const response = await toFetchHandler(api).fetch(
|
||||
new Request('http://host/api/session.export?sessionId=session-root&includeDescendants=1'),
|
||||
)
|
||||
expect(response.status).toBe(400)
|
||||
})
|
||||
|
||||
it('answers 500 when the deployment mounts no persistence or session-query service', async () => {
|
||||
const api = await buildApi({}, [], { query: false, persistence: false })
|
||||
const response = await toFetchHandler(api).fetch(
|
||||
new Request('http://host/api/session.export?sessionId=session-root'),
|
||||
)
|
||||
expect(response.status).toBe(500)
|
||||
expect(await response.text()).toContain('session-query')
|
||||
})
|
||||
|
||||
it('fails the whole export when a descendant has no stored artifact', async () => {
|
||||
const api = await buildApi({
|
||||
'session-root': artifact('session-root'),
|
||||
}, [node('child-missing')])
|
||||
const response = await toFetchHandler(api).fetch(
|
||||
new Request('http://host/api/session.export?sessionId=session-root&includeDescendants=true'),
|
||||
)
|
||||
expect(response.status).toBe(200)
|
||||
// The stream errors before completing, so the body read rejects rather
|
||||
// than returning a truncated-but-valid archive.
|
||||
await expect(response.arrayBuffer()).rejects.toThrow()
|
||||
})
|
||||
|
||||
it('keeps an astral character whole when its surrogate pair straddles a push boundary', async () => {
|
||||
// The push loop slices by 2^16 code units and must back off one unit when
|
||||
// the boundary lands inside a surrogate pair; otherwise the pair re-encodes
|
||||
// as U+FFFD and the exported artifact is silently corrupted.
|
||||
const root = { ...artifact('session-root'), content: `${'a'.repeat((1 << 16) - 1)}😀tail` }
|
||||
const api = await buildApi({ 'session-root': root })
|
||||
const response = await toFetchHandler(api).fetch(
|
||||
new Request('http://host/api/session.export?sessionId=session-root'),
|
||||
)
|
||||
const files = unzipSync(await responseBytes(response))
|
||||
expect(strFromU8(files['session.jsonl'] as Uint8Array)).toBe(root.content)
|
||||
})
|
||||
|
||||
it('splits a long artifact on a plain code-unit boundary without backoff', async () => {
|
||||
// A boundary that lands on a BMP character needs no surrogate backoff; the
|
||||
// round trip must still be byte-identical across the multi-chunk push.
|
||||
const root = { ...artifact('session-root'), content: 'z'.repeat((1 << 16) + 4096) }
|
||||
const api = await buildApi({ 'session-root': root })
|
||||
const response = await toFetchHandler(api).fetch(
|
||||
new Request('http://host/api/session.export?sessionId=session-root'),
|
||||
)
|
||||
const files = unzipSync(await responseBytes(response))
|
||||
expect(strFromU8(files['session.jsonl'] as Uint8Array)).toBe(root.content)
|
||||
})
|
||||
|
||||
it('waits for response pull capacity before reading the next archive entry', async () => {
|
||||
const root = artifact('session-root', undefined, [
|
||||
imageEventLine('after-root'),
|
||||
randomBytes(512 * 1024).toString('base64'),
|
||||
].join('\n'))
|
||||
let imageReads = 0
|
||||
const api = await buildApi({ 'session-root': root }, [], {
|
||||
attachments: async (ref) => {
|
||||
imageReads += 1
|
||||
return storedImage(String(ref.attachmentId), ref.mediaType)
|
||||
},
|
||||
})
|
||||
vi.useFakeTimers()
|
||||
let response: Response | undefined
|
||||
try {
|
||||
response = await toFetchHandler(api).fetch(
|
||||
new Request('http://host/api/session.export?sessionId=session-root'),
|
||||
)
|
||||
// Exhausting timer turns must not advance a producer whose byte queue is
|
||||
// full; only a consumer pull can release it.
|
||||
await vi.runAllTimersAsync()
|
||||
expect(imageReads).toBe(0)
|
||||
} finally {
|
||||
vi.useRealTimers()
|
||||
}
|
||||
if (response === undefined) throw new Error('missing export response')
|
||||
const files = unzipSync(await responseBytes(response))
|
||||
expect(imageReads).toBe(1)
|
||||
expect(files['media/after-root.png']).toEqual(storedImage('after-root').data)
|
||||
})
|
||||
|
||||
it('exports an empty artifact as an empty zip entry', async () => {
|
||||
const root = { ...artifact('session-root'), content: '' }
|
||||
const api = await buildApi({ 'session-root': root })
|
||||
const response = await toFetchHandler(api).fetch(
|
||||
new Request('http://host/api/session.export?sessionId=session-root'),
|
||||
)
|
||||
const files = unzipSync(await responseBytes(response))
|
||||
expect(Object.keys(files)).toEqual(['session.jsonl'])
|
||||
expect(strFromU8(files['session.jsonl'] as Uint8Array)).toBe('')
|
||||
})
|
||||
|
||||
it('exports a shared lineage node once (seen-set dedup)', async () => {
|
||||
const api = await buildApi({
|
||||
'session-root': artifact('session-root'),
|
||||
'child-a': artifact('child-a', sid('session-root')),
|
||||
'child-b': artifact('child-b', sid('session-root')),
|
||||
shared: artifact('shared', sid('child-a')),
|
||||
}, [
|
||||
node('child-a', node('shared')),
|
||||
node('child-b', node('shared')),
|
||||
])
|
||||
const response = await toFetchHandler(api).fetch(
|
||||
new Request('http://host/api/session.export?sessionId=session-root&includeDescendants=true'),
|
||||
)
|
||||
const files = unzipSync(await responseBytes(response))
|
||||
expect(Object.keys(files).sort()).toEqual([
|
||||
'session.jsonl',
|
||||
'subagents/child-a/session.jsonl',
|
||||
'subagents/child-b/session.jsonl',
|
||||
'subagents/shared/session.jsonl',
|
||||
])
|
||||
})
|
||||
|
||||
it('answers 500 without leaking the backend error when the root artifact read fails', async () => {
|
||||
const api = await buildApi({}, [], { query: true, persistence: 'throw' })
|
||||
const response = await toFetchHandler(api).fetch(
|
||||
new Request('http://host/api/session.export?sessionId=session-root'),
|
||||
)
|
||||
expect(response.status).toBe(500)
|
||||
const body = await response.text()
|
||||
expect(body).toBe('session log export failed to prepare the stored artifact')
|
||||
expect(body).not.toContain('/host/private/')
|
||||
})
|
||||
|
||||
it('answers the private-error-safe 500 when the live root flush fails', async () => {
|
||||
const api = await buildApi({ 'session-root': artifact('session-root') }, [], {
|
||||
sessions: {
|
||||
get: id => ({ id }),
|
||||
flush: async () => { throw new Error('/host/private/flush-state') },
|
||||
},
|
||||
})
|
||||
const response = await toFetchHandler(api).fetch(
|
||||
new Request('http://host/api/session.export?sessionId=session-root'),
|
||||
)
|
||||
expect(response.status).toBe(500)
|
||||
const body = await response.text()
|
||||
expect(body).toBe('session log export failed to prepare the stored artifact')
|
||||
expect(body).not.toContain('/host/private/')
|
||||
})
|
||||
|
||||
it('forwards one request signal through root, lineage, and descendant reads', async () => {
|
||||
const reads: Array<{ id: SessionId; signal: AbortSignal | undefined }> = []
|
||||
const traces: AbortSignal[] = []
|
||||
const api = await buildApi({}, [node('child-a')], {
|
||||
readRaw: async (id, signal) => {
|
||||
reads.push({ id, signal })
|
||||
return id === sid('session-root')
|
||||
? artifact('session-root')
|
||||
: artifact('child-a', sid('session-root'))
|
||||
},
|
||||
traceSession: async (_id, signal) => {
|
||||
if (signal !== undefined) traces.push(signal)
|
||||
return {
|
||||
target: { header: header('session-root'), live: false, persisted: true },
|
||||
ancestors: [],
|
||||
complete: true,
|
||||
root: { header: header('session-root'), live: false, persisted: true },
|
||||
descendants: [node('child-a')],
|
||||
}
|
||||
},
|
||||
})
|
||||
const controller = new AbortController()
|
||||
const response = await api.downloads.sessionLog(
|
||||
{ sessionId: sid('session-root'), includeDescendants: true },
|
||||
controller.signal,
|
||||
)
|
||||
await response.arrayBuffer()
|
||||
const producerSignal = traces[0]
|
||||
if (producerSignal === undefined) throw new Error('missing lineage signal')
|
||||
expect(reads[0]).toEqual({ id: sid('session-root'), signal: controller.signal })
|
||||
expect(reads[1]).toEqual({ id: sid('child-a'), signal: producerSignal })
|
||||
const cancellation = new Error('request cancelled after response')
|
||||
controller.abort(cancellation)
|
||||
expect(producerSignal.aborted).toBe(true)
|
||||
expect(producerSignal.reason).toBe(cancellation)
|
||||
})
|
||||
|
||||
it('preserves request cancellation instead of translating it to HTTP 500', async () => {
|
||||
const api = await buildApi({ 'session-root': artifact('session-root') })
|
||||
const controller = new AbortController()
|
||||
const cancellation = new Error('request cancelled')
|
||||
controller.abort(cancellation)
|
||||
await expect(api.downloads.sessionLog(
|
||||
{ sessionId: sid('session-root'), includeDescendants: false },
|
||||
controller.signal,
|
||||
)).rejects.toBe(cancellation)
|
||||
})
|
||||
|
||||
it('aborts descendant work and terminates ZIP production when its reader cancels', async () => {
|
||||
let reportDescendantStarted!: (signal: AbortSignal) => void
|
||||
const descendantStarted = new Promise<AbortSignal>((resolve) => {
|
||||
reportDescendantStarted = resolve
|
||||
})
|
||||
const api = await buildApi({}, [node('child-a')], {
|
||||
readRaw: async (id, signal) => {
|
||||
if (id === sid('session-root')) return artifact('session-root')
|
||||
if (signal === undefined) throw new Error('missing descendant signal')
|
||||
reportDescendantStarted(signal)
|
||||
return new Promise((_, reject) => {
|
||||
signal.addEventListener('abort', () => {
|
||||
reject(signal.reason as Error)
|
||||
}, { once: true })
|
||||
})
|
||||
},
|
||||
})
|
||||
const response = await api.downloads.sessionLog(
|
||||
{ sessionId: sid('session-root'), includeDescendants: true },
|
||||
new AbortController().signal,
|
||||
)
|
||||
const reader = response.body?.getReader()
|
||||
if (reader === undefined) throw new Error('missing response body')
|
||||
const descendantSignal = await descendantStarted
|
||||
const cancellation = new Error('download consumer left')
|
||||
await reader.cancel(cancellation)
|
||||
expect(descendantSignal.aborted).toBe(true)
|
||||
expect(descendantSignal.reason).toBe(cancellation)
|
||||
})
|
||||
|
||||
it('aborts attachment reads when its reader cancels', async () => {
|
||||
let reportAttachmentStarted!: (signal: AbortSignal) => void
|
||||
const attachmentStarted = new Promise<AbortSignal>((resolve) => {
|
||||
reportAttachmentStarted = resolve
|
||||
})
|
||||
const root = artifact('session-root', undefined, [
|
||||
'{"type":"session","version":0,"id":"session-root","createdAt":1000}',
|
||||
imageEventLine('slow-img'),
|
||||
].join('\n') + '\n')
|
||||
const api = await buildApi({ 'session-root': root }, [], {
|
||||
attachments: async (_ref, signal) => {
|
||||
if (signal === undefined) throw new Error('missing attachment signal')
|
||||
reportAttachmentStarted(signal)
|
||||
return new Promise((_, reject) => {
|
||||
signal.addEventListener('abort', () => {
|
||||
reject(signal.reason as Error)
|
||||
}, { once: true })
|
||||
})
|
||||
},
|
||||
})
|
||||
const response = await api.downloads.sessionLog(
|
||||
{ sessionId: sid('session-root'), includeDescendants: false },
|
||||
new AbortController().signal,
|
||||
)
|
||||
const reader = response.body?.getReader()
|
||||
if (reader === undefined) throw new Error('missing response body')
|
||||
const attachmentSignal = await attachmentStarted
|
||||
const cancellation = new Error('download consumer left during attachment read')
|
||||
await reader.cancel(cancellation)
|
||||
expect(attachmentSignal.aborted).toBe(true)
|
||||
expect(attachmentSignal.reason).toBe(cancellation)
|
||||
})
|
||||
|
||||
it('uses a stable Error reason when its reader cancels without one', async () => {
|
||||
let reportDescendantStarted!: (signal: AbortSignal) => void
|
||||
const descendantStarted = new Promise<AbortSignal>((resolve) => {
|
||||
reportDescendantStarted = resolve
|
||||
})
|
||||
const api = await buildApi({}, [node('child-a')], {
|
||||
readRaw: async (id, signal) => {
|
||||
if (id === sid('session-root')) return artifact('session-root')
|
||||
if (signal === undefined) throw new Error('missing descendant signal')
|
||||
reportDescendantStarted(signal)
|
||||
return new Promise((_, reject) => {
|
||||
signal.addEventListener('abort', () => {
|
||||
reject(signal.reason as Error)
|
||||
}, { once: true })
|
||||
})
|
||||
},
|
||||
})
|
||||
const response = await api.downloads.sessionLog(
|
||||
{ sessionId: sid('session-root'), includeDescendants: true },
|
||||
new AbortController().signal,
|
||||
)
|
||||
const reader = response.body?.getReader()
|
||||
if (reader === undefined) throw new Error('missing response body')
|
||||
const descendantSignal = await descendantStarted
|
||||
await reader.cancel()
|
||||
expect(descendantSignal.reason).toEqual(new Error('session log export stream cancelled'))
|
||||
})
|
||||
|
||||
it('normalizes a non-Error descendant failure before erroring the stream', async () => {
|
||||
const api = await buildApi({}, [node('child-a')], {
|
||||
readRaw: async (id) => {
|
||||
if (id === sid('session-root')) return artifact('session-root')
|
||||
throw 'descendant read failed'
|
||||
},
|
||||
})
|
||||
const response = await api.downloads.sessionLog(
|
||||
{ sessionId: sid('session-root'), includeDescendants: true },
|
||||
new AbortController().signal,
|
||||
)
|
||||
await expect(response.arrayBuffer()).rejects.toEqual(new Error('descendant read failed'))
|
||||
})
|
||||
|
||||
it('includes media objects referenced by the root log under media/<id>.<ext>', async () => {
|
||||
const root = artifact('session-root', undefined, [
|
||||
'{"type":"session","version":0,"id":"session-root","createdAt":1000}',
|
||||
imageEventLine('img-1'),
|
||||
].join('\n') + '\n')
|
||||
const api = await buildApi({ 'session-root': root })
|
||||
const response = await toFetchHandler(api).fetch(
|
||||
new Request('http://host/api/session.export?sessionId=session-root'),
|
||||
)
|
||||
expect(response.status).toBe(200)
|
||||
const files = unzipSync(await responseBytes(response))
|
||||
expect(Object.keys(files).sort()).toEqual(['media/img-1.png', 'session.jsonl'])
|
||||
expect(files['media/img-1.png']).toEqual(storedImage('img-1').data)
|
||||
})
|
||||
|
||||
it('collects media referenced from nested tool results', async () => {
|
||||
const nested = '{"type":"assistant/message","seq":2,"time":2000,"data":{"content":[{"type":"tool-result","content":[{"type":"image","attachment":{"attachmentId":"nested-1","mediaType":"image/webp","bytes":4,"width":2,"height":2}}]}]}}'
|
||||
const root = artifact('session-root', undefined, [
|
||||
'{"type":"session","version":0,"id":"session-root","createdAt":1000}',
|
||||
nested,
|
||||
].join('\n') + '\n')
|
||||
const api = await buildApi({ 'session-root': root })
|
||||
const response = await toFetchHandler(api).fetch(
|
||||
new Request('http://host/api/session.export?sessionId=session-root'),
|
||||
)
|
||||
const files = unzipSync(await responseBytes(response))
|
||||
expect(Object.keys(files).sort()).toEqual(['media/nested-1.webp', 'session.jsonl'])
|
||||
})
|
||||
|
||||
it('scans the wrapped, inserted, and chunk carriers plus non-object content items', async () => {
|
||||
const block = (id: string, mediaType: string) =>
|
||||
`{"type":"image","attachment":{"attachmentId":"${id}","mediaType":"${mediaType}","bytes":4,"width":2,"height":2}}`
|
||||
const wrapped = `{"type":"assistant/message","seq":2,"time":2000,"data":{"message":{"role":"assistant","content":["noise",${block('wrapped-1', 'image/jpeg')}]}}}`
|
||||
const inserted = `{"type":"context/inserted","seq":3,"time":3000,"data":{"inserted":[{"content":[${block('inserted-1', 'image/gif')}]}]}}`
|
||||
const chunk = `{"type":"assistant/chunk","seq":4,"time":4000,"data":{"chunk":{"type":"block-end","block":${block('chunk-1', 'image/png')}}}}`
|
||||
const root = artifact('session-root', undefined, [
|
||||
'{"type":"session","version":0,"id":"session-root","createdAt":1000}',
|
||||
wrapped,
|
||||
inserted,
|
||||
chunk,
|
||||
].join('\n') + '\n')
|
||||
const api = await buildApi({ 'session-root': root })
|
||||
const response = await toFetchHandler(api).fetch(
|
||||
new Request('http://host/api/session.export?sessionId=session-root'),
|
||||
)
|
||||
const files = unzipSync(await responseBytes(response))
|
||||
expect(Object.keys(files).sort()).toEqual([
|
||||
'media/chunk-1.png',
|
||||
'media/inserted-1.gif',
|
||||
'media/wrapped-1.jpg',
|
||||
'session.jsonl',
|
||||
])
|
||||
})
|
||||
|
||||
it('deduplicates one media object referenced by several included logs', async () => {
|
||||
const line = imageEventLine('shared-img')
|
||||
const root = artifact('session-root', undefined, [
|
||||
'{"type":"session","version":0,"id":"session-root","createdAt":1000}',
|
||||
line,
|
||||
].join('\n') + '\n')
|
||||
const child = artifact('child-a', sid('session-root'), [
|
||||
'{"type":"session","version":0,"id":"child-a","createdAt":1000}',
|
||||
line,
|
||||
].join('\n') + '\n')
|
||||
const api = await buildApi({ 'session-root': root, 'child-a': child }, [node('child-a')])
|
||||
const response = await toFetchHandler(api).fetch(
|
||||
new Request('http://host/api/session.export?sessionId=session-root&includeDescendants=true'),
|
||||
)
|
||||
const files = unzipSync(await responseBytes(response))
|
||||
expect(files['media/shared-img.png']).toEqual(storedImage('shared-img').data)
|
||||
expect(Object.keys(files).filter(name => name.startsWith('media/'))).toEqual(['media/shared-img.png'])
|
||||
})
|
||||
|
||||
it('includes descendant media only when descendants are requested', async () => {
|
||||
const child = artifact('child-a', sid('session-root'), [
|
||||
'{"type":"session","version":0,"id":"child-a","createdAt":1000}',
|
||||
imageEventLine('child-img'),
|
||||
].join('\n') + '\n')
|
||||
const api = await buildApi({ 'session-root': artifact('session-root'), 'child-a': child }, [node('child-a')])
|
||||
const without = await toFetchHandler(api).fetch(
|
||||
new Request('http://host/api/session.export?sessionId=session-root'),
|
||||
)
|
||||
expect(Object.keys(unzipSync(await responseBytes(without)))).toEqual(['session.jsonl'])
|
||||
const withDescendants = await toFetchHandler(api).fetch(
|
||||
new Request('http://host/api/session.export?sessionId=session-root&includeDescendants=true'),
|
||||
)
|
||||
expect(Object.keys(unzipSync(await responseBytes(withDescendants))).sort()).toEqual([
|
||||
'media/child-img.png',
|
||||
'session.jsonl',
|
||||
'subagents/child-a/session.jsonl',
|
||||
])
|
||||
})
|
||||
|
||||
it('fails the whole export when a referenced image cannot be read', async () => {
|
||||
const root = artifact('session-root', undefined, [
|
||||
'{"type":"session","version":0,"id":"session-root","createdAt":1000}',
|
||||
imageEventLine('gone-img'),
|
||||
].join('\n') + '\n')
|
||||
const api = await buildApi({ 'session-root': root }, [], {
|
||||
attachments: async () => { throw new Error('attachment bytes missing') },
|
||||
})
|
||||
const response = await toFetchHandler(api).fetch(
|
||||
new Request('http://host/api/session.export?sessionId=session-root'),
|
||||
)
|
||||
expect(response.status).toBe(200)
|
||||
await expect(response.arrayBuffer()).rejects.toThrow('attachment bytes missing')
|
||||
})
|
||||
|
||||
it('answers 500 when the deployment mounts no attachments service', async () => {
|
||||
const api = await buildApi({ 'session-root': artifact('session-root') }, [], { attachments: false })
|
||||
const response = await toFetchHandler(api).fetch(
|
||||
new Request('http://host/api/session.export?sessionId=session-root'),
|
||||
)
|
||||
expect(response.status).toBe(500)
|
||||
expect(await response.text()).toContain('attachments')
|
||||
})
|
||||
})
|
||||
@@ -29,6 +29,9 @@
|
||||
{
|
||||
"path": "../../util/brand"
|
||||
},
|
||||
{
|
||||
"path": "../../attachment/attachment"
|
||||
},
|
||||
{
|
||||
"path": "../../llm/llm"
|
||||
},
|
||||
@@ -38,6 +41,9 @@
|
||||
{
|
||||
"path": "../../core/agent-default-model"
|
||||
},
|
||||
{
|
||||
"path": "../../preset/agent-presets"
|
||||
},
|
||||
{
|
||||
"path": "../../core/session"
|
||||
},
|
||||
@@ -68,6 +74,9 @@
|
||||
{
|
||||
"path": "../../skill/skill"
|
||||
},
|
||||
{
|
||||
"path": "../../tasks/tasks"
|
||||
},
|
||||
{
|
||||
"path": "../../interaction/commands"
|
||||
},
|
||||
|
||||
@@ -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/host/directory-picker-auto/README.md
|
||||
README.md: f1715566c8aff8be90cab381bcedd4732d0b41f6
|
||||
README.zh.md: 9fc8e539d40a126b30be6dce02257bd9abe37944
|
||||
README.md: b1bbe4f97cdb88d8cf9bfe435c0eb6517554338b
|
||||
README.zh.md: dc67456e9b86636522406bf6a57929b24793dade
|
||||
|
||||
@@ -16,6 +16,6 @@ None; this package neither assembles nor sends a provider request.
|
||||
|
||||
## Known Limitations and Deferred Work
|
||||
|
||||
- **Detection infers operator location from launch context, which no launch-side signal can prove** — a tmux session detached from its SSH launch loses the `SSH_*` markers; a darwin process outside an Aqua session still counts as displayed; and the `ssh -L` shape (a workstation-local launch later reached through a forwarded port, which arrives from `127.0.0.1`) resolves `native` and opens the chooser on the unattended workstation. A wrong `native` choice degrades to the backend's existing retryable failure dialog, and composing `-browse` directly pins the safe interaction for such deployments.
|
||||
- **Detection infers operator location from launch context, which no launch-side signal can prove** — a tmux session detached from its SSH launch loses the `SSH_*` markers; a Darwin process outside an Aqua session still counts as displayed; and a workstation-local launch later reached through `ssh -L` arrives from `127.0.0.1`, resolves `native`, and opens the chooser on the unattended workstation. A wrong `native` choice degrades to the backend's existing retryable failure dialog, and composing `-browse` directly selects the safe interaction for such deployments.
|
||||
- **The Linux chooser probe reads `PATH` only** — a zenity/kdialog reachable some other way (shell alias, non-PATH install) still resolves `browse`; installing either binary on `PATH` restores `native` eligibility at the next boot.
|
||||
- **Boot-time only** — one resolution serves every client of the boot; per-connection adaptivity (native for a local browser, browse for a remote one, same server) would need a per-client capability and the wire advertisement the seam deliberately deleted, and waits for a deployment that serves both at once.
|
||||
|
||||
@@ -16,6 +16,6 @@
|
||||
|
||||
## 已知限制与暂缓事项
|
||||
|
||||
- **探测是从启动上下文推断操作者位置,而任何启动侧信号都无法证明这一点**——从 SSH 启动中脱离的 tmux 会话会丢失 `SSH_*` 标记;Aqua 会话之外的 darwin 进程仍被算作有显示;而 `ssh -L` 形态(在工作站本地启动、之后经转发端口访问,从 `127.0.0.1` 到达)会判定 `native`,把选择器弹在无人值守的工作站上。错误的 `native` 选择会退化为后端既有的可重试失败对话框,而对这类部署,直接组合 `-browse` 即固定住安全的交互。
|
||||
- **探测是从启动上下文推断操作者位置,而任何启动侧信号都无法证明这一点**——从 SSH 启动中脱离的 tmux 会话会丢失 `SSH_*` 标记;Aqua 会话之外的 Darwin 进程仍被算作有显示;在工作站本地启动、之后经 `ssh -L` 访问时,请求会从 `127.0.0.1` 到达,系统会判定 `native`,并把选择器弹在无人值守的工作站上。错误的 `native` 选择会退化为后端既有的可重试失败对话框,而对这类部署,直接组合 `-browse` 即选择安全的交互。
|
||||
- **Linux 选择器探查只读 `PATH`**——以其他途径可用的 zenity/kdialog(shell 别名、未装在 PATH 上)仍判定为 `browse`;把任一二进制装到 `PATH` 上,下次启动即恢复 `native` 资格。
|
||||
- **仅在启动时判定**——一次判定服务本次启动的所有客户端;按连接自适应(同一台服务器,本地浏览器用 native、远程浏览器用 browse)需要按客户端的能力对象以及 seam 有意删除的 wire 广播,等到出现同时服务两种形态的部署再做。
|
||||
|
||||
@@ -1,8 +1,15 @@
|
||||
{
|
||||
"name": "@deepseek-ai/dsh-host-directory-picker-auto",
|
||||
"description": "Adaptive chooser of the directory-picker seam: resolves the host situation at boot and mounts the native or browse backend for the DeepSeek Harness web GUI host",
|
||||
"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/host/directory-picker-auto"
|
||||
},
|
||||
"type": "module",
|
||||
"main": "lib/index.js",
|
||||
"types": "lib/types/index.d.ts",
|
||||
@@ -25,21 +32,21 @@
|
||||
],
|
||||
"license": "BSD-3-Clause",
|
||||
"peerDependencies": {
|
||||
"@cordisjs/plugin-loader": "^1.0.0-rc.5",
|
||||
"@deepseek-ai/dsh-host-directory-picker-browse": "^0.0.1",
|
||||
"@deepseek-ai/dsh-host-directory-picker-native": "^0.0.1",
|
||||
"@deepseek-ai/dsh-host-webserver": "^0.0.1",
|
||||
"@deepseek-ai/dsh-invariants": "^0.0.1",
|
||||
"cordis": "^4.0.0-rc.7"
|
||||
"@deepseek-ai/cordis-plugin-loader": "workspace:^",
|
||||
"@deepseek-ai/dsh-host-directory-picker-browse": "workspace:^",
|
||||
"@deepseek-ai/dsh-host-directory-picker-native": "workspace:^",
|
||||
"@deepseek-ai/dsh-host-webserver": "workspace:^",
|
||||
"@deepseek-ai/dsh-invariants": "workspace:^",
|
||||
"@deepseek-ai/cordis": "workspace:^"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@cordisjs/plugin-include": "workspace:^",
|
||||
"@cordisjs/plugin-loader": "workspace:^",
|
||||
"@deepseek-ai/cordis-plugin-include": "workspace:^",
|
||||
"@deepseek-ai/cordis-plugin-loader": "workspace:^",
|
||||
"@deepseek-ai/dsh-host-directory-picker": "workspace:^",
|
||||
"@deepseek-ai/dsh-host-directory-picker-browse": "workspace:^",
|
||||
"@deepseek-ai/dsh-host-directory-picker-native": "workspace:^",
|
||||
"@deepseek-ai/dsh-host-webserver": "workspace:^",
|
||||
"@deepseek-ai/dsh-invariants": "workspace:^",
|
||||
"cordis": "^4.0.0-rc.7"
|
||||
"@deepseek-ai/cordis": "workspace:^"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -10,9 +10,9 @@
|
||||
* @module @deepseek-ai/dsh-host-directory-picker-auto
|
||||
*/
|
||||
|
||||
import type { Context } from 'cordis'
|
||||
import type { Context } from '@deepseek-ai/cordis'
|
||||
// Empty type imports carry the `loader` and `httpServer` Context merges for the reads below.
|
||||
import type {} from '@cordisjs/plugin-loader'
|
||||
import type {} from '@deepseek-ai/cordis-plugin-loader'
|
||||
import type {} from '@deepseek-ai/dsh-host-webserver'
|
||||
import { canExecute, hasLinuxChooserBinary } from './probe.ts'
|
||||
import type { DirectoryPickerBackendKind } from './resolve.ts'
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
* @module @deepseek-ai/dsh-host-directory-picker-auto/invariant
|
||||
*/
|
||||
|
||||
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-host-directory-picker-auto'
|
||||
|
||||
@@ -13,15 +13,38 @@ import { tmpdir } from 'node:os'
|
||||
import { join } from 'node:path'
|
||||
import { pathToFileURL } from 'node:url'
|
||||
import { afterEach, describe, expect, it, vi } from 'vitest'
|
||||
import { Context } from 'cordis'
|
||||
import Loader from '@cordisjs/plugin-loader'
|
||||
import Include from '@cordisjs/plugin-include'
|
||||
import { Context } from '@deepseek-ai/cordis'
|
||||
import Loader from '@deepseek-ai/cordis-plugin-loader'
|
||||
import Include from '@deepseek-ai/cordis-plugin-include'
|
||||
import HttpServer from '@deepseek-ai/dsh-host-webserver'
|
||||
import type { DirectoryPicker } from '@deepseek-ai/dsh-host-directory-picker'
|
||||
import BrowseDirectoryPicker from '@deepseek-ai/dsh-host-directory-picker-browse'
|
||||
import NativeDirectoryPicker from '@deepseek-ai/dsh-host-directory-picker-native'
|
||||
import * as DirectoryPickerAuto from '../src/index.ts'
|
||||
|
||||
const renameControl = vi.hoisted(() => ({
|
||||
attempts: 0,
|
||||
failureCode: 'EPERM',
|
||||
injectedFailures: 0,
|
||||
remainingFailures: 0,
|
||||
}))
|
||||
|
||||
vi.mock('node:fs/promises', async (importOriginal) => {
|
||||
const actual = await importOriginal<typeof import('node:fs/promises')>()
|
||||
return {
|
||||
...actual,
|
||||
async rename(oldPath: string, newPath: string): Promise<void> {
|
||||
renameControl.attempts++
|
||||
if (renameControl.remainingFailures > 0) {
|
||||
renameControl.remainingFailures--
|
||||
renameControl.injectedFailures++
|
||||
throw Object.assign(new Error(`injected rename failure for ${newPath}`), { code: renameControl.failureCode })
|
||||
}
|
||||
await actual.rename(oldPath, newPath)
|
||||
},
|
||||
}
|
||||
})
|
||||
|
||||
const AUTO = '@deepseek-ai/dsh-host-directory-picker-auto'
|
||||
const NATIVE = '@deepseek-ai/dsh-host-directory-picker-native'
|
||||
const BROWSE = '@deepseek-ai/dsh-host-directory-picker-browse'
|
||||
@@ -41,6 +64,10 @@ afterEach(async () => {
|
||||
}
|
||||
root = undefined
|
||||
fakeBin = undefined
|
||||
renameControl.attempts = 0
|
||||
renameControl.failureCode = 'EPERM'
|
||||
renameControl.injectedFailures = 0
|
||||
renameControl.remainingFailures = 0
|
||||
})
|
||||
|
||||
/** Write a two-row cordis.yml (webserver + chooser), then boot it through the real Loader. */
|
||||
@@ -163,9 +190,30 @@ describe('real Loader composition', () => {
|
||||
const backendEntry = [...ctx.loader.entries()].find(entry => entry.options.name === NATIVE)!
|
||||
await ctx.loader.remove(backendEntry.id)
|
||||
const autoEntry = [...ctx.loader.entries()].find(entry => entry.options.name === AUTO)!
|
||||
renameControl.remainingFailures = 1
|
||||
await expect(autoEntry.fiber!.dispose()).resolves.not.toThrow()
|
||||
expect(entryNames(ctx)).not.toContain(NATIVE)
|
||||
// Same self-dispose persistence as above: let the write land before teardown.
|
||||
await expect.poll(async () => await readFile(configPath, 'utf8')).toContain('disabled: true')
|
||||
expect(renameControl.injectedFailures).toBe(1)
|
||||
expect(renameControl.remainingFailures).toBe(0)
|
||||
expect(renameControl.attempts).toBeGreaterThanOrEqual(2)
|
||||
})
|
||||
|
||||
it('reports a terminal debounced-write failure again to the teardown owner', { timeout: 60_000 }, async () => {
|
||||
stubAttendedHost()
|
||||
const { ctx } = await loadComposition('127.0.0.1')
|
||||
const autoEntry = [...ctx.loader.entries()].find(entry => entry.options.name === AUTO)!
|
||||
const include = [...ctx.loader.entries()]
|
||||
.find(entry => entry.options.name === 'cordis:include')?.subtree as Include | undefined
|
||||
if (include === undefined) throw new Error('expected the root Include tree')
|
||||
renameControl.failureCode = 'EIO'
|
||||
renameControl.remainingFailures = 1
|
||||
|
||||
await autoEntry.fiber!.dispose()
|
||||
await expect.poll(() => renameControl.injectedFailures).toBe(1)
|
||||
await expect(include.stop()).rejects.toMatchObject({ code: 'EIO' })
|
||||
await expect(ctx.fiber.dispose()).resolves.not.toThrow()
|
||||
context = undefined
|
||||
})
|
||||
})
|
||||
|
||||
@@ -1,8 +1,15 @@
|
||||
{
|
||||
"name": "@deepseek-ai/dsh-host-directory-picker-browse",
|
||||
"description": "In-app browsing backend of the directory-picker seam (listing/creation primitives over the host filesystem)",
|
||||
"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/host/directory-picker-browse"
|
||||
},
|
||||
"type": "module",
|
||||
"main": "lib/index.js",
|
||||
"types": "lib/types/index.d.ts",
|
||||
@@ -32,16 +39,16 @@
|
||||
"dependencies": {
|
||||
"@deepseek-ai/dsh-host-directory-picker": "workspace:^",
|
||||
"clsx": "^2.0.0",
|
||||
"schemastery": "^3.18.0"
|
||||
"@deepseek-ai/schemastery": "workspace:^"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@deepseek-ai/dsh-client-locale": "^0.0.1",
|
||||
"@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-client-ui-workspace": "^0.0.1",
|
||||
"@deepseek-ai/dsh-invariants": "^0.0.1",
|
||||
"cordis": "^4.0.0-rc.7",
|
||||
"@deepseek-ai/dsh-client-locale": "workspace:^",
|
||||
"@deepseek-ai/dsh-client-runtime": "workspace:^",
|
||||
"@deepseek-ai/dsh-client-ui-primitives": "workspace:^",
|
||||
"@deepseek-ai/dsh-client-ui-slots": "workspace:^",
|
||||
"@deepseek-ai/dsh-client-ui-workspace": "workspace:^",
|
||||
"@deepseek-ai/dsh-invariants": "workspace:^",
|
||||
"@deepseek-ai/cordis": "workspace:^",
|
||||
"react": "^18.2.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
@@ -53,15 +60,17 @@
|
||||
"@deepseek-ai/dsh-client-ui-workspace": "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"
|
||||
},
|
||||
"dshClient": {
|
||||
"inject": [
|
||||
"@deepseek-ai/dsh-client-runtime",
|
||||
"@deepseek-ai/dsh-client-ui-workspace",
|
||||
"@deepseek-ai/dsh-client-locale"
|
||||
],
|
||||
"platform": "web"
|
||||
"dsh": {
|
||||
"client": {
|
||||
"inject": [
|
||||
"@deepseek-ai/dsh-client-runtime",
|
||||
"@deepseek-ai/dsh-client-ui-workspace",
|
||||
"@deepseek-ai/dsh-client-locale"
|
||||
],
|
||||
"platform": "web"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -12,8 +12,8 @@
|
||||
import { mkdir, opendir, stat } from 'node:fs/promises'
|
||||
import { homedir } from 'node:os'
|
||||
import { basename, dirname, join, posix, resolve, win32 } from 'node:path'
|
||||
import type { Context } from 'cordis'
|
||||
import z from 'schemastery'
|
||||
import type { Context } from '@deepseek-ai/cordis'
|
||||
import z from '@deepseek-ai/schemastery'
|
||||
import {
|
||||
DirectoryPicker, DirectoryPickerError,
|
||||
} from '@deepseek-ai/dsh-host-directory-picker'
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
* @module @deepseek-ai/dsh-host-directory-picker-browse/invariant
|
||||
*/
|
||||
|
||||
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-host-directory-picker-browse'
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
// @vitest-environment jsdom
|
||||
import { Context } from 'cordis'
|
||||
import { Context } from '@deepseek-ai/cordis'
|
||||
import { afterEach, describe, expect, it, vi } from 'vitest'
|
||||
import { cleanup, fireEvent, render, screen, waitFor } from '@testing-library/react'
|
||||
import { SlotsService } from '@deepseek-ai/dsh-client-runtime/client'
|
||||
|
||||
@@ -4,7 +4,7 @@ import { mkdir, mkdtemp, rm, symlink, writeFile } from 'node:fs/promises'
|
||||
import { homedir, tmpdir } from 'node:os'
|
||||
import { basename, join } from 'node:path'
|
||||
import { afterAll, beforeAll, describe, expect, it } from 'vitest'
|
||||
import { Context } from 'cordis'
|
||||
import { Context } from '@deepseek-ai/cordis'
|
||||
import { DirectoryPickerError } from '@deepseek-ai/dsh-host-directory-picker'
|
||||
import type { DirectoryPickerBrowseCapability } from '@deepseek-ai/dsh-host-directory-picker'
|
||||
import BrowseDirectoryPicker, { boundedInsert, fullyQualified, raceAbort } from '../src/index.ts'
|
||||
|
||||
@@ -1,8 +1,15 @@
|
||||
{
|
||||
"name": "@deepseek-ai/dsh-host-directory-picker-native",
|
||||
"description": "Native-OS-chooser backend of the directory-picker seam for the DeepSeek Harness web GUI host",
|
||||
"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/host/directory-picker-native"
|
||||
},
|
||||
"type": "module",
|
||||
"main": "lib/index.js",
|
||||
"types": "lib/types/index.d.ts",
|
||||
@@ -40,11 +47,11 @@
|
||||
"koffi": "^3.1.0"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@deepseek-ai/dsh-client-runtime": "^0.0.1",
|
||||
"@deepseek-ai/dsh-client-ui-slots": "^0.0.1",
|
||||
"@deepseek-ai/dsh-client-ui-workspace": "^0.0.1",
|
||||
"@deepseek-ai/dsh-invariants": "^0.0.1",
|
||||
"cordis": "^4.0.0-rc.7",
|
||||
"@deepseek-ai/dsh-client-runtime": "workspace:^",
|
||||
"@deepseek-ai/dsh-client-ui-slots": "workspace:^",
|
||||
"@deepseek-ai/dsh-client-ui-workspace": "workspace:^",
|
||||
"@deepseek-ai/dsh-invariants": "workspace:^",
|
||||
"@deepseek-ai/cordis": "workspace:^",
|
||||
"react": "^18.2.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
@@ -53,15 +60,17 @@
|
||||
"@deepseek-ai/dsh-client-ui-workspace": "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",
|
||||
"tsx": "^4.19.2"
|
||||
},
|
||||
"dshClient": {
|
||||
"inject": [
|
||||
"@deepseek-ai/dsh-client-runtime",
|
||||
"@deepseek-ai/dsh-client-ui-workspace"
|
||||
],
|
||||
"platform": "web"
|
||||
"dsh": {
|
||||
"client": {
|
||||
"inject": [
|
||||
"@deepseek-ai/dsh-client-runtime",
|
||||
"@deepseek-ai/dsh-client-ui-workspace"
|
||||
],
|
||||
"platform": "web"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
* @module @deepseek-ai/dsh-host-directory-picker-native/invariant
|
||||
*/
|
||||
|
||||
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-host-directory-picker-native'
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
// @vitest-environment jsdom
|
||||
import { Context } from 'cordis'
|
||||
import { Context } from '@deepseek-ai/cordis'
|
||||
import { describe, expect, it, vi } from 'vitest'
|
||||
import { act, cleanup, render } from '@testing-library/react'
|
||||
import { afterEach } from 'vitest'
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
/** Registration/capability behavior of the native backend (the seam's cordis half). */
|
||||
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { Context } from 'cordis'
|
||||
import { Context } from '@deepseek-ai/cordis'
|
||||
import NativeDirectoryPicker from '../src/index.ts'
|
||||
|
||||
describe('NativeDirectoryPicker', () => {
|
||||
|
||||
@@ -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/host/directory-picker/README.md
|
||||
README.md: 3749b238b56578ec68610bc13550760aa084bad6
|
||||
README.zh.md: bc77a9c6e1d76e00926774dc518fce42b2860735
|
||||
README.md: d90f939aca57b6bc520bb96b56b8a7738b69a522
|
||||
README.zh.md: 40d82b3d60ab7d27100133385a73f31d8cb3c26a
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
|
||||
English | [中文](README.zh.md)
|
||||
|
||||
The **workspace-directory picking seam** for the web-GUI host: an abstract `DirectoryPicker` service (`ctx.directoryPicker`) whose single contract method `capability()` returns a discriminated capability describing how an operator selects a directory. Backends differ in interaction shape, not just mechanism, so the seam models the shapes explicitly instead of one method set: `{ kind: 'native', pick(signal) }` opens one native OS chooser on the host display ([`-native`](../directory-picker-native/README.md)); `{ kind: 'browse', list(path?), createDirectory(path, name) }` serves listing/creation primitives an in-app browser drives, which works for remote clients no OS chooser can reach ([`-browse`](../directory-picker-browse/README.md)). Consumers switch on `capability().kind`; the union derives from the merge-extensible `DirectoryPickerCapabilities` map (a new backend declaration-merges its shape there), and the documented default for an unknown kind is to hide the picking affordance rather than fail. The capability object must be stable for the service lifetime. The client side mirrors the seam without a wire advertisement: each backend package is dual-face, its browser half registering the matching picking interaction into ui-workspace's directory-flow slots — so one composition row swaps both the host capability and the client flow together. A composition that should not pin an interaction mounts the [`-auto`](../directory-picker-auto/README.md) chooser instead, which resolves the host's situation once at boot and mounts the matching backend row itself.
|
||||
The web GUI host's workspace-directory picker is a capability seam. The abstract `DirectoryPicker` service (`ctx.directoryPicker`) is its Service Definition. Its only method, `capability()`, returns a discriminated union describing how an operator selects a directory. Backends differ in user interaction, not just implementation: `{ kind: 'native', pick(signal) }` opens one native OS chooser on the host display ([`-native`](../directory-picker-native/README.md)); `{ kind: 'browse', list(path?), createDirectory(path, name) }` provides listing and creation operations for an in-app browser, which works for remote clients that cannot reach an OS chooser ([`-browse`](../directory-picker-browse/README.md)). Consumers switch on `capability().kind`; the union derives from the merge-extensible `DirectoryPickerCapabilities` map, and a new backend adds its variant there through declaration merging. For an unknown kind, consumers hide directory picking rather than fail. The capability object must be stable for the service lifetime. Each backend package also has a browser entrypoint that registers the matching interaction in ui-workspace's directory-flow slots, so one composition row selects both the host capability and the client flow. A composition that should choose at runtime mounts [`-auto`](../directory-picker-auto/README.md), which inspects the host once at boot and mounts the matching backend row.
|
||||
|
||||
Browse primitives fail with the typed `DirectoryPickerError` (`directory-unreadable` / `directory-exists` / `directory-create-failed`, each carrying the subject `path`), which the consuming gateway maps 1:1 onto wire error codes. `DirectoryEntry` rows carry a host-owned `hidden` flag (POSIX dot convention) so display policy stays client-side; `DirectoryListing.crumbs` is the ancestor chain from the filesystem root, every crumb a jump target. Design rationale, the `ctx.fs` separation, and the policy decisions live in [the directory-picker capability seam Agent Note](../../../.agents/notes/implemented/architecture/2026-07-28-directory-picker-capability-seam.md).
|
||||
|
||||
@@ -16,4 +16,4 @@ None; this package neither assembles nor sends a provider request.
|
||||
|
||||
## Known Limitations and Deferred Work
|
||||
|
||||
- **No multi-root vocabulary** — the browse contract exposes one ancestry chain per listing; per-deployment root scoping (and Windows drive-root enumeration above a drive) waits for a consumer that needs it, per the seam Agent Note.
|
||||
- **No multi-root support** — the browse contract exposes one ancestry chain per listing; per-deployment root scoping (and Windows drive-root enumeration above a drive) waits for a consumer that needs it, per the DirectoryPicker Agent Note.
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
|
||||
[English](README.md) | 中文
|
||||
|
||||
web GUI 宿主的**工作区目录选择 seam**:抽象服务 `DirectoryPicker`(`ctx.directoryPicker`),唯一约定方法 `capability()` 返回一个可辨识能力对象,描述操作者以何种方式选择目录。后端之间的差异在交互形态而不只是机制,因此 seam 显式建模形态而非统一方法集:`{ kind: 'native', pick(signal) }` 在宿主屏幕上打开一个原生 OS 选择器([`-native`](../directory-picker-native/README.md));`{ kind: 'browse', list(path?), createDirectory(path, name) }` 提供应用内浏览器驱动的列举/创建原语,也能服务于 OS 对话框无法触及的远程客户端([`-browse`](../directory-picker-browse/README.md))。消费方按 `capability().kind` 分支;联合类型由可合并扩展的 `DirectoryPickerCapabilities` 映射派生(新后端通过声明合并加入自己的形态),未知 kind 的文档化默认行为是隐藏选择入口而非失败。能力对象在服务生命周期内必须保持稳定。client 侧以镜像方式承接该 seam,无需通过 wire 公布能力:每个后端包都是双面包,其 browser half 把匹配的选取交互注册进 ui-workspace 的目录流 slot——因此一项组合配置会同时切换宿主能力与 client 流程。不应固定某种交互的组合改为挂载 [`-auto`](../directory-picker-auto/README.md) 选择器,它在启动时一次性判定宿主处境,并自行挂载匹配的后端行。
|
||||
web GUI 宿主的工作区目录选择是一项能力 seam。抽象的 `DirectoryPicker` 服务(`ctx.directoryPicker`)是其 Service Definition。该服务只提供一个方法:`capability()`,它返回一个可辨识联合类型,说明操作者如何选择目录。后端之间的用户交互不同,不只是实现不同:`{ kind: 'native', pick(signal) }` 在宿主屏幕上打开一个原生 OS 选择器([`-native`](../directory-picker-native/README.md));`{ kind: 'browse', list(path?), createDirectory(path, name) }` 提供应用内浏览器使用的列举与创建操作,也能服务于无法访问 OS 对话框的远程客户端([`-browse`](../directory-picker-browse/README.md))。消费方按 `capability().kind` 分支;联合类型由可合并扩展的 `DirectoryPickerCapabilities` 映射派生,新后端通过声明合并在其中加入自己的变体。遇到未知 kind 时,消费方会隐藏目录选择入口,而不是失败。能力对象在服务生命周期内必须保持稳定。每个后端包还提供 browser 入口,在 ui-workspace 的 directory-flow slot 中注册匹配的交互,因此一项组合配置会同时选择宿主能力与 client 流程。需要在运行时选择交互的组合挂载 [`-auto`](../directory-picker-auto/README.md),它在启动时检查一次宿主情况,并挂载匹配的后端行。
|
||||
|
||||
浏览原语失败时会抛出带类型的 `DirectoryPickerError`(`directory-unreadable`/`directory-exists`/`directory-create-failed`,各自携带出错对象的 `path`),消费网关将其 1:1 映射为协议错误码。`DirectoryEntry` 行携带宿主判定的 `hidden` 标志(POSIX 点前缀约定),展示策略留在客户端;`DirectoryListing.crumbs` 是从文件系统根开始的祖先链,每个 crumb 都是跳转目标。设计依据、与 `ctx.fs` 的切分、策略裁决见[目录选择能力 seam Agent Note](../../../.agents/notes/implemented/architecture/2026-07-28-directory-picker-capability-seam.md)。
|
||||
|
||||
@@ -16,4 +16,4 @@ web GUI 宿主的**工作区目录选择 seam**:抽象服务 `DirectoryPicker`
|
||||
|
||||
## 已知限制与暂缓事项
|
||||
|
||||
- **约定未定义多根目录词汇**——浏览约定每次列举只暴露一条祖先链;按部署限定可浏览根(以及 Windows 盘符之上的根枚举)等到出现需要它的消费方再做,见 seam Agent Note。
|
||||
- **不支持多根目录**——浏览约定每次列举只公开一条祖先链;按部署限定可浏览根(以及在盘符根的上一级枚举 Windows 各盘符根目录)等到出现需要它的消费方再做,见 DirectoryPicker Agent Note。
|
||||
|
||||
@@ -1,8 +1,15 @@
|
||||
{
|
||||
"name": "@deepseek-ai/dsh-host-directory-picker",
|
||||
"description": "Abstract workspace-directory picking seam (ctx.directoryPicker) for the DeepSeek Harness web GUI host",
|
||||
"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/host/directory-picker"
|
||||
},
|
||||
"type": "module",
|
||||
"main": "lib/index.js",
|
||||
"types": "lib/types/index.d.ts",
|
||||
@@ -25,11 +32,11 @@
|
||||
],
|
||||
"license": "BSD-3-Clause",
|
||||
"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:^"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -11,7 +11,7 @@
|
||||
* @module @deepseek-ai/dsh-host-directory-picker
|
||||
*/
|
||||
|
||||
import { Context, Service } from 'cordis'
|
||||
import { Context, Service } from '@deepseek-ai/cordis'
|
||||
|
||||
/** The native interaction: one OS directory chooser on the host display. */
|
||||
export interface DirectoryPickerNativeCapability {
|
||||
@@ -115,7 +115,7 @@ export class DirectoryPickerError extends Error {
|
||||
}
|
||||
}
|
||||
|
||||
declare module 'cordis' {
|
||||
declare module '@deepseek-ai/cordis' {
|
||||
interface Context {
|
||||
directoryPicker: DirectoryPicker
|
||||
}
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
/** Package-owned invariant companion for the directory-picker seam. @module @deepseek-ai/dsh-host-directory-picker/invariant */
|
||||
|
||||
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-host-directory-picker'
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
/** Contract behavior the seam itself owns: registration identity and typed failures. */
|
||||
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { Context } from 'cordis'
|
||||
import { Context } from '@deepseek-ai/cordis'
|
||||
import { DirectoryPicker, DirectoryPickerError } from '../src/index.ts'
|
||||
import type { DirectoryPickerCapability } from '../src/index.ts'
|
||||
|
||||
|
||||
@@ -1,8 +1,15 @@
|
||||
{
|
||||
"name": "@deepseek-ai/dsh-frontend-static",
|
||||
"description": "SPA dist server for the Web shell: owns the webserver fallback seat, serving the built frontend with index-tap injection, traversal rejection, and SPA index fallback",
|
||||
"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/host/frontend-static"
|
||||
},
|
||||
"type": "module",
|
||||
"main": "lib/index.js",
|
||||
"types": "lib/types/index.d.ts",
|
||||
@@ -25,17 +32,17 @@
|
||||
],
|
||||
"license": "BSD-3-Clause",
|
||||
"peerDependencies": {
|
||||
"@deepseek-ai/dsh-host-webserver": "^0.0.1",
|
||||
"@deepseek-ai/dsh-invariants": "^0.0.1",
|
||||
"cordis": "^4.0.0-rc.7"
|
||||
},
|
||||
"dependencies": {
|
||||
"schemastery": "^3.18.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@cordisjs/plugin-loader": "workspace:^",
|
||||
"@deepseek-ai/dsh-host-webserver": "workspace:^",
|
||||
"@deepseek-ai/dsh-invariants": "workspace:^",
|
||||
"cordis": "^4.0.0-rc.7"
|
||||
"@deepseek-ai/cordis": "workspace:^"
|
||||
},
|
||||
"dependencies": {
|
||||
"@deepseek-ai/schemastery": "workspace:^"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@deepseek-ai/cordis-plugin-loader": "workspace:^",
|
||||
"@deepseek-ai/dsh-host-webserver": "workspace:^",
|
||||
"@deepseek-ai/dsh-invariants": "workspace:^",
|
||||
"@deepseek-ai/cordis": "workspace:^"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -14,8 +14,8 @@
|
||||
import type { ServerResponse } from 'node:http'
|
||||
import { readFile } from 'node:fs/promises'
|
||||
import { dirname, extname, join, normalize, resolve, sep } from 'node:path'
|
||||
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-host-webserver'
|
||||
|
||||
/** Stable Cordis plugin name. */
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
* @module @deepseek-ai/dsh-frontend-static/invariant
|
||||
*/
|
||||
|
||||
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-frontend-static'
|
||||
|
||||
@@ -11,9 +11,9 @@ import { tmpdir } from 'node:os'
|
||||
import { join } from 'node:path'
|
||||
import { pathToFileURL } from 'node:url'
|
||||
import { afterEach, describe, expect, it } from 'vitest'
|
||||
import { Context } from 'cordis'
|
||||
import Loader from '@cordisjs/plugin-loader'
|
||||
import Include from '@cordisjs/plugin-include'
|
||||
import { Context } from '@deepseek-ai/cordis'
|
||||
import Loader from '@deepseek-ai/cordis-plugin-loader'
|
||||
import Include from '@deepseek-ai/cordis-plugin-include'
|
||||
import HttpServer from '@deepseek-ai/dsh-host-webserver'
|
||||
import * as FrontendStatic from '../src/index.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/host/webserver/README.md
|
||||
README.md: 569c3f0c19db2c308beaef35baaf915fd39768cd
|
||||
README.zh.md: 3aee06487743764bf2cb837360bb1ac9f0268508
|
||||
README.md: c41001fba3a69bfd7c00550d0be602e3fc2e0474
|
||||
README.zh.md: 061bed977e456ba6c3cd38f5ad3d30fe0c9354ab
|
||||
|
||||
@@ -2,9 +2,9 @@
|
||||
|
||||
English | [中文](README.zh.md)
|
||||
|
||||
Web HTTP and upgrade-route registration plugin (default-exported `HttpServerService`, config `{host, port}`): a `node:http` server that listens on activation and provides `ctx.httpServer`. `register(route)` adds a named `exact`/`prefix` HTTP route; `registerUpgrade(route)` adds an upgrade route for an exact pathname. A duplicate path within either table throws because route patterns are a composition-level contract and a collision is a misconfiguration; both methods return a disposer that removes the registration. `registerFallback(handler)` claims the single fallback seat answering everything no named route matches — one owner only (a second claim throws; the SPA dist server [`dsh-frontend-static`](../frontend-static/README.md) is the shipped owner), 404 while unclaimed. `tapIndex(transform)` adds an index.html transform, and `applyIndexTaps(html)` runs a body through the registered transforms in order — the fallback owner calls it on every index response. `port` reads the listening port (the OS-assigned value when `port` is 0), and `host` reads the configured bind host (composition-time facts other plugins adapt to, e.g. the directory-picker chooser). HTTP match order is fixed: exact over the whole table, then longest prefix, then the fallback seat. Upgrades match exactly and unmatched connections are closed; registration order carries no request-facing semantics.
|
||||
Web HTTP and upgrade-route registration plugin (default-exported `HttpServerService`, config `{host, port}`): a `node:http` server that listens on activation and provides `ctx.httpServer`. `register(route)` adds a named `exact`/`prefix` HTTP route; `registerUpgrade(route)` adds an upgrade route for an exact pathname. A duplicate path within either table throws because route patterns are a composition-level contract and a collision is a misconfiguration; both methods return a disposer that removes the registration. `registerFallback(handler)` registers the one handler for requests that match no named route. A second registration throws; the SPA dist server [`dsh-frontend-static`](../frontend-static/README.md) is the shipped owner, and the server returns 404 while none is registered. `tapIndex(transform)` adds an index.html transform, and `applyIndexTaps(html)` runs a body through the registered transforms in order; the fallback handler calls it on every index response. `port` reads the listening port (the OS-assigned value when `port` is 0), and `host` reads the configured bind host (composition-time facts other plugins adapt to, e.g. the directory-picker chooser). HTTP match order is fixed: exact over the whole table, then longest prefix, then the fallback handler. Upgrades match exactly and unmatched connections are closed; registration order carries no request-facing semantics.
|
||||
|
||||
The package knows no harness concepts and serves no files: the `/api` HTTP bridge and downlink WebSockets are routes owned by the connection plugin, plugin bundles and the HMR event stream are routes owned by the modules/hmr plugins, and dist serving belongs to the fallback owner. The upgrade handler owns the protocol handshake and connection contents; the webserver only delivers the raw socket and request. `host` accepts only `127.0.0.1` (default posture) and `0.0.0.0` (deliberate network exposure). Web (browser) shape only — Electron loads dist over `file://` and carries fetch over an IPC bridge, not this server. This package never prints; the URL line belongs to the shell.
|
||||
The package knows no harness concepts and serves no files: the `/api` HTTP bridge and downlink WebSockets are routes owned by the connection plugin, plugin bundles and the HMR event stream are routes owned by the modules/hmr plugins, and dist serving belongs to the fallback owner. The upgrade handler owns the protocol handshake and connection contents; the webserver only delivers the raw socket and request. `host` accepts only `127.0.0.1` (default posture) and `0.0.0.0` (deliberate network exposure). This server serves browsers only; Electron loads dist over `file://` and carries fetch over an IPC bridge. This package never prints; the URL line belongs to the shell.
|
||||
|
||||
A listen failure (EADDRINUSE…) throws out of activation and rejects Loader composition with the bind diagnostic; the failed candidate fiber is disposed. An HTTP request whose handling throws (a fallback owner's `decodeURIComponent` on a malformed %-escape, a client dropping mid-body) is answered 400 — or the socket destroyed when headers are already out — and logged as a warning; it never exits the process. An upgrade-handler exception or upgraded-socket transport error is logged as a warning and destroys its socket. Disposal starts `close()` and `closeAllConnections()`, destroys every tracked upgraded socket, and returns only after the HTTP server and those sockets have closed.
|
||||
|
||||
|
||||
@@ -2,9 +2,9 @@
|
||||
|
||||
[English](README.md) | 中文
|
||||
|
||||
Web HTTP 与 upgrade route 注册插件(默认导出 `HttpServerService`,配置为 `{host, port}`):一个在激活时开始监听的 `node:http` 服务器,提供 `ctx.httpServer`。`register(route)` 添加具名的 `exact`/`prefix` HTTP route;`registerUpgrade(route)` 添加精确 pathname 的 upgrade route;同一张表内的重复路径会抛错,因为 route 模式是组合层约定,冲突即配置错误;两者返回的 disposer 都会移除注册。`registerFallback(handler)` 认领唯一的回退席位,应答所有未被具名 route 命中的请求:只允许一个持有者(第二次认领会抛错;随附的持有者是 SPA dist 服务器 [`dsh-frontend-static`](../frontend-static/README.md)),席位未被认领时返回 404。`tapIndex(transform)` 添加一个 index.html 转换,`applyIndexTaps(html)` 按注册顺序对一段响应体运行已注册的转换:fallback 持有者在每次 index 响应时调用它。`port` 读取正在监听的端口(当 `port` 为 0 时读取 OS 分配的值),`host` 读取配置的绑定宿主(这些是其他插件据以自适应的组合期事实,例如 directory-picker 选择器)。HTTP 匹配顺序固定不变:先在整张表中匹配精确 route,再匹配最长前缀,最后交给回退席位。upgrade 只做精确匹配,未命中连接直接关闭;注册顺序不承载任何面向请求的语义。
|
||||
Web HTTP 与 upgrade route 注册插件(默认导出 `HttpServerService`,配置为 `{host, port}`):一个在激活时开始监听的 `node:http` 服务器,提供 `ctx.httpServer`。`register(route)` 添加具名的 `exact`/`prefix` HTTP route;`registerUpgrade(route)` 添加精确 pathname 的 upgrade route;同一张表内的重复路径会抛错,因为 route 模式是组合层约定,冲突即配置错误;两者返回的 disposer 都会移除注册。`registerFallback(handler)` 注册一个 handler,处理所有未被具名 route 命中的请求。第二次注册会抛错;随附的 SPA dist 服务器 [`dsh-frontend-static`](../frontend-static/README.md) 是该 handler 的所有者,没有注册 handler 时服务器返回 404。`tapIndex(transform)` 添加一个 index.html 转换,`applyIndexTaps(html)` 按注册顺序对一段响应体运行已注册的转换;fallback handler 在每次 index 响应时调用它。`port` 读取正在监听的端口(当 `port` 为 0 时读取 OS 分配的值),`host` 读取配置的绑定宿主(这些是其他插件据以自适应的组合期事实,例如 directory-picker 选择器)。HTTP 匹配顺序固定不变:先在整张表中匹配精确 route,再匹配最长前缀,最后交给 fallback handler。upgrade 只做精确匹配,未命中连接直接关闭;注册顺序不影响请求处理。
|
||||
|
||||
该包不了解任何 harness 概念,也不提供任何文件服务:`/api` HTTP 桥接与下行 WebSocket 是 connection 插件的 route,插件 bundle 与 HMR(热模块替换)事件流是 modules/hmr 插件的 route,dist 服务则属于 fallback 持有者。upgrade handler 拥有协议握手与连接内容;webserver 只交付原始 socket 与 request。`host` 只接受 `127.0.0.1`(默认姿态)和 `0.0.0.0`(有意向网络开放)。该服务器只服务 Web(浏览器)形态;Electron 通过 `file://` 加载 dist,并经 IPC 桥接承载 fetch,而不使用本服务器。该包从不打印内容;URL 行属于 shell。
|
||||
该包不了解任何 harness 概念,也不提供任何文件服务:`/api` HTTP 桥接与下行 WebSocket 是 connection 插件的 route,插件 bundle 与 HMR(热模块替换)事件流是 modules/hmr 插件的 route,dist 服务则属于 fallback 持有者。upgrade handler 拥有协议握手与连接内容;webserver 只交付原始 socket 与 request。`host` 只接受 `127.0.0.1`(默认值)和 `0.0.0.0`(有意向网络开放)。该服务器只服务浏览器;Electron 通过 `file://` 加载 dist,并经 IPC 桥接承载 fetch。该包从不打印内容;URL 行属于 shell。
|
||||
|
||||
监听失败(EADDRINUSE……)会从激活过程抛出,以 bind 诊断使 Loader 组合 reject;失败的候选 fiber 会被 dispose(资源释放)。处理 HTTP 请求时抛错(例如 fallback 持有者的 `decodeURIComponent` 收到格式错误的百分号转义,或客户端在请求体传输中途断开)时,服务器会响应 400;若响应头已经发出,则销毁 socket,并记录 warning,但绝不会退出进程。upgrade handler 抛错或升级 socket 出现传输错误时,会记录 warning 并销毁对应 socket。资源释放会启动 `close()` 与 `closeAllConnections()`,销毁所有受跟踪的升级 socket,并仅在 HTTP server 与这些 socket 均已关闭后返回。
|
||||
|
||||
|
||||
@@ -1,8 +1,15 @@
|
||||
{
|
||||
"name": "@deepseek-ai/dsh-host-webserver",
|
||||
"description": "Web route-registration plugin: HTTP and upgrade routes, index transform taps, and static dist fallback; knows no harness concepts",
|
||||
"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/host/webserver"
|
||||
},
|
||||
"type": "module",
|
||||
"main": "lib/index.js",
|
||||
"types": "lib/types/index.d.ts",
|
||||
@@ -25,14 +32,14 @@
|
||||
],
|
||||
"license": "BSD-3-Clause",
|
||||
"peerDependencies": {
|
||||
"cordis": "^4.0.0-rc.7",
|
||||
"@deepseek-ai/dsh-invariants": "^0.0.1"
|
||||
"@deepseek-ai/cordis": "workspace:^",
|
||||
"@deepseek-ai/dsh-invariants": "workspace:^"
|
||||
},
|
||||
"dependencies": {
|
||||
"schemastery": "^3.18.0"
|
||||
"@deepseek-ai/schemastery": "workspace:^"
|
||||
},
|
||||
"devDependencies": {
|
||||
"cordis": "^4.0.0-rc.7",
|
||||
"@deepseek-ai/cordis": "workspace:^",
|
||||
"@deepseek-ai/dsh-invariants": "workspace:^"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -12,10 +12,10 @@ import { createServer } from 'node:http'
|
||||
import type { IncomingMessage, ServerResponse, Server } from 'node:http'
|
||||
import type { AddressInfo } from 'node:net'
|
||||
import type { Duplex } from 'node:stream'
|
||||
import { Context, Service } from 'cordis'
|
||||
import z from 'schemastery'
|
||||
import { Context, Service } from '@deepseek-ai/cordis'
|
||||
import z from '@deepseek-ai/schemastery'
|
||||
|
||||
declare module 'cordis' {
|
||||
declare module '@deepseek-ai/cordis' {
|
||||
interface Context {
|
||||
httpServer: HttpServerService
|
||||
}
|
||||
@@ -50,12 +50,11 @@ export interface Config {
|
||||
}
|
||||
|
||||
/**
|
||||
* The web-shape HTTP carrier service. Activation listens immediately (route
|
||||
* registration order carries no request-facing semantics: named routes are
|
||||
* composed to be disjoint, and the fallback seat answers anything not yet
|
||||
* claimed during the boot window — 404 until its owner registers). A listen
|
||||
* failure throws out of init — a FAILED fiber the boot's fail-loud sweep
|
||||
* reports.
|
||||
* The browser HTTP carrier service. Activation listens immediately. Route
|
||||
* registration order does not affect requests because configured named routes
|
||||
* must be distinct, and the fallback handler answers anything not yet claimed
|
||||
* during startup with 404 until its owner registers. A listen failure rejects
|
||||
* initialization, and the boot process reports the failed fiber.
|
||||
*/
|
||||
export class HttpServerService extends Service {
|
||||
static Config: z<Config> = z.object({
|
||||
@@ -224,8 +223,8 @@ export class HttpServerService extends Service {
|
||||
})
|
||||
})
|
||||
|
||||
// Node does not include upgraded sockets in closeAllConnections(), so the
|
||||
// service tracks and destroys them as part of the same ownership boundary.
|
||||
// Node does not include upgraded sockets in closeAllConnections(). The service
|
||||
// owns them with the other connections, so it tracks and destroys them explicitly.
|
||||
this.ctx.effect(() => async () => {
|
||||
const serverClosed = new Promise<void>((resolve) => {
|
||||
this.server.close(() => { resolve() })
|
||||
|
||||
@@ -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-host-webserver'
|
||||
|
||||
@@ -12,9 +12,9 @@ import { tmpdir } from 'node:os'
|
||||
import { join } from 'node:path'
|
||||
import { pathToFileURL } from 'node:url'
|
||||
import { afterEach, describe, expect, it } from 'vitest'
|
||||
import { Context } from 'cordis'
|
||||
import Loader from '@cordisjs/plugin-loader'
|
||||
import Include from '@cordisjs/plugin-include'
|
||||
import { Context } from '@deepseek-ai/cordis'
|
||||
import Loader from '@deepseek-ai/cordis-plugin-loader'
|
||||
import Include from '@deepseek-ai/cordis-plugin-include'
|
||||
import HttpServer from '../src/index.ts'
|
||||
|
||||
let root: string | undefined
|
||||
|
||||
Reference in New Issue
Block a user