Merge remote-tracking branch 'origin/master' into worktree/web-background-tasks-display-258f7e
# Conflicts: # docs/subsystems/tasks.i18n.yaml # docs/subsystems/tasks.md # docs/subsystems/tasks.zh.md # packages/host/apiproxy/README.i18n.yaml # packages/host/apiproxy/README.md # packages/host/apiproxy/README.zh.md # packages/host/apiproxy/src/api-proxy.ts # packages/tasks/tasks-local/src/index.ts # packages/tasks/tasks/README.i18n.yaml # packages/tasks/tasks/README.md # packages/tasks/tasks/README.zh.md # packages/tasks/tasks/src/index.ts
This commit is contained in:
@@ -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: 5f52781021a70d89c63a49aa74dec002a04d0f40
|
||||
README.zh.md: 996935b2028c98a97a7d6208c72fc6a72a023ee4
|
||||
README.md: 2a27ab9f2cfcd4f12c32b815583b13f439b3eaac
|
||||
README.zh.md: ba49ccff82bab1e677bad1495e48b8503564ae1d
|
||||
|
||||
@@ -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?}`, 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.
|
||||
|
||||
@@ -30,15 +30,15 @@ Question responses are validated against their pending request before the first
|
||||
|
||||
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.
|
||||
|
||||
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.
|
||||
|
||||
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({ 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()`.
|
||||
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.
|
||||
|
||||
@@ -52,13 +52,13 @@ The `agentPreset.list` domain exposes the deployment's preset roster so a browse
|
||||
|
||||
`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 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 `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 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.
|
||||
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
|
||||
|
||||
@@ -70,7 +70,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?}`,提供 `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`,最后是这个默认值。已经跑过一轮的会话从自己的日志推导选择,空白会话则能观察到创建之后保存的默认值。
|
||||
|
||||
@@ -30,15 +30,15 @@ Settings 分节中的 `reasoningEffort` 在 agent-default-model 插件配置中
|
||||
|
||||
会话标题与其他所有领域一样搭乘这对通用投影机制——历史尾页的 `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 只是提示性设计,这个方法始终可被调用。
|
||||
|
||||
待处理的 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 顺序认领下一条可唤醒消息,浏览器绝不重发或提升它。队列操作绝不恢复冷会话,客户端也绝不根据轮次或状态事件推断某项已退出队列。
|
||||
|
||||
后台任务沿用同一种实时推送姿态。当组合中有 `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({ 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()` 中。
|
||||
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 变量上限。
|
||||
|
||||
@@ -52,17 +52,17 @@ Workspace 列表与 Session 列表是相互独立的重连基线。`workspace.cr
|
||||
|
||||
`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` 而不是做差分。
|
||||
`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 偏好 `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` 错误应答这些领域。
|
||||
`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 影响
|
||||
|
||||
@@ -70,7 +70,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:^",
|
||||
@@ -62,13 +70,13 @@
|
||||
"@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:^",
|
||||
"zod": "^4.4.3"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@deepseek-ai/dsh-agent-presets": "^0.0.1",
|
||||
"@deepseek-ai/dsh-invariants": "^0.0.1",
|
||||
"cordis": "^4.0.0-rc.7"
|
||||
"@deepseek-ai/dsh-agent-presets": "workspace:^",
|
||||
"@deepseek-ai/dsh-invariants": "workspace:^",
|
||||
"@deepseek-ai/cordis": "workspace:^"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@deepseek-ai/dsh-agent-presets": "workspace:^",
|
||||
@@ -77,6 +85,6 @@
|
||||
"@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/cordis": "workspace:^"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -5,14 +5,16 @@
|
||||
|
||||
import { randomUUID } from 'node:crypto'
|
||||
import { mkdir, stat } from 'node:fs/promises'
|
||||
import { dirname, join } from 'node:path'
|
||||
import type { Context } from 'cordis'
|
||||
import { dirname } from 'node:path'
|
||||
import type { Context } from '@deepseek-ai/cordis'
|
||||
import { installModelSelection } from '@deepseek-ai/dsh-agent'
|
||||
import type { Agent, ModelSelection, ModelSelectionRef, AgentOptions, AgentStatus } from '@deepseek-ai/dsh-agent'
|
||||
import { AGENT_DEFAULT_MODEL_SETTINGS_NAMESPACE } from '@deepseek-ai/dsh-agent-default-model'
|
||||
import { createUserMessage, freezeMessage, ReasoningEffortId } from '@deepseek-ai/dsh-llm'
|
||||
import { AttachmentError } from '@deepseek-ai/dsh-attachment'
|
||||
import type { ImageAttachmentRef } from '@deepseek-ai/dsh-attachment'
|
||||
import { contentHasImage, createUserMessage, freezeMessage, ReasoningEffortId } from '@deepseek-ai/dsh-llm'
|
||||
import { errorChain } from '@deepseek-ai/dsh-llm'
|
||||
import type { MessageSource } from '@deepseek-ai/dsh-llm'
|
||||
import type { ContentBlock, MessageSource } from '@deepseek-ai/dsh-llm'
|
||||
import { isAppendSurfaceEvent, lastActivityTime } from '@deepseek-ai/dsh-session'
|
||||
import type { Session, SessionEvent, SessionEventMap, SessionHeader, SessionId, UserMessage } from '@deepseek-ai/dsh-session'
|
||||
import type { SessionPersistence } from '@deepseek-ai/dsh-session-persistence'
|
||||
@@ -31,11 +33,12 @@ import {
|
||||
PresetNotWritableError, resolveSessionPreset,
|
||||
SETTINGS_NAMESPACE as AGENT_PRESET_SETTINGS_NAMESPACE, UnknownPresetError,
|
||||
} from '@deepseek-ai/dsh-agent-presets'
|
||||
import type { PresetBearingSession } from '@deepseek-ai/dsh-agent-presets'
|
||||
import type {} from '@deepseek-ai/dsh-tools'
|
||||
import type {
|
||||
ApiProxy, ConfigurableProviderView, CredentialView, GoalRef, HistoryEntry, HostFrame,
|
||||
ModelCatalogFailure, ModelProviderGroup,
|
||||
ModelReasoning, MuxFrame, QuestionResponsePayload, SessionProjectionsBlock, SessionSearchItem,
|
||||
ModelReasoning, MuxFrame, PromptContentPart, QuestionResponsePayload, SessionProjectionsBlock, SessionSearchItem,
|
||||
QueuedInboxItem, SessionSummary, SettingsNamespaceView, SubagentAddress, TaskView, ToolEventView,
|
||||
WorkspaceId, WorkspaceView,
|
||||
} from './api/index.ts'
|
||||
@@ -94,7 +97,7 @@ import { canOpenNativePath, openNativePath, openNativeTextFile } from './native-
|
||||
const DEFAULT_MAX_MESSAGES = 50
|
||||
|
||||
/** Non-model settings namespaces intentionally served to the Web client. */
|
||||
const WEB_SETTINGS_NAMESPACES = ['permission'] as const
|
||||
const WEB_SETTINGS_NAMESPACES = ['locale', 'permission', 'ui-conversation', 'ui-theme'] as const
|
||||
|
||||
/** Provider work budget: at most 100 calls and 2,000 inspected hits. */
|
||||
const SESSION_SEARCH_PROVIDER_CALL_LIMIT = 100
|
||||
@@ -105,6 +108,113 @@ const COLD_SUMMARY_BATCH_SIZE = 16
|
||||
/** Conversation message event types (the pagination counting unit). */
|
||||
const MESSAGE_TYPES = new Set(['user/message', 'assistant/message'])
|
||||
|
||||
/** Decode the browser payload while rejecting non-canonical base64 forms. */
|
||||
function decodeBase64(data: string): Uint8Array {
|
||||
const decoded = Buffer.from(data, 'base64')
|
||||
if (data.length === 0 || decoded.toString('base64') !== data) {
|
||||
throw new AttachmentError('Image upload is not canonical base64.', 'INVALID_IMAGE_BASE64')
|
||||
}
|
||||
return new Uint8Array(decoded)
|
||||
}
|
||||
|
||||
/** Validate one prompt as a batch before publishing any durable image object. */
|
||||
async function durablePromptContent(ctx: Context, content: readonly PromptContentPart[]): Promise<ContentBlock[]> {
|
||||
if (content.every(part => part.type === 'text')) {
|
||||
return content.map(part => ({ type: 'text', text: part.text }))
|
||||
}
|
||||
const limits = ctx.attachments.imageLimits
|
||||
if (content.filter(part => part.type === 'image').length > limits.maxImagesPerMessage) {
|
||||
throw new AttachmentError('Prompt exceeds the configured image-count limit.', 'TOO_MANY_IMAGES')
|
||||
}
|
||||
const prepared = content.map(part => part.type === 'text'
|
||||
? part
|
||||
: { part, data: decodeBase64(part.data) })
|
||||
const images = prepared.filter((part): part is Extract<typeof part, { data: Uint8Array }> => 'data' in part)
|
||||
const totalBytes = images.reduce((sum, image) => sum + image.data.byteLength, 0)
|
||||
if (totalBytes > limits.maxMessageImageBytes) {
|
||||
throw new AttachmentError('Prompt exceeds the configured aggregate image-byte limit.', 'IMAGES_TOO_LARGE')
|
||||
}
|
||||
for (const image of images) {
|
||||
await ctx.attachments.validateImage({
|
||||
data: image.data,
|
||||
mediaType: image.part.mediaType,
|
||||
...image.part.name === undefined ? {} : { name: image.part.name },
|
||||
})
|
||||
}
|
||||
const blocks: ContentBlock[] = []
|
||||
for (const item of prepared) {
|
||||
if (!('data' in item)) {
|
||||
blocks.push({ type: 'text', text: item.text })
|
||||
continue
|
||||
}
|
||||
const attachment = await ctx.attachments.saveImage({
|
||||
data: item.data,
|
||||
mediaType: item.part.mediaType,
|
||||
...item.part.name === undefined ? {} : { name: item.part.name },
|
||||
})
|
||||
blocks.push({ type: 'image', attachment })
|
||||
}
|
||||
return blocks
|
||||
}
|
||||
|
||||
/** Search durable content for an image reference, including nested tool results. */
|
||||
function imageBlockIn(content: unknown, match: (ref: ImageAttachmentRef) => boolean): ImageAttachmentRef | undefined {
|
||||
if (!Array.isArray(content)) return undefined
|
||||
for (const value of content) {
|
||||
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
|
||||
if (match(ref)) return ref
|
||||
}
|
||||
if (block.type === 'tool-result') {
|
||||
const nested = imageBlockIn(block.content, match)
|
||||
if (nested !== undefined) return nested
|
||||
}
|
||||
}
|
||||
return undefined
|
||||
}
|
||||
|
||||
/** Search every durable event carrier that can own model-visible content. */
|
||||
function imageInEvent(event: SessionEvent, match: (ref: ImageAttachmentRef) => boolean): ImageAttachmentRef | undefined {
|
||||
const data = event.data as {
|
||||
content?: unknown
|
||||
message?: { content?: unknown }
|
||||
inserted?: Array<{ content?: unknown }>
|
||||
chunk?: { type?: unknown; block?: unknown }
|
||||
}
|
||||
const direct = imageBlockIn(data.content, match)
|
||||
if (direct !== undefined) return direct
|
||||
if (data.message !== undefined) {
|
||||
const wrapped = imageBlockIn(data.message.content, match)
|
||||
if (wrapped !== undefined) return wrapped
|
||||
}
|
||||
if (data.inserted !== undefined) {
|
||||
for (const message of data.inserted) {
|
||||
const inserted = imageBlockIn(message.content, match)
|
||||
if (inserted !== undefined) return inserted
|
||||
}
|
||||
}
|
||||
if (event.type === 'assistant/chunk' && data.chunk?.type === 'block-end') {
|
||||
return imageBlockIn([data.chunk.block], match)
|
||||
}
|
||||
return undefined
|
||||
}
|
||||
|
||||
/** True when the current model-visible surface contains an image. */
|
||||
function messagesHaveImage(messages: readonly { content: readonly ContentBlock[] }[]): boolean {
|
||||
return messages.some(message => contentHasImage(message.content))
|
||||
}
|
||||
|
||||
/** Resolve the first reference matching one opaque id. */
|
||||
function referencedImage(events: readonly SessionEvent[], attachmentId: string): ImageAttachmentRef | undefined {
|
||||
for (const event of events) {
|
||||
const found = imageInEvent(event, ref => String(ref.attachmentId) === attachmentId)
|
||||
if (found !== undefined) return found
|
||||
}
|
||||
return undefined
|
||||
}
|
||||
|
||||
/**
|
||||
* Product settings intentionally exposed beside model-provider namespaces.
|
||||
*
|
||||
@@ -422,8 +532,6 @@ export interface ApiProxyDefaults {
|
||||
saveDefaultModelSelection?: (selection: ModelSelection) => Promise<void>
|
||||
/** Default project directory for new sessions whose create request carries no cwd. */
|
||||
cwd: string
|
||||
/** Parent directory for name-created workspaces. */
|
||||
workspaceRoot: string
|
||||
/** Native open-with-default-application; injectable for carrier tests. */
|
||||
openPath?: (path: string, signal: AbortSignal) => Promise<void>
|
||||
/** Native text-editor handoff; injectable for settings-document tests. */
|
||||
@@ -814,9 +922,6 @@ class SessionCwdConflict extends Error {
|
||||
}
|
||||
}
|
||||
|
||||
/** Host failed before the registry could adopt a name-created directory. */
|
||||
class WorkspaceDirectoryCreationError extends Error {}
|
||||
|
||||
/** An explicit Host naming operation would duplicate another Workspace title. */
|
||||
class WorkspaceNameConflictError extends Error {
|
||||
constructor(readonly workspaceName: string) {
|
||||
@@ -888,6 +993,14 @@ export function createApiProxy(ctx: Context, defaults: ApiProxyDefaults): ApiPro
|
||||
const pendingQuestions = new Map<RpcId, PendingQuestion>()
|
||||
const pendingApprovals = new Map<RpcId, PendingApproval>()
|
||||
const muxQueues = new Set<FrameQueue<RpcRequest<MuxFrame>>>()
|
||||
const imageAdmissionChains = new WeakMap<Agent, Promise<void>>()
|
||||
|
||||
/** Serialize image admission with model selection for one agent. */
|
||||
function serializeImageAdmission<T>(agent: Agent, operation: () => Promise<T>): Promise<T> {
|
||||
const result = (imageAdmissionChains.get(agent) ?? Promise.resolve()).then(operation)
|
||||
imageAdmissionChains.set(agent, result.then(() => undefined, () => undefined))
|
||||
return result
|
||||
}
|
||||
|
||||
/**
|
||||
* Install or return the session-local model selection that prompt assembly snapshots.
|
||||
@@ -944,7 +1057,9 @@ export function createApiProxy(ctx: Context, defaults: ApiProxyDefaults): ApiPro
|
||||
* common paths — reconnecting, resuming, retrying a create — are unaffected.
|
||||
* @param sessionId - the identity being adopted.
|
||||
* @param requested - the preset the request named, if any.
|
||||
* @param existing - the preset the session was created under, if any.
|
||||
* @param existing - the preset the session RUNS, if any; both callers resolve
|
||||
* it from the log, which differs from the creation header once a blank
|
||||
* session has switched.
|
||||
* @throws when both are present and differ.
|
||||
*/
|
||||
function assertPresetUnchanged(
|
||||
@@ -1257,17 +1372,26 @@ export function createApiProxy(ctx: Context, defaults: ApiProxyDefaults): ApiPro
|
||||
* The registry view scope a transcript's presenters resolve in.
|
||||
*
|
||||
* A live agent is that scope itself (its chain passes through its preset's
|
||||
* standing layer). A cold session names its preset on the header, and the
|
||||
* standing layer). A cold session resolves its preset from the LOG, and the
|
||||
* preset's STANDING key serves without resuming anything — ensuring the
|
||||
* mount composes plugins but starts no agent, session, or turn. No roster,
|
||||
* no recorded preset, or a preset the roster no longer supplies all fall
|
||||
* back to the global layer: the transcript still serves, with the generic
|
||||
* cards a viewless entry renders.
|
||||
*
|
||||
* Reading the header alone would render a session that switched while blank
|
||||
* through the composition it was CREATED with. Every tool only the newer
|
||||
* preset registers resolves to no presenter there, and the transcript
|
||||
* silently degrades to generic cards for exactly the calls its history is
|
||||
* made of.
|
||||
* @param sessionId - the transcript being read.
|
||||
* @param header - that session's header (attached or inspected).
|
||||
* @param session - that session's header and log (attached or inspected).
|
||||
* @returns the scope to pass to presenter lookups, or undefined for global.
|
||||
*/
|
||||
async function presenterScopeFor(sessionId: SessionId, header: SessionHeader): Promise<ScopeKey | undefined> {
|
||||
async function presenterScopeFor(
|
||||
sessionId: SessionId,
|
||||
session: PresetBearingSession,
|
||||
): Promise<ScopeKey | undefined> {
|
||||
const live = ctx.get('agents')?.get(sessionId)
|
||||
if (live !== undefined) return live
|
||||
const presets = ctx.get('agentPresets')
|
||||
@@ -1277,7 +1401,7 @@ export function createApiProxy(ctx: Context, defaults: ApiProxyDefaults): ApiPro
|
||||
// through the DEFAULT preset's standing layer: that is the composition
|
||||
// an unnamed session composes today, and presenters are pure display,
|
||||
// so the worst a mismatch produces is the generic card it had anyway.
|
||||
return await presets.standingKeyFor(header.agentPreset)
|
||||
return await presets.standingKeyFor(resolveSessionPreset(session))
|
||||
} catch {
|
||||
// Swallows only the unknown/unusable-preset rejection from the roster:
|
||||
// a deleted or broken preset must degrade this read, never fail it.
|
||||
@@ -1370,7 +1494,7 @@ export function createApiProxy(ctx: Context, defaults: ApiProxyDefaults): ApiPro
|
||||
// Beside the cwd check for the same reason, and after the await so it
|
||||
// covers every path that yields a live agent — freshly created, adopted
|
||||
// live, resumed from disk, or recovered by the concurrent-creation catch.
|
||||
assertPresetUnchanged(sessionId, presetId, agent.session.header.agentPreset)
|
||||
assertPresetUnchanged(sessionId, presetId, resolveSessionPreset(agent.session))
|
||||
if (agent.session.header.cwd !== cwd) {
|
||||
throw new SessionCwdConflict(sessionId, cwd, agent.session.header.cwd)
|
||||
}
|
||||
@@ -1378,29 +1502,11 @@ export function createApiProxy(ctx: Context, defaults: ApiProxyDefaults): ApiPro
|
||||
}
|
||||
|
||||
/** Resolve or create one path while holding the Host's workspace-create chain. */
|
||||
function ensureWorkspace(
|
||||
path: string,
|
||||
title: string | undefined,
|
||||
rejectExistingName = false,
|
||||
createDirectory = false,
|
||||
): Promise<{ workspace: Workspace; created: boolean }> {
|
||||
function ensureWorkspace(path: string): Promise<{ workspace: Workspace; created: boolean }> {
|
||||
const operation = workspaceCreationChain.then(async () => {
|
||||
if (rejectExistingName && title !== undefined
|
||||
&& ctx.workspace.list().some(workspace => workspace.title === title)) {
|
||||
throw new WorkspaceNameConflictError(title)
|
||||
}
|
||||
if (createDirectory) {
|
||||
try {
|
||||
await mkdir(path, { recursive: true })
|
||||
} catch (error: unknown) {
|
||||
throw new WorkspaceDirectoryCreationError(
|
||||
`failed to create workspace directory "${path}": ${String(error)}`,
|
||||
)
|
||||
}
|
||||
}
|
||||
const existing = await ctx.workspace.resolveByPath(path)
|
||||
if (existing !== undefined) return { workspace: existing, created: false }
|
||||
return { workspace: await ctx.workspace.create(path, title), created: true }
|
||||
return { workspace: await ctx.workspace.create(path), created: true }
|
||||
})
|
||||
workspaceCreationChain = operation.then(() => undefined, () => undefined)
|
||||
return operation
|
||||
@@ -1904,12 +2010,16 @@ export function createApiProxy(ctx: Context, defaults: ApiProxyDefaults): ApiPro
|
||||
})
|
||||
}
|
||||
}
|
||||
// Echo the RESOLVED composition so a client can label the session it
|
||||
// just created without waiting for the next list refresh — the create
|
||||
// is the commit point that knows it (a caller that named none gets
|
||||
// the default the header recorded).
|
||||
// Echo the composition the session RUNS so a client can label it
|
||||
// without waiting for the next list refresh — the create is the commit
|
||||
// point that knows it (a caller that named none gets the default).
|
||||
// Resolved from the log for the same reason `sessionListFields()` is:
|
||||
// this handler also adopts an already-live session, and one that
|
||||
// switched while blank runs a preset its header no longer names, so
|
||||
// echoing the header would contradict both the adoption this call just
|
||||
// allowed and the row `session.list` serves for the same session.
|
||||
const created = ctx.agents.get(sessionId)
|
||||
const createdPreset = created?.session.header.agentPreset
|
||||
const createdPreset = created === undefined ? undefined : resolveSessionPreset(created.session)
|
||||
return ok(request, { sessionId, ...createdPreset === undefined ? {} : { agentPreset: createdPreset } })
|
||||
},
|
||||
|
||||
@@ -1928,7 +2038,7 @@ export function createApiProxy(ctx: Context, defaults: ApiProxyDefaults): ApiPro
|
||||
details: {},
|
||||
})
|
||||
}
|
||||
const page = historyPage(ctx, state.events, beforeSeq, maxMessages, await presenterScopeFor(sessionId, state.header))
|
||||
const page = historyPage(ctx, state.events, beforeSeq, maxMessages, await presenterScopeFor(sessionId, state))
|
||||
return ok(request, {
|
||||
events: page.events,
|
||||
hasMore: page.hasMore,
|
||||
@@ -1950,41 +2060,51 @@ export function createApiProxy(ctx: Context, defaults: ApiProxyDefaults): ApiPro
|
||||
const { sessionId, provider, model, reasoningEffort } = request.payload
|
||||
const found = await agentFor(sessionId)
|
||||
if ('error' in found) return err(request, found.error)
|
||||
try {
|
||||
const resolved = await ctx.llm.resolveCallConfig({
|
||||
provider,
|
||||
model,
|
||||
...reasoningEffort === undefined
|
||||
? {}
|
||||
: { reasoningEffort: ReasoningEffortId(reasoningEffort) },
|
||||
})
|
||||
const selected: ModelSelection = {
|
||||
provider: resolved.provider,
|
||||
model: resolved.model,
|
||||
...resolved.reasoningEffort === undefined
|
||||
? {}
|
||||
: { reasoningEffort: resolved.reasoningEffort },
|
||||
}
|
||||
selectionFor(found.agent).current = selected
|
||||
// A switch is also how this deployment's default is chosen: the next
|
||||
// session created without one of its own starts here. Sessions that
|
||||
// have already logged a selection are unaffected — they derive from
|
||||
// their own log (see selectionFor).
|
||||
return serializeImageAdmission(found.agent, async () => {
|
||||
try {
|
||||
await defaults.saveDefaultModelSelection?.(selected)
|
||||
const resolved = await ctx.llm.resolveCallConfig({
|
||||
provider,
|
||||
model,
|
||||
...reasoningEffort === undefined
|
||||
? {}
|
||||
: { reasoningEffort: ReasoningEffortId(reasoningEffort) },
|
||||
})
|
||||
const pendingImage = [...found.agent.inbox.nextTurn, ...found.agent.inbox.nextStep]
|
||||
.some(message => contentHasImage(message.content))
|
||||
if (pendingImage || messagesHaveImage(found.agent.session.deriveMessages())) {
|
||||
const info = await ctx.llm.resolveModelInfo(resolved.provider, resolved.model)
|
||||
if (info.inputModalities !== undefined && !info.inputModalities.includes('image')) {
|
||||
return err(request, {
|
||||
code: 'model-unavailable',
|
||||
message: `Model "${resolved.model}" does not accept image input, but this session already contains images; select an image-capable model.`,
|
||||
details: { provider, model },
|
||||
})
|
||||
}
|
||||
}
|
||||
const selected: ModelSelection = {
|
||||
provider: resolved.provider,
|
||||
model: resolved.model,
|
||||
...resolved.reasoningEffort === undefined
|
||||
? {}
|
||||
: { reasoningEffort: resolved.reasoningEffort },
|
||||
}
|
||||
selectionFor(found.agent).current = selected
|
||||
try {
|
||||
await defaults.saveDefaultModelSelection?.(selected)
|
||||
} catch (error: unknown) {
|
||||
ctx.logger.warn(
|
||||
`api-proxy: the model switch applies to this session but was not saved as the default: ${String(error)}`,
|
||||
)
|
||||
}
|
||||
return ok(request, { selected: { ...selected } })
|
||||
} catch (error: unknown) {
|
||||
ctx.logger.warn(
|
||||
`api-proxy: the model switch applies to this session but was not saved as the default: ${String(error)}`,
|
||||
)
|
||||
return err(request, {
|
||||
code: 'model-unavailable',
|
||||
message: error instanceof Error ? error.message : String(error),
|
||||
details: { provider, model },
|
||||
})
|
||||
}
|
||||
return ok(request, { selected: { ...selected } })
|
||||
} catch (error: unknown) {
|
||||
return err(request, {
|
||||
code: 'model-unavailable',
|
||||
message: error instanceof Error ? error.message : String(error),
|
||||
details: { provider, model },
|
||||
})
|
||||
}
|
||||
})
|
||||
},
|
||||
|
||||
async rename(request) {
|
||||
@@ -2122,19 +2242,101 @@ export function createApiProxy(ctx: Context, defaults: ApiProxyDefaults): ApiPro
|
||||
const agent = resolved.agent
|
||||
// The rpcId rides MessageSource into user/message (merge declaration in api/sessions.ts; provisional correlation).
|
||||
const source: MessageSource = { kind: 'user', rpcId: request.rpcId }
|
||||
try {
|
||||
const message: UserMessage = createUserMessage({ content, source })
|
||||
if (mode === 'steer') agent.steer(message)
|
||||
else agent.followup(message)
|
||||
} catch (error: unknown) {
|
||||
// A synchronous throw from steer/followup means disposed or invalid input; surface as agent-busy with the reason attached.
|
||||
return err(request, { code: 'agent-busy', message: 'prompt rejected', details: { reason: String(error) } })
|
||||
const hasImage = content.some(part => part.type === 'image')
|
||||
const admit = async (): Promise<RpcResponse<{ accepted: true }>> => {
|
||||
try {
|
||||
if (hasImage) {
|
||||
const current = selectionFor(agent).current
|
||||
const modelInfo = await ctx.llm.resolveModelInfo(current.provider, current.model)
|
||||
if (modelInfo.inputModalities !== undefined && !modelInfo.inputModalities.includes('image')) {
|
||||
return err(request, {
|
||||
code: 'attachment-error',
|
||||
message: `Model "${current.model}" does not support image input.`,
|
||||
details: { reason: 'MODEL_DOES_NOT_SUPPORT_IMAGES' },
|
||||
})
|
||||
}
|
||||
}
|
||||
const durable = await durablePromptContent(ctx, content)
|
||||
const message: UserMessage = createUserMessage({ content: durable, source })
|
||||
if (mode === 'steer') agent.steer(message)
|
||||
else agent.followup(message)
|
||||
} catch (error: unknown) {
|
||||
if (error instanceof AttachmentError) {
|
||||
return err(request, {
|
||||
code: 'attachment-error',
|
||||
message: error.message,
|
||||
details: { reason: error.code },
|
||||
})
|
||||
}
|
||||
return err(request, {
|
||||
code: 'agent-busy',
|
||||
message: 'prompt rejected',
|
||||
details: { reason: String(error) },
|
||||
})
|
||||
}
|
||||
return ok(request, { accepted: true as const })
|
||||
}
|
||||
return hasImage ? serializeImageAdmission(agent, admit) : admit()
|
||||
},
|
||||
|
||||
async attachment(request) {
|
||||
const { sessionId, attachmentId } = request.payload
|
||||
let state: SessionReadState
|
||||
try {
|
||||
state = await readSessionState(sessionId)
|
||||
} catch (error: unknown) {
|
||||
if (error instanceof SessionNotFound) {
|
||||
return err(request, {
|
||||
code: 'session-not-found',
|
||||
message: error.message,
|
||||
details: { sessionId },
|
||||
})
|
||||
}
|
||||
return err(request, {
|
||||
code: 'internal',
|
||||
message: `attachment authorization unavailable for session "${sessionId}": ${String(error)}`,
|
||||
details: {},
|
||||
})
|
||||
}
|
||||
const ref = referencedImage(state.events, String(attachmentId))
|
||||
if (ref === undefined) {
|
||||
return err(request, {
|
||||
code: 'attachment-error',
|
||||
message: 'Image is not referenced by this session.',
|
||||
details: { reason: 'ATTACHMENT_NOT_REFERENCED' },
|
||||
})
|
||||
}
|
||||
try {
|
||||
const stored = await ctx.attachments.readImage(ref)
|
||||
return ok(request, {
|
||||
attachment: stored.ref,
|
||||
data: Buffer.from(stored.data).toString('base64'),
|
||||
})
|
||||
} catch (error: unknown) {
|
||||
if (error instanceof AttachmentError) {
|
||||
return err(request, {
|
||||
code: 'attachment-error',
|
||||
message: error.message,
|
||||
details: { reason: error.code },
|
||||
})
|
||||
}
|
||||
return err(request, {
|
||||
code: 'internal',
|
||||
message: 'Unable to read image attachment.',
|
||||
details: {},
|
||||
})
|
||||
}
|
||||
return ok(request, { accepted: true as const })
|
||||
},
|
||||
|
||||
updateQueue(request) {
|
||||
const { sessionId, itemId, action } = request.payload
|
||||
if (action.kind === 'edit' && action.content.some(block => block.type !== 'text')) {
|
||||
return Promise.resolve(err(request, {
|
||||
code: 'attachment-error',
|
||||
message: 'queue edits accept text content only',
|
||||
details: { reason: 'QUEUE_EDIT_NON_TEXT' },
|
||||
}))
|
||||
}
|
||||
const agent = ctx.agents.get(sessionId)
|
||||
if (agent !== undefined && hasSubagentOwner(agent.session, agent)) {
|
||||
return Promise.resolve(err(request, subagentOwnershipError(sessionId)))
|
||||
@@ -2354,54 +2556,12 @@ export function createApiProxy(ctx: Context, defaults: ApiProxyDefaults): ApiPro
|
||||
}))
|
||||
},
|
||||
|
||||
// Exactly one of path/name arrives (schema refine). Existing-folder
|
||||
// adoption reuses its canonical path; create-by-name rejects a name
|
||||
// already present in the registry.
|
||||
// TODO: the create-by-name branch lost its last product consumer when
|
||||
// the Web picker collapsed onto the directory flow
|
||||
// (.agents/notes/implemented/simplification/2026-07-31-one-route-to-add-a-workspace.md).
|
||||
// Delete it with the wire schema's `name` member, this
|
||||
// `defaults.workspaceRoot`, the client contract that carried the name
|
||||
// (`WorkspaceCreateInput`, `WorkspacesService.create`'s `{ name }` arm,
|
||||
// `intentName`'s name branch, the manager's "name under workspaceRoot"
|
||||
// contract), and the `dsh web --workspace-root` flag plus its apps/cli
|
||||
// README lines, which exist only to feed it.
|
||||
async create(request) {
|
||||
const { payload } = request
|
||||
let path: string
|
||||
if (payload.name !== undefined) {
|
||||
const name = payload.name.trim()
|
||||
if (name === '' || name === '.' || name === '..' || /[/\\]/.test(name)) {
|
||||
return err(request, {
|
||||
code: 'workspace-invalid-path',
|
||||
message: `workspace name must be one non-empty path segment, got "${payload.name}"`,
|
||||
details: { path: payload.name },
|
||||
})
|
||||
}
|
||||
path = join(defaults.workspaceRoot, name)
|
||||
} else {
|
||||
path = payload.path as string
|
||||
}
|
||||
const { path } = request.payload
|
||||
try {
|
||||
const name = payload.name?.trim()
|
||||
const { workspace, created } = await ensureWorkspace(
|
||||
path,
|
||||
name,
|
||||
name !== undefined,
|
||||
name !== undefined,
|
||||
)
|
||||
const { workspace, created } = await ensureWorkspace(path)
|
||||
return ok(request, { workspace: workspaceView(workspace), created })
|
||||
} catch (error: unknown) {
|
||||
if (error instanceof WorkspaceNameConflictError) {
|
||||
return err(request, {
|
||||
code: 'workspace-name-conflict',
|
||||
message: error.message,
|
||||
details: { name: error.workspaceName },
|
||||
})
|
||||
}
|
||||
if (error instanceof WorkspaceDirectoryCreationError) {
|
||||
return err(request, { code: 'internal', message: error.message, details: {} })
|
||||
}
|
||||
// The registry rejects a path that does not resolve to an existing
|
||||
// directory (realpath ENOENT / not-a-directory) — the business
|
||||
// error of the typed-path flow, surfaced as a validation failure.
|
||||
@@ -2857,7 +3017,7 @@ export function createApiProxy(ctx: Context, defaults: ApiProxyDefaults): ApiPro
|
||||
// The scope presenters resolve in — the live agent, else the recorded
|
||||
// preset's standing key, else the global layer — so a cold session's
|
||||
// '/' popup lists the catalog its composition actually serves.
|
||||
const scope = await presenterScopeFor(sessionId, session.header)
|
||||
const scope = await presenterScopeFor(sessionId, session)
|
||||
try {
|
||||
const skills = (await skillRegistry.list({ cwd, scope })).filter(isUserInvocable)
|
||||
return ok(request, {
|
||||
@@ -3222,6 +3382,17 @@ export function createApiProxy(ctx: Context, defaults: ApiProxyDefaults): ApiPro
|
||||
ctx.on('commands/change', () => {
|
||||
queue.push(frame({ type: 'host/commands-changed' }))
|
||||
}),
|
||||
// The recompose itself registers nothing (it re-parents the agent's
|
||||
// scope onto a standing mount that may already exist), so the
|
||||
// logged selection is the only commit point a client can follow.
|
||||
ctx.on('session/event', (session: Session, event: SessionEvent) => {
|
||||
if (event.type !== 'agent-preset/selected') return
|
||||
queue.push(frame({
|
||||
type: 'host/session-preset-changed',
|
||||
sessionId: session.id,
|
||||
agentPreset: event.data.agentPreset,
|
||||
}))
|
||||
}),
|
||||
ctx.on('settings/document-updated', (ns) => {
|
||||
// The RAW-section event, not the resolved one: a field going from
|
||||
// inherited to overridden leaves the resolved value equal, and a
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -16,7 +16,7 @@ import {
|
||||
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(),
|
||||
@@ -84,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') }),
|
||||
|
||||
@@ -145,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). */
|
||||
|
||||
@@ -39,7 +39,8 @@ export interface ApiProxy {
|
||||
// ---- Domain interfaces and payload entities ----
|
||||
export type {
|
||||
HistoryEntry, ModelCatalogFailure, ModelCatalogModel, ModelProviderGroup, ModelReasoning,
|
||||
ModelReasoningEffort, ModelSelection, QueueAction, SessionModels, SessionProjectionsBlock, SessionSearchItem,
|
||||
ModelReasoningEffort, ModelSelection, PromptContentPart, QueueAction, SessionModels, SessionProjectionsBlock,
|
||||
SessionSearchItem,
|
||||
SessionsApi, SessionSummary,
|
||||
} from './sessions.ts'
|
||||
export type { DirectoryEntry, DirectoryListing, HostApi } from './host.ts'
|
||||
|
||||
@@ -32,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']
|
||||
|
||||
@@ -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).
|
||||
*/
|
||||
@@ -52,6 +52,7 @@ export const rpcErrorSchema: z.ZodType<RpcError> = z.discriminatedUnion('code',
|
||||
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({}) }),
|
||||
|
||||
@@ -50,6 +50,7 @@ export interface RpcErrorDetailsMap {
|
||||
'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. */
|
||||
|
||||
@@ -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). */
|
||||
@@ -249,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. */
|
||||
export const sessionPromptRequestSchema = z.object({
|
||||
sessionId: sessionIdSchema,
|
||||
mode: z.union([z.literal('queue'), z.literal('steer')]),
|
||||
content: z.array(contentBlockSchema),
|
||||
content: z.array(promptContentPartSchema),
|
||||
}) as unknown as z.ZodType<RequestPayload<'session.prompt'>>
|
||||
|
||||
/** session.prompt response value (the command slot appears only when the prompt dispatched a slash command). */
|
||||
@@ -265,6 +281,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
|
||||
@@ -53,6 +54,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. */
|
||||
@@ -302,10 +308,14 @@ export interface SessionsApi {
|
||||
fork(request: RpcRequest<{ sessionId: SessionId; atSeq?: number }>):
|
||||
Promise<RpcResponse<{ sessionId: SessionId }>>
|
||||
|
||||
/** Sends a message to an ordinary session Agent. Session-backed subagents reject with `agent-busy` and use `subagent.prompt`. */
|
||||
prompt(request: RpcRequest<{ sessionId: SessionId; mode: 'queue' | 'steer'; content: ContentBlock[] }>):
|
||||
/** Sends text and temporary image bytes after durable host admission. Session-backed subagents reject with `agent-busy`. */
|
||||
prompt(request: RpcRequest<{ sessionId: SessionId; mode: 'queue' | 'steer'; content: PromptContentPart[] }>):
|
||||
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`.
|
||||
|
||||
@@ -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,
|
||||
@@ -94,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'>>>
|
||||
}
|
||||
@@ -180,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,
|
||||
@@ -420,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),
|
||||
}
|
||||
|
||||
@@ -16,6 +16,7 @@ import type { Wire } from '../api/rpc.schema.ts'
|
||||
import { clientRequestSchema, clientResponseSchema } from '../api/rpc.schema.ts'
|
||||
import {
|
||||
sessionCancelRequestSchema,
|
||||
sessionAttachmentRequestSchema,
|
||||
sessionCreateRequestSchema,
|
||||
sessionForkRequestSchema,
|
||||
sessionHistoryRequestSchema,
|
||||
@@ -95,6 +96,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) },
|
||||
|
||||
@@ -8,13 +8,12 @@
|
||||
* 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'
|
||||
@@ -27,17 +26,15 @@ 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 config for native Host integration. */
|
||||
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,
|
||||
@@ -51,16 +48,15 @@ export interface Config {
|
||||
/**
|
||||
* 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(),
|
||||
})
|
||||
|
||||
@@ -80,12 +76,10 @@ export class ApiProxyService extends Service implements ApiProxy {
|
||||
|
||||
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 },
|
||||
})
|
||||
this.sessions = api.sessions
|
||||
|
||||
@@ -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'
|
||||
|
||||
@@ -8,12 +8,13 @@
|
||||
import { mkdtempSync, realpathSync } from 'node:fs'
|
||||
import { tmpdir } from 'node:os'
|
||||
import { join } from 'node:path'
|
||||
import { Context } from 'cordis'
|
||||
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'
|
||||
@@ -136,7 +137,6 @@ async function harness(
|
||||
const api = createApiProxy(ctx, {
|
||||
defaultModelSelection: () => ({ provider: 'test', model: 'test-model' }),
|
||||
cwd,
|
||||
workspaceRoot: cwd,
|
||||
...options.defaults,
|
||||
})
|
||||
return { api, ctx, cwd }
|
||||
@@ -186,6 +186,29 @@ describe('session.create with an agent preset', () => {
|
||||
})
|
||||
})
|
||||
|
||||
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' }))
|
||||
@@ -331,11 +354,11 @@ describe('agentPreset.select', () => {
|
||||
})
|
||||
|
||||
it('records the switch in the log, and the list reads it back', async () => {
|
||||
const { api, ctx } = await harness(['standard', 'core-web'])
|
||||
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: 'core-web' }))
|
||||
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.
|
||||
@@ -343,22 +366,53 @@ describe('agentPreset.select', () => {
|
||||
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('core-web')
|
||||
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('core-web')
|
||||
.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', 'core-web'])
|
||||
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: 'core-web' })),
|
||||
api.agentPresets.select(request({ sessionId: SessionId('sel-race'), agentPreset: 'minimal' })),
|
||||
api.agentPresets.select(request({ sessionId: SessionId('sel-race'), agentPreset: 'standard' })),
|
||||
])
|
||||
|
||||
@@ -581,7 +635,7 @@ describe('skills over the layered host registry', () => {
|
||||
})
|
||||
|
||||
it('resolves a cold session to its recorded preset standing key', async () => {
|
||||
const { api, ctx } = await harness(['standard', 'core-web'])
|
||||
const { api, ctx } = await harness(['standard', 'minimal'])
|
||||
const seen: unknown[] = []
|
||||
ctx.provide('skills', {
|
||||
list: (options: { scope?: unknown }) => {
|
||||
@@ -589,12 +643,12 @@ describe('skills over the layered host registry', () => {
|
||||
return Promise.resolve([])
|
||||
},
|
||||
} as never)
|
||||
ctx.sessions.create(SessionId('h2'), { meta: { cwd: '/workspace/cold', agentPreset: 'core-web' } })
|
||||
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('core-web')])
|
||||
expect(seen).toEqual([standingKeys.get('minimal')])
|
||||
})
|
||||
|
||||
it('serves the global view when the roster no longer supplies the recorded preset', async () => {
|
||||
@@ -617,8 +671,8 @@ describe('skills over the layered host registry', () => {
|
||||
|
||||
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', 'core-web'])
|
||||
await api.sessions.create(request({ sessionId: SessionId('p1'), agentPreset: 'core-web' }))
|
||||
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.
|
||||
@@ -629,6 +683,27 @@ describe('session.history presenter scope', () => {
|
||||
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' }
|
||||
|
||||
@@ -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,
|
||||
@@ -476,7 +476,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)
|
||||
@@ -501,7 +501,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)
|
||||
@@ -527,7 +527,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({
|
||||
@@ -571,7 +571,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,29 @@ 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 () => {
|
||||
|
||||
@@ -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'
|
||||
@@ -95,7 +95,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 }
|
||||
}
|
||||
|
||||
@@ -8,7 +8,7 @@
|
||||
*/
|
||||
|
||||
import { describe, expect, it } 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, { SessionId } from '@deepseek-ai/dsh-session'
|
||||
@@ -65,7 +65,7 @@ async function harness(withRegistry: boolean): Promise<{ ctx: Context; session:
|
||||
return { ctx, session, agent }
|
||||
}
|
||||
|
||||
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' })
|
||||
|
||||
/** Drain the mux until `count` session/tasks frames arrived, then abort. */
|
||||
async function collect(
|
||||
|
||||
@@ -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.native(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([])
|
||||
|
||||
@@ -56,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,
|
||||
@@ -428,8 +432,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 } } }
|
||||
},
|
||||
@@ -357,6 +363,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,
|
||||
|
||||
@@ -330,11 +330,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)
|
||||
})
|
||||
|
||||
@@ -506,6 +506,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 })
|
||||
|
||||
@@ -29,6 +29,9 @@
|
||||
{
|
||||
"path": "../../util/brand"
|
||||
},
|
||||
{
|
||||
"path": "../../attachment/attachment"
|
||||
},
|
||||
{
|
||||
"path": "../../llm/llm"
|
||||
},
|
||||
|
||||
Reference in New Issue
Block a user