Merge remote-tracking branch 'origin/master' into simpl-a1-drop-image

# Conflicts:
#	docs/rfc/README.md
This commit is contained in:
Tianyi Cui
2026-07-04 22:46:55 +08:00
42 changed files with 227 additions and 516 deletions

View File

@@ -19,7 +19,7 @@ Packages are grouped by modular role at `packages/<group>/<pkg>/`. The group dir
| [`hooks/`](hooks/README.md) | Hook bridges + the shared Claude Code / Codex wire-protocol library | Product — stable surface |
| [`session-persistence/`](session-persistence/README.md) | Persistence capability family: the seam + JSONL/SQLite backends | Product — stable surface |
| [`ui/`](ui/README.md) | Editor/client integration surfaces (the ACP bridge) + the app packages | Product — stable surface |
| [`support/`](support/README.md) | Dev/test/example infrastructure (invariants, stdio UI, replay adapter) | Support — lower compatibility expectations |
| [`support/`](support/README.md) | Dev/test/example infrastructure (invariants, replay adapter, subagent mock) | Support — lower compatibility expectations |
| [`util/`](util/README.md) | Low-level zero-dependency utilities shared across groups (the `Branded<B>` primitive) | Support — small, stable, harness-dep-free |
The split is the point: a package's group says whether it is part of the product API or support/test/example infrastructure, so release and removal decisions do not treat every package as an equal public contract. New packages join an existing group; adding a new top-level group is a deliberate act (extend the group READMEs and this table).

View File

@@ -5,8 +5,7 @@ Packages that exist to serve development, testing, and the examples rather than
| Package | Role | ctx key |
|---|---|---|
| `invariants/` | Dev-mode event-contract invariants + session-log freeze | (listens on `session/*`, `agent/*`) |
| `ui-stdio/` | Minimal stdio (readline) UI plugin: renders `agent/*` events, feeds stdin lines to the agent | (drives `ctx.agents`) |
| `llm-replay/` | Record/replay adapter: short-circuits `llm/stream` from a recorded session JSONL (keyless snapshot tests) | (listens on `llm/stream`) |
| `subagent-mock/` | Scripted `SubagentProvider` for deterministic seam/tool tests | (registers on `ctx.subagents`) |
`invariants` runs only in dev mode (contract checks, not runtime behavior). `ui-stdio` and `llm-replay` were extracted from the examples for reuse and to bring them under the per-file coverage gate; they back the demos and the snapshot test tier. `subagent-mock` exercises the real `ctx.subagents` load path without a model or child agent. A package graduates OUT of `support/` into a product group only when it gains documented product consumers.
`invariants` runs only in dev mode (contract checks, not runtime behavior). `llm-replay` backs the demos and the snapshot test tier under the per-file coverage gate. `subagent-mock` exercises the real `ctx.subagents` load path without a model or child agent. A package graduates OUT of `support/` into a product group only when it gains documented product consumers.

View File

@@ -1,44 +0,0 @@
# @deepseek-ai/dsh-ui-stdio
A minimal stdio (readline) UI, as a plugin. It reads lines from stdin and feeds them to an agent (`send` when idle, `steer` while a turn is running), and renders that agent's streamed output and tool activity to stdout. A UI is "just a plugin" here — it consumes the `session/event` transcript feed plus a few `agent/*` control events (`agent/status`, `agent/created`/`agent/disposed`) and the `agents` service (`inject: ['agents']`), so the same plugin drives any example or product surface.
This is a **convenience REPL for local testing and the demos, not a product surface** — its observable behavior is free to change. It is deliberately NOT treated as a load-bearing consumer when weighing whether a live event/API must exist: the boundary mirror events were removed precisely because "ui-stdio renders from them" is not a product constraint (it was migrated to `session/event`). The real product surfaces are the ACP bridge (`dsh-acp`) and the app packages.
This package consolidates what were two near-identical copies under `examples/echo-agent` and `examples/coding-agent`. The coding copy was a superset; this package IS that superset — dimmed chain-of-thought rendering plus robust piped-stdin EOF handling — with the per-consumer differences moved into `Config`.
## Config
| Key | Type | Default | Notes |
|---|---|---|---|
| `welcome` | string | `'ready.'` | Banner printed once on start, before the first `> ` prompt. |
| `agent` | string | `'main'` | Id of the agent that stdin **drives** (`send`/`steer`) and whose `agent/status` gates the EOF exit. Rendering is **not** scoped by it — see below. |
```yaml
- id: ui-stdio
name: '@deepseek-ai/dsh-ui-stdio'
config:
welcome: 'agent REPL ready. Give it a coding task.'
```
## Rendering
Rendering is **global** — every agent's events are written to stdout, not just `config.agent`'s. `config.agent` scopes only *input* (which agent stdin drives) and the EOF-exit gate; the single-agent demos this serves have just one agent, so the distinction is moot for them. (A multi-agent UI that needs per-agent panes would filter these handlers by the agent argument — deliberately out of scope here.)
- `session/event` — the durable transcript feed drives ALL rendering, from a single listener so `inReasoning` transitions stay deterministic in append order: `assistant/chunk` writes the model's `text-delta` verbatim and wraps `reasoning-delta` in the dim SGR (`\x1B[2m … \x1B[0m`) so the chain-of-thought is visually subordinate to the answer (inert when no `reasoning-delta` chunks arrive, e.g. a mock model); `turn/start` prints a `[<agent> turn N]` header (the short agent label comes from a session-id→agent-id map seeded from `ctx.agents.list()` at install and kept live via `agent/created`/`agent/disposed`, since the turn event carries only the turn number); `turn/end` prints the trailing `> ` prompt; `tool/call` renders `[tool call] name(args)`; `tool/result` renders the joined text blocks as `[tool result] …`; and `todo/write` renders a glyphed checklist.
## The I/O seam
The production entry point `apply(ctx, config)` binds the real `process` streams. The testable core is `createStdioChat(ctx, config, runtime)`, where `runtime: StdioRuntime` supplies `input` / `output` / `exit`. This seam is deliberately **not** part of the serializable `Config` (streams and functions do not belong in YAML config); it exists so the render, EOF, and disposal branches can be exercised with fakes instead of hijacking globals.
## Piped-stdin exit
On stdin EOF the plugin exits the process, but carefully:
- **No work submitted** (empty stdin, blank-only lines): exit immediately — no turn will ever start, so there is nothing to wait for. Gating on an observed `running` here would hang forever.
- **Work submitted**: exit the next time the agent settles to `idle` *after* having been observed `running`. `agent.send()` does not synchronously flip status to `running`, so requiring an observed `running` first (`sawRunning`) avoids exiting in the gap before the turn starts and dropping work; and the loop batches several queued messages into one turn, so the exit keys off the idle transition rather than counting sends.
Disposal (HMR or fiber teardown) closes the readline interface, which also fires `close` — a `disposed` guard ensures teardown never calls `process.exit`.
## Plugin export shape
Named `name` / `inject` / `Config` / `apply`, with **no default export**: the cordis Loader's `unwrapExports` does `exports.default ?? exports`, so a stray default would collapse the module to the bare function and drop the `inject` namespace (see [docs/postmortem/0001](../../../docs/postmortem/0001-acp-default-export-drops-inject.md)). The keyless Loader-path e2e smokes in `examples/{echo,coding}-agent` guard this end-to-end.

View File

@@ -1,39 +0,0 @@
{
"name": "@deepseek-ai/dsh-ui-stdio",
"description": "Minimal stdio (readline) UI plugin: renders agent/* events to stdout and feeds stdin lines to the agent",
"version": "0.0.1",
"private": true,
"type": "module",
"main": "lib/index.js",
"types": "lib/types/index.d.ts",
"exports": {
".": {
"types": "./lib/types/index.d.ts",
"default": "./lib/index.js"
},
"./src/*": "./src/*",
"./package.json": "./package.json"
},
"files": [
"lib/index.js",
"lib/types/**/*.d.ts",
"lib/types/**/*.d.ts.map",
"src"
],
"license": "BSD-3-Clause",
"peerDependencies": {
"@deepseek-ai/dsh-agent": "^0.0.1",
"@deepseek-ai/dsh-llm": "^0.0.1",
"@deepseek-ai/dsh-session": "^0.0.1",
"cordis": "^4.0.0-rc.6"
},
"dependencies": {
"schemastery": "^3.18.0"
},
"devDependencies": {
"@deepseek-ai/dsh-agent": "workspace:^",
"@deepseek-ai/dsh-llm": "workspace:^",
"@deepseek-ai/dsh-session": "workspace:^",
"cordis": "^4.0.0-rc.6"
}
}

View File

@@ -1,30 +0,0 @@
{
"extends": "../../../tsconfig.base.json",
"compilerOptions": {
"rootDir": "src",
"outDir": "lib/types"
},
"include": [
"src"
],
"references": [
{
"path": "../../../vendor/cosmokit"
},
{
"path": "../../../vendor/cordis"
},
{
"path": "../../../vendor/schemastery"
},
{
"path": "../../core/agent"
},
{
"path": "../../llm/llm"
},
{
"path": "../../core/session"
}
]
}

View File

@@ -6,4 +6,4 @@ The model-facing todo tool. A single **product** package — there is no interfa
|---|---|---|
| `tool-todo/` | Model-facing `todo_write` tool; writes the whole list to the session log (`todo/write`) | (registers on `ctx.tools`) |
The list lives on the event-sourced session log (`SessionEventMap['todo/write']`, owned by [`dsh-session`](../core/session)); this package is the thin consumer that appends the snapshot. UIs render off `session/event`: the [stdio UI](../support/ui-stdio) prints the list, the [ACP bridge](../ui/acp) maps it to a `plan` sessionUpdate.
The list lives on the event-sourced session log (`SessionEventMap['todo/write']`, owned by [`dsh-session`](../core/session)); this package is the thin consumer that appends the snapshot. UIs render off `session/event`: the [stdio app's readline UI](../ui/stdio-agent) prints the list, the [ACP bridge](../ui/acp) maps it to a `plan` sessionUpdate.

View File

@@ -18,7 +18,7 @@ Beyond the schema's type/required/enum checks, `execute` rejects an empty or dup
## Rendering
The tool writes only the session event; it does not render. UIs subscribe to `session/event` and render the `todo/write` data themselves: the [stdio UI](../../support/ui-stdio) prints a glyphed checklist, and the [ACP bridge](../../ui/acp) maps the list to a `plan` sessionUpdate (synthesizing the `priority` ACP requires).
The tool writes only the session event; it does not render. UIs subscribe to `session/event` and render the `todo/write` data themselves: the [stdio app's readline UI](../../ui/stdio-agent) prints a glyphed checklist, and the [ACP bridge](../../ui/acp) maps the list to a `plan` sessionUpdate (synthesizing the `priority` ACP requires).
## Export shape

View File

@@ -8,6 +8,6 @@ Integrations that expose the agent to an external editor or client. These are **
| `stdio-agent/` | Terminal stdio chat APP: the agent-core spine + console logger + readline UI + a pre-created `main` agent, with a `bin` | (composition + `bin`) |
| `acp-agent/` | ACP server APP: the agent-core spine + JSONL persistence + the `acp` bridge (no stdout logger), with a `bin` | (composition + `bin`) |
A UI integration is a client-driver plugin, not a loop change and not a capability seam: it consumes the existing `agent/*` event taxonomy and the `dsh-agent` factory. The readline `ui-stdio` plugin is the unstructured analogue but lives in `support/` because it exists chiefly for the examples and the coverage gate — `ui/` is reserved for surfaces shipped as product.
A UI integration is a client-driver plugin, not a loop change and not a capability seam: it consumes the existing `agent/*` event taxonomy and the `dsh-agent` factory. The readline UI is the unstructured analogue of the `acp` bridge and lives INSIDE the stdio app (the `stdio-chat` module of [`stdio-agent/`](stdio-agent/README.md)): it is scaffolding for that one front door, not an independently swappable integration, so it carries no package boundary of its own.
`stdio-agent` and `acp-agent` are the two **app packages**: each composes the [`core/agent-core`](../core/agent-core/README.md) spine with its coupled front-door cluster (and owns the boot `bin`), so a leaf `cordis.yml` is the swappable backends plus one app entry plus any optional product tools. They live in `ui/` because each IS a user-facing front door; the stdout-purity coupling (logger vs. no logger) becomes a property of the artifact rather than a leaf convention.

View File

@@ -13,7 +13,7 @@ A terminal chat always wants the same cluster, so the package owns it rather tha
| `@cordisjs/plugin-logger-console` | the console logger — stdout is just the terminal here, so logging to it is correct (the ACP app must NOT have this) |
| `@deepseek-ai/dsh-agent-core` | the spine, pre-creating a `main` agent from this app's `model`/`systemPrompt` |
| `@deepseek-ai/dsh-session-persistence-jsonl` | durable JSONL session log under `persistenceRoot` |
| `@deepseek-ai/dsh-ui-stdio` | the readline UI, bound to the `main` agent |
| `stdio-chat` (in-package module) | the readline UI, bound to the `main` agent |
`@cordisjs/plugin-hmr` (the dev/demo edit-reload loop) is deliberately a **leaf** entry, NOT baked in here: it is a Loader-only, subprocess-only dev plugin — its constructor throws without `node --expose-internals` + a live `loader`, and the in-process test tier cannot even import it (so a package whose `apply` statically pulled it in could never carry the per-file coverage gate). Unlike the console logger, a stray `hmr` is not a stdout-purity footgun, so leaving it at the leaf costs no safety. The `demo:echo` / `demo:repl` leaves load it and pass `--expose-internals`.

View File

@@ -34,10 +34,10 @@
"@cordisjs/plugin-loader": "^1.0.0-rc.4",
"@cordisjs/plugin-logger-console": "^1.0.0",
"@deepseek-ai/dsh-agent": "^0.0.1",
"@deepseek-ai/dsh-llm": "^0.0.1",
"@deepseek-ai/dsh-agent-core": "^0.0.1",
"@deepseek-ai/dsh-session": "^0.0.1",
"@deepseek-ai/dsh-session-persistence-jsonl": "^0.0.1",
"@deepseek-ai/dsh-ui-stdio": "^0.0.1",
"cordis": "^4.0.0-rc.6",
"schemastery": "^3.17.0"
},
@@ -46,10 +46,10 @@
"@cordisjs/plugin-loader": "workspace:^",
"@cordisjs/plugin-logger-console": "workspace:^",
"@deepseek-ai/dsh-agent": "workspace:^",
"@deepseek-ai/dsh-llm": "workspace:^",
"@deepseek-ai/dsh-agent-core": "workspace:^",
"@deepseek-ai/dsh-session": "workspace:^",
"@deepseek-ai/dsh-session-persistence-jsonl": "workspace:^",
"@deepseek-ai/dsh-ui-stdio": "workspace:^",
"cordis": "^4.0.0-rc.6",
"schemastery": "^3.17.0"
}

View File

@@ -1,12 +1,13 @@
/**
* The stdio chat app: the providerless agent spine ({@link
* @deepseek-ai/dsh-agent-core}) plus the coupled front-door cluster a terminal
* chat needs — a console logger, the readline `ui-stdio` UI, JSONL session
* chat needs — a console logger, the readline UI (the in-package `stdio-chat`
* module), JSONL session
* persistence, and a pre-created `main` agent the UI drives.
*
* The cluster is BAKED IN, not left to the leaf: a stdio app always logs to the
* console (stdout is just the terminal) and always pre-creates the `main` agent
* `ui-stdio` sends to. The leaf supplies the swappable backends (the LLM
* the readline UI sends to. The leaf supplies the swappable backends (the LLM
* adapter, the bash executor), optional product tools, the optional `hmr`
* dev-reload plugin, and this app's {@link Config} (model, prompt, persistence
* root, welcome banner).
@@ -29,8 +30,10 @@
* Plugin export shape: named `name`/`Config`/`apply`, NO default export — the
* cordis Loader's `unwrapExports` does `exports.default ?? exports`, so a stray
* default would collapse the module to the bare `apply` and drop the `Config`
* namespace (see docs/postmortem/0001). The keyless Loader-path smoke in the
* echo example guards this end-to-end.
* namespace (see docs/postmortem/0001). This app carries no `inject`, so a
* collapsed shape would BOOT rather than crash a smoke — the shape is pinned by
* the explicit `unwrapExports` assertion in this package's unit suite, and the
* keyless echo smoke proves the composed tree runs through the real Loader.
*
* @module @deepseek-ai/dsh-stdio-agent
*/
@@ -42,7 +45,7 @@ import { AgentId } from '@deepseek-ai/dsh-agent'
import { SessionId } from '@deepseek-ai/dsh-session'
import * as agentCore from '@deepseek-ai/dsh-agent-core'
import SessionPersistenceJsonl from '@deepseek-ai/dsh-session-persistence-jsonl'
import * as uiStdio from '@deepseek-ai/dsh-ui-stdio'
import * as uiStdio from './stdio-chat.ts'
export const name = 'stdio-agent'
@@ -81,7 +84,7 @@ export const Config: z<Config> = z.object({
* Compose the spine with the stdio front door. The console logger comes first
* (infra), then the agent-core bundle pre-creating the `main` agent from this
* app's `model`/`systemPrompt`/`resumeSessionId`, then the JSONL backend, then
* the `ui-stdio` UI bound to `main`. The `hmr` dev-reload plugin is a leaf
* the readline UI bound to `main`. The `hmr` dev-reload plugin is a leaf
* concern (see the module doc), so it is not mounted here.
*/
export function apply(ctx: Context, config: Config): void {

View File

@@ -1,23 +1,18 @@
/**
* Minimal stdio UI plugin: reads lines from stdin `agent.send()`/`steer()`,
* and renders the durable transcript to stdout. A UI is "just a plugin" it
* consumes the `session/event` feed (the assistant token stream, turn/step
* boundaries, tool activity, todos) plus a few `agent/*` control events
* (`agent/status`, `agent/created`/`agent/disposed`) and the `agents` service,
* so the same plugin drives any example or product surface.
* The stdio app's readline UI: reads lines from stdin `agent.send()`/
* `steer()`, and renders the durable transcript to stdout. A UI is "just a
* plugin" it consumes the `session/event` feed (the assistant token stream,
* turn/step boundaries, tool activity, todos) plus a few `agent/*` control
* events (`agent/status`, `agent/created`/`agent/disposed`) and the `agents`
* service. Dimmed chain-of-thought rendering plus robust piped-stdin EOFidle
* exit handling, configured via {@link Config}.
*
* Consolidates what were two near-identical copies under `examples/echo-agent`
* and `examples/coding-agent` (the latter a superset). This package IS that
* superset: dimmed chain-of-thought rendering plus the robust piped-stdin
* EOFidle exit handling, configured per consumer via {@link Config}.
* An internal module of the stdio app, not a package of its own: the app's
* front-door cluster always includes this UI, and nothing else composes it.
* The export shape stays named `name`/`inject`/`Config`/`apply` the plugin
* contract the app's `ctx.plugin(uiStdio, …)` mount consumes.
*
* Plugin export shape: named `name`/`inject`/`Config`/`apply`, NO default
* export the cordis Loader's `unwrapExports` does `exports.default ?? exports`,
* so a stray default would collapse the module to the bare function and drop
* the `inject` namespace (see docs/postmortem/0001). The keyless Loader-path
* e2e smokes in `examples/{echo,coding}-agent` guard this end-to-end.
*
* @module @deepseek-ai/dsh-ui-stdio
* @module @deepseek-ai/dsh-stdio-agent/stdio-chat
*/
import { createInterface } from 'node:readline'

View File

@@ -35,7 +35,7 @@ const stdioBin = join(repoRoot, 'packages/ui/stdio-agent/lib/bin.js')
const dshPackages = [
'core/agent-core', 'core/agent', 'core/session', 'core/system-prompt',
'core/tools', 'core/agent-loop', 'llm/llm', 'bash/bash', 'bash/bash-local',
'bash/tool-bash', 'support/invariants', 'support/ui-stdio',
'bash/tool-bash', 'support/invariants',
'session-persistence/session-persistence',
'session-persistence/session-persistence-jsonl', 'ui/stdio-agent',
]

View File

@@ -2,7 +2,7 @@ import { EventEmitter } from 'node:events'
import type { Readable, Writable } from 'node:stream'
import { describe, expect, it, vi } from 'vitest'
import type { Context } from 'cordis'
import type { StdioRuntime } from '../src/index.ts'
import type { StdioRuntime } from '../src/stdio-chat.ts'
const createInterface = vi.hoisted(() => vi.fn(() => {
const reader = new EventEmitter() as EventEmitter & { close(): void }
@@ -32,7 +32,7 @@ function fakeRuntime(inputIsTTY: boolean, outputIsTTY: boolean): StdioRuntime {
describe('createStdioChat readline mode', () => {
it('enables terminal editing only when both stdio streams are TTYs', async () => {
const { createStdioChat } = await import('../src/index.ts')
const { createStdioChat } = await import('../src/stdio-chat.ts')
const tty = fakeRuntime(true, true)
createStdioChat(fakeContext(), {}, tty)

View File

@@ -12,9 +12,11 @@ import * as stdioAgent from '../src/index.ts'
* agent; `persistenceRoot`/`welcome`/`resumeSessionId` route to their backends.
*
* `hmr` is NOT part of this plugin (it is a leaf entry — a Loader-only dev
* plugin the in-process tier cannot import); the REAL Loader-path guard (export
* shape, `unwrapExports`, the whole subprocess tree incl. `hmr`) is the keyless
* echo smoke in `examples/echo-agent`. Here we assert the composition + config
* plugin the in-process tier cannot import); the keyless echo smoke in
* `examples/echo-agent` proves the whole subprocess tree (incl. `hmr`) boots
* through the real Loader, while the export SHAPE is pinned by this suite's
* explicit `unwrapExports` assertion (an inject-less app would boot past a
* stray default rather than crash). Here we assert the composition + config
* forwarding the unit tier can reach.
*/
async function mount(config: stdioAgent.Config): Promise<Context> {

View File

@@ -5,7 +5,7 @@ import type { Agent, AgentStatus } from '@deepseek-ai/dsh-agent'
import AgentRegistry from '@deepseek-ai/dsh-agent'
import type { ContentBlock, StreamChunk } from '@deepseek-ai/dsh-llm'
import type { Session, SessionEvent } from '@deepseek-ai/dsh-session'
import { createStdioChat, type Config, type StdioRuntime } from '../src/index.ts'
import { createStdioChat, type Config, type StdioRuntime } from '../src/stdio-chat.ts'
/**
* Unit tests for the stdio UI plugin. They drive the REAL plugin body

View File

@@ -31,9 +31,6 @@
},
{
"path": "../../session-persistence/session-persistence-jsonl"
},
{
"path": "../../support/ui-stdio"
}
]
}

View File

@@ -28,4 +28,4 @@ Each tool is registered independently; a product that wants only one disables th
Tool registration follows product **enablement**, not backend availability. A tool stays visible even when its selected provider is missing, misconfigured, ambiguous, or temporarily unavailable; the seam resolves the provider at execution time and execution fails with a structured `WebError` (e.g. `WEB_PROVIDER_UNAVAILABLE`, `WEB_PROVIDER_AMBIGUOUS`), which `ToolRegistry.execute()` turns into an error tool result the model can read and hooks/UI can route on. This keeps the model schema stable without making plugin load order, credential state, or HMR timing part of the model-facing contract. To remove a web tool entirely, disable it here in config.
The tool reads only the aggregated `ctx.web.searchStatus()` / `fetchStatus()` for diagnostics — never each provider's `status()` directly — so provider selection has one owner.
The tool never calls a provider's `status()` and never enumerates providers — its only execution path is `ctx.web.search()` / `ctx.web.fetch()`, and provider unavailability reaches it as the structured `WebError` codes selection throws at execution time. Provider selection stays entirely inside the seam, with one owner.

View File

@@ -188,9 +188,12 @@ describe('tool-web registration', () => {
})
it('registers web_search even when no provider is available (schema follows enablement, not availability)', async () => {
const { fiber, ctx } = await mountTools()
const { fiber, ctx, call } = await mountTools()
expect(ctx.tools.schemas().map(s => s.name)).toContain('web_search')
expect(ctx.web.searchStatus()).toEqual({ available: false, reason: 'none' })
// No provider is registered: the schema stays visible and execution reports
// the structured unavailability instead.
const out = await call('web_search', { query: 'q' })
expect(out.error?.code).toBe('WEB_PROVIDER_UNAVAILABLE')
await fiber.dispose()
})

View File

@@ -377,9 +377,11 @@ describe('web-fetch-local plugin registration', () => {
const ctx = new Context()
await ctx.plugin(WebService, { fetchProvider: LOCAL_FETCH_PROVIDER_ID })
const fiber = await ctx.plugin(fetchPlugin, {})
expect(ctx.web.fetchStatus()).toEqual({ available: true, providerId: LOCAL_FETCH_PROVIDER_ID })
await expect(ctx.web.fetch({ url: `${base}/` }))
.resolves.toMatchObject({ providerId: LOCAL_FETCH_PROVIDER_ID, statusCode: 200 })
await fiber.dispose()
expect(ctx.web.fetchStatus()).toEqual({ available: false, reason: 'configured-missing' })
await expect(ctx.web.fetch({ url: `${base}/` }))
.rejects.toThrow(expect.objectContaining({ code: 'WEB_PROVIDER_CONFIGURED_MISSING' }))
})
it('has no default export (namespace plugin export shape)', () => {
@@ -418,7 +420,8 @@ describe('web-fetch-local plugin registration', () => {
const ctx = new Context()
await ctx.plugin(WebService, { fetchProvider: LOCAL_FETCH_PROVIDER_ID })
const fiber = await ctx.plugin(fetchPlugin, { maxRedirects: 0 })
expect(ctx.web.fetchStatus()).toEqual({ available: true, providerId: LOCAL_FETCH_PROVIDER_ID })
await expect(ctx.web.fetch({ url: `${base}/` }))
.resolves.toMatchObject({ providerId: LOCAL_FETCH_PROVIDER_ID, statusCode: 200 })
await fiber.dispose()
})
})

View File

@@ -264,12 +264,14 @@ describe('DeepSeekSearchProvider error handling', () => {
describe('web-search-deepseek plugin registration', () => {
it('registers the provider into ctx.web (HMR-safe)', async () => {
vi.stubGlobal('fetch', vi.fn(async () => jsonResponse(searchResponse())))
const ctx = new Context()
await ctx.plugin(WebService, { searchProvider: DEEPSEEK_PROVIDER_ID })
const fiber = await ctx.plugin(deepseekPlugin, { apiKey: 'ds-key' })
expect(ctx.web.searchStatus()).toEqual({ available: true, providerId: DEEPSEEK_PROVIDER_ID })
await expect(ctx.web.search({ query: 'q' })).resolves.toMatchObject({ providerId: DEEPSEEK_PROVIDER_ID })
await fiber.dispose()
expect(ctx.web.searchStatus()).toEqual({ available: false, reason: 'configured-missing' })
await expect(ctx.web.search({ query: 'q' }))
.rejects.toThrow(expect.objectContaining({ code: 'WEB_PROVIDER_CONFIGURED_MISSING' }))
})
it('rejects maxTokens: 0 at plugin construction', async () => {
@@ -315,13 +317,14 @@ describe('web-search-deepseek plugin registration', () => {
})
it('boots over ctx.web through the unwrapped module without an inject error', async () => {
vi.stubGlobal('fetch', vi.fn(async () => jsonResponse(searchResponse())))
const ctx = new Context()
await ctx.plugin(WebService, { searchProvider: DEEPSEEK_PROVIDER_ID })
const loader = Object.create(Loader.prototype) as Loader
const unwrapped = loader.unwrapExports(deepseekPlugin) as Parameters<Context['plugin']>[0]
// A collapsed export shape (dropped inject) would throw "without inject" here.
const fiber = await ctx.plugin(unwrapped, { apiKey: 'ds-key' })
expect(ctx.web.searchStatus()).toEqual({ available: true, providerId: DEEPSEEK_PROVIDER_ID })
await expect(ctx.web.search({ query: 'q' })).resolves.toMatchObject({ providerId: DEEPSEEK_PROVIDER_ID })
await fiber.dispose()
})
@@ -334,7 +337,6 @@ describe('web-search-deepseek plugin registration', () => {
const ctx = new Context()
await ctx.plugin(WebService, { searchProvider: DEEPSEEK_PROVIDER_ID })
const fiber = await ctx.plugin(deepseekPlugin, {})
expect(ctx.web.searchStatus()).toEqual({ available: true, providerId: DEEPSEEK_PROVIDER_ID })
await ctx.web.search({ query: 'q' })
const [url, init] = fetchMock.mock.calls[0] as unknown as [string, RequestInit]
expect(url).toBe('https://api.deepseek.com/anthropic/v1/messages')
@@ -354,7 +356,8 @@ describe('web-search-deepseek plugin registration', () => {
const ctx = new Context()
await ctx.plugin(WebService, { searchProvider: DEEPSEEK_PROVIDER_ID })
await ctx.plugin(deepseekPlugin, {})
expect(ctx.web.searchStatus()).toEqual({ available: false, reason: 'configured-unavailable' })
await expect(ctx.web.search({ query: 'q' }))
.rejects.toThrow(expect.objectContaining({ code: 'WEB_PROVIDER_CONFIGURED_UNAVAILABLE' }))
} finally {
if (prev !== undefined) process.env.DEEPSEEK_API_KEY = prev
}

View File

@@ -205,12 +205,14 @@ describe('ExaSearchProvider error handling', () => {
describe('web-search-exa plugin registration', () => {
it('registers the provider into ctx.web (HMR-safe)', async () => {
vi.stubGlobal('fetch', vi.fn(async () => jsonResponse({ results: [] })))
const ctx = new Context()
await ctx.plugin(WebService, { searchProvider: EXA_PROVIDER_ID })
const fiber = await ctx.plugin(exaPlugin, { apiKey: 'exa-key' })
expect(ctx.web.searchStatus()).toEqual({ available: true, providerId: EXA_PROVIDER_ID })
await expect(ctx.web.search({ query: 'q' })).resolves.toMatchObject({ providerId: EXA_PROVIDER_ID })
await fiber.dispose()
expect(ctx.web.searchStatus()).toEqual({ available: false, reason: 'configured-missing' })
await expect(ctx.web.search({ query: 'q' }))
.rejects.toThrow(expect.objectContaining({ code: 'WEB_PROVIDER_CONFIGURED_MISSING' }))
})
it('has no default export (namespace plugin export shape)', () => {
@@ -238,7 +240,6 @@ describe('web-search-exa plugin registration', () => {
const ctx = new Context()
await ctx.plugin(WebService, { searchProvider: EXA_PROVIDER_ID })
const fiber = await ctx.plugin(exaPlugin, {})
expect(ctx.web.searchStatus()).toEqual({ available: true, providerId: EXA_PROVIDER_ID })
await ctx.web.search({ query: 'q' })
const [url] = fetchMock.mock.calls[0] as unknown as [string]
expect(url).toBe('https://api.exa.ai/search')
@@ -256,7 +257,8 @@ describe('web-search-exa plugin registration', () => {
const ctx = new Context()
await ctx.plugin(WebService, { searchProvider: EXA_PROVIDER_ID })
await ctx.plugin(exaPlugin, {})
expect(ctx.web.searchStatus()).toEqual({ available: false, reason: 'configured-unavailable' })
await expect(ctx.web.search({ query: 'q' }))
.rejects.toThrow(expect.objectContaining({ code: 'WEB_PROVIDER_CONFIGURED_UNAVAILABLE' }))
} finally {
if (prev !== undefined) process.env.EXA_API_KEY = prev
}

View File

@@ -186,12 +186,14 @@ describe('PerplexitySearchProvider error handling', () => {
describe('web-search-perplexity plugin registration', () => {
it('registers the provider into ctx.web (HMR-safe)', async () => {
vi.stubGlobal('fetch', vi.fn(async () => jsonResponse({ choices: [{ message: { content: 'a' } }], citations: [] })))
const ctx = new Context()
await ctx.plugin(WebService, { searchProvider: PERPLEXITY_PROVIDER_ID })
const fiber = await ctx.plugin(perplexityPlugin, { apiKey: 'pplx-key' })
expect(ctx.web.searchStatus()).toEqual({ available: true, providerId: PERPLEXITY_PROVIDER_ID })
await expect(ctx.web.search({ query: 'q' })).resolves.toMatchObject({ providerId: PERPLEXITY_PROVIDER_ID })
await fiber.dispose()
expect(ctx.web.searchStatus()).toEqual({ available: false, reason: 'configured-missing' })
await expect(ctx.web.search({ query: 'q' }))
.rejects.toThrow(expect.objectContaining({ code: 'WEB_PROVIDER_CONFIGURED_MISSING' }))
})
it('has no default export (namespace plugin export shape)', () => {
@@ -219,7 +221,6 @@ describe('web-search-perplexity plugin registration', () => {
const ctx = new Context()
await ctx.plugin(WebService, { searchProvider: PERPLEXITY_PROVIDER_ID })
const fiber = await ctx.plugin(perplexityPlugin, {})
expect(ctx.web.searchStatus()).toEqual({ available: true, providerId: PERPLEXITY_PROVIDER_ID })
await ctx.web.search({ query: 'q' })
const [url, init] = fetchMock.mock.calls[0] as unknown as [string, RequestInit]
expect(url).toBe('https://api.perplexity.ai/chat/completions')
@@ -238,7 +239,8 @@ describe('web-search-perplexity plugin registration', () => {
const ctx = new Context()
await ctx.plugin(WebService, { searchProvider: PERPLEXITY_PROVIDER_ID })
await ctx.plugin(perplexityPlugin, {})
expect(ctx.web.searchStatus()).toEqual({ available: false, reason: 'configured-unavailable' })
await expect(ctx.web.search({ query: 'q' }))
.rejects.toThrow(expect.objectContaining({ code: 'WEB_PROVIDER_CONFIGURED_UNAVAILABLE' }))
} finally {
if (prev !== undefined) process.env.PERPLEXITY_API_KEY = prev
}

View File

@@ -18,8 +18,7 @@ Search and fetch share no request schema and no business logic, but they are del
| Member | Semantics |
|---|---|
| `registerSearchProvider(provider)` / `registerFetchProvider(provider)` | Register a backend. Throws `WebError` `WEB_DUPLICATE_PROVIDER` on a duplicate id within that capability kind. Returns a disposer; emits `web/providers-change` on register and on dispose. Disposed with the calling fiber. |
| `searchStatus()` / `fetchStatus()` | Derived (never stored) `WebCapabilityStatus`: whether the capability has a selected usable provider, or the broad category it fails in. Diagnostics + execution-resolution input. |
| `registerSearchProvider(provider)` / `registerFetchProvider(provider)` | Register a backend. Throws `WebError` `WEB_DUPLICATE_PROVIDER` on a duplicate id within that capability kind. Returns a disposer. Disposed with the calling fiber. |
| `search(request, exec?)` | Resolve the search provider and run one search. Enforces `request.maxResults` on the result (truncates `sources[]`, sets `truncated`). Throws `WebError` when the capability cannot run. |
| `fetch(request, exec?)` | Resolve the fetch provider and retrieve one URL. A non-2xx response is a result, not a throw. Throws `WebError` for failures to safely retrieve or represent the resource. |
@@ -27,18 +26,18 @@ Providers register **capabilities**, not tools. `dsh-tool-web` is the only owner
## Selection
Selection never depends on registration, config, or HMR order. A capability has an explicit provider id (config `searchProvider`/`fetchProvider`, or env `$DSH_WEB_SEARCH_PROVIDER`/`$DSH_WEB_FETCH_PROVIDER` feeding the same fields), or auto-selects when exactly one usable provider is registered:
Selection never depends on registration, config, or HMR order. A capability has an explicit provider id (config `searchProvider`/`fetchProvider`, or env `$DSH_WEB_SEARCH_PROVIDER`/`$DSH_WEB_FETCH_PROVIDER` feeding the same fields), or auto-selects when exactly one usable provider is registered. `search()`/`fetch()` resolve the provider at execution time:
| Situation | `WebCapabilityStatus` | Execution |
|---|---|---|
| configured id registered and `status().available` | `available` for it | runs |
| configured id not registered | `configured-missing` | `WEB_PROVIDER_CONFIGURED_MISSING` |
| configured id registered but unavailable | `configured-unavailable` | `WEB_PROVIDER_CONFIGURED_UNAVAILABLE` |
| no id, exactly one registered usable provider | `available` for it | runs |
| no id, no usable provider | `none` | `WEB_PROVIDER_UNAVAILABLE` |
| no id, multiple usable providers | `ambiguous` | `WEB_PROVIDER_AMBIGUOUS` |
| Situation | Execution |
|---|---|
| configured id registered and `status().available` | runs that provider |
| configured id not registered | `WEB_PROVIDER_CONFIGURED_MISSING` |
| configured id registered but unavailable | `WEB_PROVIDER_CONFIGURED_UNAVAILABLE` |
| no id, exactly one registered usable provider | runs it |
| no id, no usable provider | `WEB_PROVIDER_UNAVAILABLE` |
| no id, multiple usable providers | `WEB_PROVIDER_AMBIGUOUS` |
`WebCapabilityStatus` carries only `available` + a `reason` discriminant (plus the winning `providerId` on the available branch). The branchable per-reason detail lives in the thrown `WebError`, which is the surface callers route on — so the same fact never gets two homes that can disagree. A provider's own `status()` is a cheap local check (credential presence, parseable config) and **must not make network calls**; `dsh-tool-web` reads only the aggregated `searchStatus()`/`fetchStatus()`, never each provider's `status()` directly.
The failure branches throw `WebError`, whose structured code (plus message detail — the missing id, the ambiguous candidate set) is the surface callers route on. A provider's own `status()` is a cheap local check (credential presence, parseable config) that feeds this execution-time selection and **must not make network calls**; `dsh-tool-web` never calls a provider's `status()` — it executes through `ctx.web.search()`/`fetch()` and routes on the thrown codes, so provider selection has one owner.
## Vocabulary

View File

@@ -3,15 +3,14 @@
* execution surface for two capabilities — search and fetch. Provider packages
* register concrete backends with `registerSearchProvider` /
* `registerFetchProvider`; the model-facing consumer
* (`@deepseek-ai/dsh-tool-web`) reads capability status and executes through
* `search()` / `fetch()`.
* (`@deepseek-ai/dsh-tool-web`) executes through `search()` / `fetch()` and
* routes on the structured {@link WebError} codes selection throws.
*
* The registry half stays close to `LlmService`: a `Map<id, provider>` per
* capability kind, register methods that return disposers, duplicate ids that
* throw, and execution-time resolution that throws when the selected provider is
* absent or unusable. On top of that sits one small selection-status layer so
* diagnostics and execution can explain why a capability can or cannot run,
* independent of registration order.
* absent or unusable — with selection rules that never depend on registration
* order.
*
* @module @deepseek-ai/dsh-web
*/
@@ -19,7 +18,6 @@
import { Context, Service } from 'cordis'
import z from 'schemastery'
import type {
WebCapabilityStatus,
WebExecContext,
WebFetchProvider,
WebFetchRequest,
@@ -35,7 +33,6 @@ export {
WebError,
} from './types.ts'
export type {
WebCapabilityStatus,
WebExecContext,
WebFetchBody,
WebFetchProvider,
@@ -52,21 +49,9 @@ declare module 'cordis' {
interface Context {
web: WebService
}
interface Events {
/**
* Fired after the provider registry changes — a search or fetch provider was
* registered or disposed. Carries no payload and no capability graph: it
* means only "the provider registry changed; observers may recompute status
* from `ctx.web`". `searchStatus()` / `fetchStatus()` stay derived, not
* stored.
* @mode emit
*/
'web/providers-change'(this: WebService): void
}
}
/** Selection inputs shared by the status query and execution resolution. */
/** Selection inputs for execution-time provider resolution. */
interface Selection<P> {
/** The configured provider id for this capability, if any. */
readonly configuredId?: string
@@ -90,17 +75,14 @@ export interface WebServiceConfig {
/**
* The web access service. Registered as `ctx.web` (one instance per context).
*
* Selection semantics (identical for status and execution, never order-
* dependent):
* Selection semantics (resolved at execution time, never order-dependent):
* - A configured id that is registered and `status().available` → that provider.
* - A configured id not registered → `configured-missing` /
* `WEB_PROVIDER_CONFIGURED_MISSING`.
* - A configured id registered but unavailable → `configured-unavailable` /
* - A configured id not registered → `WEB_PROVIDER_CONFIGURED_MISSING`.
* - A configured id registered but unavailable →
* `WEB_PROVIDER_CONFIGURED_UNAVAILABLE`.
* - No id configured, exactly one registered usable provider → that provider.
* - No id configured, multiple usable providers → `ambiguous` /
* `WEB_PROVIDER_AMBIGUOUS`.
* - No id configured, no usable provider → `none` / `WEB_PROVIDER_UNAVAILABLE`.
* - No id configured, multiple usable providers → `WEB_PROVIDER_AMBIGUOUS`.
* - No id configured, no usable provider → `WEB_PROVIDER_UNAVAILABLE`.
*/
export class WebService extends Service {
/**
@@ -126,9 +108,8 @@ export class WebService extends Service {
/**
* Register a search provider. Throws {@link WebError} `WEB_DUPLICATE_PROVIDER`
* if its id is already registered for search. Returns a disposer; emits
* `web/providers-change` after a successful register and again on dispose.
* Disposed with the calling fiber.
* if its id is already registered for search. Returns a disposer; disposed
* with the calling fiber.
* @param provider - the provider; its `id` is the registry key.
* @returns the disposer that unregisters the provider.
*/
@@ -138,9 +119,8 @@ export class WebService extends Service {
/**
* Register a fetch provider. Throws {@link WebError} `WEB_DUPLICATE_PROVIDER`
* if its id is already registered for fetch. Returns a disposer; emits
* `web/providers-change` after a successful register and again on dispose.
* Disposed with the calling fiber.
* if its id is already registered for fetch. Returns a disposer; disposed
* with the calling fiber.
* @param provider - the provider; its `id` is the registry key.
* @returns the disposer that unregisters the provider.
*/
@@ -152,45 +132,15 @@ export class WebService extends Service {
if (store.has(provider.id)) {
throw new WebError(`a web provider with id "${provider.id}" is already registered`, 'WEB_DUPLICATE_PROVIDER')
}
const dispose = this.ctx.effect(function* (this: WebService) {
const dispose = this.ctx.effect(function* () {
store.set(provider.id, provider)
// Yield the rollback BEFORE emitting `web/providers-change`: the generator
// effect collects each yielded disposer before the next step runs, so a
// throwing change listener removes the just-added provider instead of
// leaking it into the registry.
yield () => {
store.delete(provider.id)
this.ctx.emit('web/providers-change')
}
this.ctx.emit('web/providers-change')
}.bind(this), 'web.registerProvider()')
yield () => store.delete(provider.id)
}, 'web.registerProvider()')
// ctx.effect's disposer returns Promise<void>; our disposer API is
// synchronous fire-and-forget — discard the (always-resolved) promise.
return () => void dispose()
}
/**
* Search-capability selection status, derived live (never stored).
* @returns which provider would serve a search right now, or why none would.
*/
searchStatus(): WebCapabilityStatus {
return resolveStatus({
providers: this.searchProviders,
...this.searchProviderId !== undefined ? { configuredId: this.searchProviderId } : {},
})
}
/**
* Fetch-capability selection status, derived live (never stored).
* @returns which provider would serve a fetch right now, or why none would.
*/
fetchStatus(): WebCapabilityStatus {
return resolveStatus({
providers: this.fetchProviders,
...this.fetchProviderId !== undefined ? { configuredId: this.fetchProviderId } : {},
})
}
/**
* Run one search through the selected provider. Resolves the provider at call
* time with the selection rules above; throws {@link WebError} when the
@@ -231,27 +181,7 @@ interface ResolvableProvider {
status(): WebProviderStatus
}
/** Compute the capability status from configured id + registered providers. */
function resolveStatus<P extends ResolvableProvider>(selection: Selection<P>): WebCapabilityStatus {
const { configuredId, providers } = selection
if (configuredId !== undefined) {
const provider = providers.get(configuredId)
if (!provider) return { available: false, reason: 'configured-missing' }
if (!provider.status().available) return { available: false, reason: 'configured-unavailable' }
return { available: true, providerId: configuredId }
}
const usable = [...providers.values()].filter(provider => provider.status().available)
const [single] = usable
if (single === undefined) return { available: false, reason: 'none' }
if (usable.length > 1) return { available: false, reason: 'ambiguous' }
return { available: true, providerId: single.id }
}
/**
* Resolve the selected provider or throw the matching {@link WebError}. Shares
* the selection rules with {@link resolveStatus} so status and execution can
* never disagree.
*/
/** Resolve the selected provider or throw the matching {@link WebError}. */
function resolveProvider<P extends ResolvableProvider>(selection: Selection<P>): P {
const { configuredId, providers } = selection
if (configuredId !== undefined) {

View File

@@ -1,8 +1,8 @@
/**
* Vocabulary for the web capability seam (`ctx.web`): the search/fetch
* request/result shapes providers produce and consumers format, the provider
* and capability status discriminants selection reports, the execution-control
* context, and the typed error taxonomy.
* status discriminant selection reads, the execution-control context, and the
* typed error taxonomy.
*
* These types are shared by every provider backend
* (`@deepseek-ai/dsh-web-search-exa`, `@deepseek-ai/dsh-web-search-perplexity`,
@@ -128,25 +128,15 @@ export type WebFetchBody =
/**
* Whether one concrete provider implementation is usable, by cheap local checks
* only (credential presence, parseable endpoint config). A provider `status()`
* must NOT make network calls. It is an input to selection, not a health system.
* must NOT make network calls. It is an input to execution-time selection, not
* a health system: `WebService.search()`/`fetch()` read it to pick a usable
* provider, and selection failure surfaces as the structured {@link WebError}
* codes callers route on.
*/
export type WebProviderStatus =
| { readonly available: true }
| { readonly available: false; readonly reason: 'missing-credential' | 'misconfigured' }
/**
* Whether a capability (search or fetch) has a selected usable provider, or the
* broad category in which selection fails. Intentionally small: it carries the
* winning `providerId` on the available branch (so diagnostics can report which
* provider won) but NOT the per-reason payload (the missing id, the ambiguous
* candidate set). That branchable detail lives in the {@link WebError} thrown at
* execution time — the surface callers route on — so the same fact does not get
* two homes that can disagree.
*/
export type WebCapabilityStatus =
| { readonly available: true; readonly providerId: string }
| { readonly available: false; readonly reason: 'none' | 'configured-missing' | 'configured-unavailable' | 'ambiguous' }
/**
* A search-capable backend. Registered with `ctx.web.registerSearchProvider`.
* `id` is a stable string, unique within the search capability kind.

View File

@@ -1,4 +1,4 @@
import { describe, expect, it, vi } from 'vitest'
import { describe, expect, it } from 'vitest'
import { Context } from 'cordis'
import WebService, {
WebError,
@@ -42,18 +42,14 @@ async function mountWeb(config: ConstructorParameters<typeof WebService>[1] = {}
}
describe('WebService registration', () => {
it('registers and disposes a search provider, emitting providers-change each way', async () => {
const { ctx, web } = await mountWeb()
const changed = vi.fn()
ctx.on('web/providers-change', changed)
it('registers a search provider and unregisters it via the returned disposer', async () => {
const { web } = await mountWeb()
const dispose = web.registerSearchProvider(makeSearchProvider('exa', available, () => Promise.resolve(searchResult('exa'))))
expect(changed).toHaveBeenCalledTimes(1)
expect(web.searchStatus()).toEqual({ available: true, providerId: 'exa' })
await expect(web.search({ query: 'q' })).resolves.toMatchObject({ providerId: 'exa' })
dispose()
expect(changed).toHaveBeenCalledTimes(2)
expect(web.searchStatus()).toEqual({ available: false, reason: 'none' })
await expect(web.search({ query: 'q' })).rejects.toThrow(expect.objectContaining({ code: 'WEB_PROVIDER_UNAVAILABLE' }))
})
it('throws WEB_DUPLICATE_PROVIDER on a duplicate search id', async () => {
@@ -69,88 +65,14 @@ describe('WebService registration', () => {
expect(() => web.registerFetchProvider(makeFetchProvider('shared', available, fetchResult('shared')))).not.toThrow()
})
it('rolls back a registration when a providers-change listener throws', async () => {
const { ctx, web } = await mountWeb()
ctx.on('web/providers-change', () => { throw new Error('listener boom') })
expect(() => web.registerSearchProvider(makeSearchProvider('exa', available, () => Promise.resolve(searchResult('exa')))))
.toThrow('listener boom')
// The throwing listener must not leave the provider in the registry.
expect(web.searchStatus()).toEqual({ available: false, reason: 'none' })
})
it('disposes provider registrations when the contributing fiber is disposed (HMR safety)', async () => {
const { ctx, web } = await mountWeb()
const fiber = await ctx.plugin(Object.assign((inner: Context) => {
inner.web.registerSearchProvider(makeSearchProvider('exa', available, () => Promise.resolve(searchResult('exa'))))
}, { inject: ['web'] }))
expect(web.searchStatus()).toEqual({ available: true, providerId: 'exa' })
await expect(web.search({ query: 'q' })).resolves.toMatchObject({ providerId: 'exa' })
await fiber.dispose()
expect(web.searchStatus()).toEqual({ available: false, reason: 'none' })
})
})
describe('WebService selection status', () => {
it('reports none when nothing is registered', async () => {
const { web } = await mountWeb()
expect(web.searchStatus()).toEqual({ available: false, reason: 'none' })
expect(web.fetchStatus()).toEqual({ available: false, reason: 'none' })
})
it('auto-selects the single usable provider when no id is configured', async () => {
const { web } = await mountWeb()
web.registerSearchProvider(makeSearchProvider('exa', available, () => Promise.resolve(searchResult('exa'))))
expect(web.searchStatus()).toEqual({ available: true, providerId: 'exa' })
})
it('reports ambiguous when multiple usable providers exist and none is configured', async () => {
const { web } = await mountWeb()
web.registerSearchProvider(makeSearchProvider('exa', available, () => Promise.resolve(searchResult('exa'))))
web.registerSearchProvider(makeSearchProvider('perplexity', available, () => Promise.resolve(searchResult('perplexity'))))
expect(web.searchStatus()).toEqual({ available: false, reason: 'ambiguous' })
})
it('ignores unusable providers when auto-selecting', async () => {
const { web } = await mountWeb()
web.registerSearchProvider(makeSearchProvider('exa', available, () => Promise.resolve(searchResult('exa'))))
web.registerSearchProvider(makeSearchProvider('perplexity', unavailable, () => Promise.resolve(searchResult('perplexity'))))
expect(web.searchStatus()).toEqual({ available: true, providerId: 'exa' })
})
it('reports none when providers exist but none are usable', async () => {
const { web } = await mountWeb()
web.registerSearchProvider(makeSearchProvider('exa', unavailable, () => Promise.resolve(searchResult('exa'))))
expect(web.searchStatus()).toEqual({ available: false, reason: 'none' })
})
it('honors a configured id over a different registered provider', async () => {
const { web } = await mountWeb({ searchProvider: 'perplexity' })
web.registerSearchProvider(makeSearchProvider('exa', available, () => Promise.resolve(searchResult('exa'))))
web.registerSearchProvider(makeSearchProvider('perplexity', available, () => Promise.resolve(searchResult('perplexity'))))
expect(web.searchStatus()).toEqual({ available: true, providerId: 'perplexity' })
})
it('reports configured-missing when the configured id is not registered', async () => {
const { web } = await mountWeb({ searchProvider: 'perplexity' })
web.registerSearchProvider(makeSearchProvider('exa', available, () => Promise.resolve(searchResult('exa'))))
expect(web.searchStatus()).toEqual({ available: false, reason: 'configured-missing' })
})
it('reports configured-unavailable when the configured id is registered but unusable', async () => {
const { web } = await mountWeb({ searchProvider: 'exa' })
web.registerSearchProvider(makeSearchProvider('exa', unavailable, () => Promise.resolve(searchResult('exa'))))
expect(web.searchStatus()).toEqual({ available: false, reason: 'configured-unavailable' })
})
it('does not let registration order change auto-selection', async () => {
const a = await mountWeb()
a.web.registerSearchProvider(makeSearchProvider('exa', unavailable, () => Promise.resolve(searchResult('exa'))))
a.web.registerSearchProvider(makeSearchProvider('perplexity', available, () => Promise.resolve(searchResult('perplexity'))))
expect(a.web.searchStatus()).toEqual({ available: true, providerId: 'perplexity' })
const b = await mountWeb()
b.web.registerSearchProvider(makeSearchProvider('perplexity', available, () => Promise.resolve(searchResult('perplexity'))))
b.web.registerSearchProvider(makeSearchProvider('exa', unavailable, () => Promise.resolve(searchResult('exa'))))
expect(b.web.searchStatus()).toEqual({ available: true, providerId: 'perplexity' })
await expect(web.search({ query: 'q' })).rejects.toThrow(expect.objectContaining({ code: 'WEB_PROVIDER_UNAVAILABLE' }))
})
})
@@ -160,6 +82,12 @@ describe('WebService execution resolution', () => {
await expect(web.search({ query: 'q' })).rejects.toThrow(expect.objectContaining({ code: 'WEB_PROVIDER_UNAVAILABLE' }))
})
it('throws WEB_PROVIDER_UNAVAILABLE when providers exist but none are usable', async () => {
const { web } = await mountWeb()
web.registerSearchProvider(makeSearchProvider('exa', unavailable, () => Promise.resolve(searchResult('exa'))))
await expect(web.search({ query: 'q' })).rejects.toThrow(expect.objectContaining({ code: 'WEB_PROVIDER_UNAVAILABLE' }))
})
it('throws WEB_PROVIDER_CONFIGURED_MISSING for an unregistered configured id', async () => {
const { web } = await mountWeb({ searchProvider: 'perplexity' })
web.registerSearchProvider(makeSearchProvider('exa', available, () => Promise.resolve(searchResult('exa'))))
@@ -179,6 +107,32 @@ describe('WebService execution resolution', () => {
await expect(web.search({ query: 'q' })).rejects.toThrow(expect.objectContaining({ code: 'WEB_PROVIDER_AMBIGUOUS' }))
})
it('runs the configured provider even when another usable provider is registered', async () => {
const { web } = await mountWeb({ searchProvider: 'perplexity' })
web.registerSearchProvider(makeSearchProvider('exa', available, () => Promise.resolve(searchResult('exa'))))
web.registerSearchProvider(makeSearchProvider('perplexity', available, () => Promise.resolve(searchResult('perplexity'))))
await expect(web.search({ query: 'q' })).resolves.toMatchObject({ providerId: 'perplexity' })
})
it('ignores unusable providers when auto-selecting', async () => {
const { web } = await mountWeb()
web.registerSearchProvider(makeSearchProvider('exa', available, () => Promise.resolve(searchResult('exa'))))
web.registerSearchProvider(makeSearchProvider('perplexity', unavailable, () => Promise.resolve(searchResult('perplexity'))))
await expect(web.search({ query: 'q' })).resolves.toMatchObject({ providerId: 'exa' })
})
it('does not let registration order change auto-selection', async () => {
const a = await mountWeb()
a.web.registerSearchProvider(makeSearchProvider('exa', unavailable, () => Promise.resolve(searchResult('exa'))))
a.web.registerSearchProvider(makeSearchProvider('perplexity', available, () => Promise.resolve(searchResult('perplexity'))))
await expect(a.web.search({ query: 'q' })).resolves.toMatchObject({ providerId: 'perplexity' })
const b = await mountWeb()
b.web.registerSearchProvider(makeSearchProvider('perplexity', available, () => Promise.resolve(searchResult('perplexity'))))
b.web.registerSearchProvider(makeSearchProvider('exa', unavailable, () => Promise.resolve(searchResult('exa'))))
await expect(b.web.search({ query: 'q' })).resolves.toMatchObject({ providerId: 'perplexity' })
})
it('runs the selected provider and returns its result', async () => {
const { web } = await mountWeb()
web.registerSearchProvider(makeSearchProvider('exa', available, () => Promise.resolve(