mirror of
https://github.com/getpaseo/paseo.git
synced 2026-07-29 12:01:31 +00:00
Compare commits
6 Commits
feat-paseo
...
lenient-co
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
9eec33773a | ||
|
|
5616c6af6a | ||
|
|
58fce6622b | ||
|
|
27f1f1d207 | ||
|
|
807d0d6d69 | ||
|
|
6513b56571 |
@@ -36,8 +36,8 @@ At the start of non-trivial work, list `docs/` and skim anything relevant to the
|
||||
| [docs/expo-router.md](docs/expo-router.md) | Expo Router route ownership, startup restore, and native blank-screen gotchas |
|
||||
| [docs/file-icons.md](docs/file-icons.md) | Material icon theme integration for the file explorer |
|
||||
| [docs/providers.md](docs/providers.md) | Adding a new agent provider end-to-end |
|
||||
| [docs/custom-providers.md](docs/custom-providers.md) | Custom provider config: Z.AI, Alibaba/Qwen, ACP agents, profiles, custom binaries |
|
||||
| [docs/service-proxy.md](docs/service-proxy.md) | Service proxy: exposing workspace scripts at public URLs, DNS setup, reverse proxy config |
|
||||
| [docs/paseo-agent.md](docs/paseo-agent.md) | Paseo Agent provider (id `paseo`): model-provider catalog, generic config, OAuth store, CLI/app setup |
|
||||
| [docs/development.md](docs/development.md) | Dev server, build sync gotchas, CLI reference, agent state, Playwright MCP |
|
||||
| [docs/rpc-namespacing.md](docs/rpc-namespacing.md) | WebSocket RPC naming convention — dotted namespaces and `.request`/`.response` pairs |
|
||||
| [docs/terminal-performance.md](docs/terminal-performance.md) | Terminal latency pipeline, coalescing/backpressure invariants, benchmark + perf spec usage |
|
||||
|
||||
@@ -195,6 +195,9 @@ Single file, validated with `PersistedConfigSchema`.
|
||||
|
||||
All fields are optional with sensible defaults.
|
||||
|
||||
Config parsing strips unrecognized object keys so a config written by a newer daemon does not brick
|
||||
older read paths such as `paseo daemon status`. Malformed known fields still fail validation.
|
||||
|
||||
`agents.metadataGeneration.providers` controls the preferred structured-generation fallback order for daemon-side metadata tasks such as commit messages, PR text, branch names, and generated agent titles. Entries are tried first in the configured order, then Paseo falls through to dynamically discovered defaults and finally the current selection when available.
|
||||
|
||||
Local speech model ids are intentionally narrow: STT uses `parakeet-tdt-0.6b-v2-int8`, TTS uses `kokoro-en-v0_19`, and turn detection uses the bundled Silero VAD model.
|
||||
|
||||
@@ -6,7 +6,7 @@ Authoritative terminology. UI label wins. Don't invent synonyms; use what's here
|
||||
- **Workspace** — One concrete `cwd` on one daemon, with git state; belongs to exactly one project. Its `id` is opaque workspace identity; its `cwd` is the filesystem directory. UI: "Workspace". Code: `WorkspaceDescriptorPayload` (`packages/protocol/src/messages.ts:2178`). Don't confuse with: Branch (one branch can back many workspaces via worktrees). Forbidden: "Folder", "Directory" as UI label.
|
||||
- **Workspace kind** — `"directory" | "local_checkout" | "worktree"`. The git-derived, persisted property of a workspace, used across its lifetime (archive safety, sidebar, grouping). Derived from the cwd's git reality (`deriveWorkspaceKind`, `packages/server/src/server/workspace-registry-model.ts:158`), not stored from a user choice. Code: `PersistedWorkspaceKind` (`packages/server/src/server/workspace-registry-model.ts:8`). Don't confuse with **Isolation** (the create-time intent).
|
||||
- **Isolation** — Create-time choice for a new workspace: reuse the existing checkout (**Local**) or cut a dedicated git worktree (**New worktree**). A transient setup input, also remembered as a create-form preference; it is not a workspace property. UI: "Isolation" control on the New Workspace screen. Code: `isolation` (`"local" | "worktree"`), `useWorkspaceIsolation` (`packages/app/src/screens/new-workspace-screen.tsx`); persisted as `FormPreferences.isolation` (`packages/app/src/create-agent-preferences/preferences.ts`). Distinct from **Workspace kind**, which is the git-derived property the intent produces (Local → `local_checkout` or `directory` by git-ness; New worktree → `worktree`). On the wire it is the create request's `source.kind` (`directory | worktree`, `packages/protocol/src/messages.ts:1693`).
|
||||
- **Agent** — A runnable identity the user picks to start work: today that is an **Agent provider** (Claude Code, Codex, Paseo Agent); direction: also a named **Agent definition** (`reviewer`) that pins provider, model, prompt, and tools. One CLI namespace: `paseo run --agent <id>` resolves agent provider ids first, then agent definition ids. The running instance is an **Agent session** — "New Agent" starts a new agent session. Forbidden: "Task", "Job", "Run".
|
||||
- **Agent** — See **Agent session**. UI still says "Agent" / "New Agent" in places, but moving toward **Agent session** as the canonical term. Code: `AgentSnapshotPayload` (`packages/protocol/src/messages.ts:608`). Forbidden: "Task", "Job", "Run".
|
||||
- **Daemon** — Local Paseo server process; identified by `serverId`. UI: "Daemon" (system contexts only). Code: `serverId` in `ServerInfoStatusPayloadSchema` (`packages/protocol/src/messages.ts:1936`), `DaemonClient` (`packages/client/src/daemon-client.ts`).
|
||||
- **Host** — Client-side connection profile pointing at a daemon; bundles one or more `HostConnection`s. UI: "Host" / "Add host" / "Switch host". Code: `HostProfile` (`packages/app/src/types/host-connection.ts:37`). Forbidden: "Connection" (means `HostConnection`, not host).
|
||||
- **Project host entry** — One row in a project for a single (project, daemon) pair, aggregating that daemon's workspaces in the project. Internal. Code: `ProjectHostEntry` (`packages/app/src/utils/projects.ts:11`). Don't introduce "Checkout" as a synonym.
|
||||
@@ -17,13 +17,11 @@ Authoritative terminology. UI label wins. Don't invent synonyms; use what's here
|
||||
- **Directory-backed surface** — A right-sidebar surface whose content is determined by the workspace's `cwd`, so two workspaces on the same directory see identical content: git diff/status, GitHub PR info, file preview/explorer contents. Keyed by `(serverId, cwd)`, never `workspaceId`. See [architecture.md](architecture.md#right-sidebar-boundary-directory-backed-vs-workspace-owned).
|
||||
- **Workspace-owned state** — Per-workspace state that never leaks to a same-`cwd` sibling: tabs, agents, terminals, panes, title, plus review drafts, diff-mode overrides, composer attachments, and file-explorer open/expand state. Keyed by `workspaceId` (`cwd` only as a fallback for old payloads). See [architecture.md](architecture.md#right-sidebar-boundary-directory-backed-vs-workspace-owned).
|
||||
- **Workspace status bucket** — Aggregate activity signal for a workspace row. Same-`cwd` workspaces intentionally share agent and terminal status buckets, while tab, agent, and terminal visibility remains scoped by `workspaceId`.
|
||||
- **Agent session** — One running instance of an agent inside a workspace (one agent provider, one model, one cwd, one timeline). The conceptual unit; in the UI this opens as a tab. Canonical term for the running instance (bare "Agent" is the identity being run, see **Agent**). Code: `AgentSnapshotPayload` (`packages/protocol/src/messages.ts:608`).
|
||||
- **Agent definition** — A Paseo-owned markdown persona in `$PASEO_HOME/agents/*.md`: frontmatter `name`, `prompt`, `model`, `tools`, `permissions`; body is the prompt. Selected via `agents.paseo.defaultAgent` (`defaultProfile` is a legacy alias). Direction: definitions join the `--agent` namespace (`paseo run --agent reviewer`), so a definition id must not shadow an agent provider id. Code: `loadAgentDefinition` (`packages/server/src/server/agent/providers/paseo-agent/prompt-profiles.ts:62`), config keys (`packages/server/src/server/agent/providers/paseo-agent/config.ts:174`). Forbidden: "Profile", "Prompt profile" as synonyms.
|
||||
- **Agent session** — One running instance of an agent inside a workspace (one provider, one model, one cwd, one timeline). The conceptual unit; in the UI this opens as a tab. Moving toward this as the canonical term over "Agent". Code: `AgentSnapshotPayload` (`packages/protocol/src/messages.ts:608`).
|
||||
- **Session** — Two senses: (a) per-client connection to a daemon, internal; (b) user-facing agent session, see **Agent session**. Code: `Session` (`packages/server/src/server/session.ts`) for (a). Don't confuse with: provider-side agent session log.
|
||||
- **Profile** — Internal name for the persisted shape of a host. Code: `HostProfile` (`packages/app/src/types/host-connection.ts:37`). Never user-facing.
|
||||
- **Agent provider** — The harness/backend that runs agents (Claude Code, Codex, Copilot, OpenCode, Pi, OMP, Paseo Agent with id `paseo`). UI: "Agent provider" (target label; surfaces still saying bare "Provider" migrate as touched). Wire and config keep `provider` — labels move, the protocol does not. Code: `AgentProvider` (`packages/protocol/src/agent-types.ts:3`), `ProviderSnapshotEntrySchema` (`packages/protocol/src/messages.ts:262`). Don't confuse with: **Model provider**. Forbidden: bare "Provider" as a new UI label — always qualify which layer.
|
||||
- **Model provider** — A model API attached to the Paseo Agent provider: a type (`openrouter`, `openai`, `anthropic`, `openai-codex`, …) plus key/OAuth and models, keyed by instance name (`openrouter-main`, `chatgpt`). UI: "Model providers" (Paseo Agent settings). CLI: `paseo provider add` — the CLI `provider` noun means model provider. Config: `agents.paseo.providers` (`packages/server/src/server/agent/providers/paseo-agent/config.ts:169`); wire: `PaseoAgentProviderTypeSchema` (`packages/protocol/src/messages.ts:1924`). Don't confuse with: **Agent provider**. Forbidden: "Inference provider" (old docs term), bare "Provider" as a new UI label.
|
||||
- **Model** — A specific LLM offered by a provider; for the Paseo Agent provider, addressed as `<modelProviderName>/<modelId>`. UI: "Model" / "Select model". Code: `AgentModelDefinition` (`packages/protocol/src/messages.ts:187`).
|
||||
- **Provider** — Agent backend (Claude Code, Codex, Copilot, OpenCode, Pi, OMP). UI: "Provider". Code: `ProviderSnapshotEntry` (`packages/protocol/src/messages.ts:198`).
|
||||
- **Model** — A specific LLM offered by a provider. UI: "Model" / "Select model". Code: `AgentModelDefinition` (`packages/protocol/src/messages.ts:187`).
|
||||
- **Tab** — UI surface representing one session inside a workspace. Not a conceptual unit; use **Agent session** when talking about the model. Code: `WorkspaceTabDescriptor` (`packages/app/src/screens/workspace/workspace-tabs-types.ts`).
|
||||
- **Terminal** — Workspace-scoped PTY shell streamed over the binary mux channel. UI: "Terminal". Code: `TerminalStreamFrame` (`packages/protocol/src/terminal-stream-protocol.ts`).
|
||||
- **Schedule** — Cron-style trigger that creates new agents. UI: CLI/MCP (`paseo schedule`, `create_schedule`). Don't confuse with: Heartbeat (cron prompt back into the same agent) or Loop (iterative re-execution of one agent).
|
||||
@@ -41,8 +39,5 @@ Authoritative terminology. UI label wins. Don't invent synonyms; use what's here
|
||||
|
||||
## Inconsistencies (documented, not papered over)
|
||||
|
||||
- Bare "Provider" survives as a UI label in the provider picker and settings; target is "Agent provider" / "Model providers" per the entries above. Migrate labels as surfaces are touched — no big-bang rename.
|
||||
- CLI `paseo provider` describes itself as "Manage agent providers" (`packages/cli/src/commands/provider/index.ts:11`) but manages **model providers**. Fix the description; keep the noun.
|
||||
- CLI `paseo run --provider <id>` selects an agent provider. Direction: add `--agent <id>` as the canonical flag resolving one namespace (agent provider ids, then agent definition ids), keep `--provider` as a COMPAT alias.
|
||||
- CLI `--host <host>` description `"Daemon host target"` (`packages/cli/src/utils/command-options.ts:5`) blurs daemon/host; the app keeps them distinct.
|
||||
- `WorkspaceDescriptorPayloadSchema.workspaceKind` accepts legacy `"checkout"` on the wire (`packages/protocol/src/messages.ts:2187`) while `PersistedWorkspaceKind` does not (`packages/server/src/server/workspace-registry-model.ts:8`).
|
||||
|
||||
@@ -1,351 +0,0 @@
|
||||
# Paseo Agent provider
|
||||
|
||||
Paseo Agent is the built-in agent provider that runs Pi's coding-agent harness **in
|
||||
process** (no `pi` CLI, no `~/.pi` discovery). Its provider id is **`paseo`** and its
|
||||
model backends are configured under `agents.paseo` in `$PASEO_HOME/config.json`.
|
||||
|
||||
Use it like any other agent provider:
|
||||
|
||||
```bash
|
||||
paseo run --provider paseo --model <providerInstance>/<modelId> "Reply with pong"
|
||||
```
|
||||
|
||||
Example: `--model openrouter/openai/gpt-4o-mini` selects the `openrouter` provider
|
||||
instance and the `openai/gpt-4o-mini` model exposed by that instance.
|
||||
|
||||
> Smoke note: the daemon supervisor runs from `packages/server/dist`. After changing
|
||||
> provider/config code, run `npm run build:server` (or run a source/dev daemon) before
|
||||
> smoking, otherwise stale `dist` may reject or omit `agents.paseo` behavior. Always pass
|
||||
> `--host <addr>` to CLI smoke commands so they hit your isolated daemon, not the real
|
||||
> daemon on `:6767`.
|
||||
|
||||
## Timeline and persistence
|
||||
|
||||
Paseo Agent does not write or resume Pi CLI session files such as
|
||||
`~/.pi/agent/sessions/*`; it runs Pi's harness in process and currently reports no
|
||||
provider-owned session persistence. App refreshes and daemon-side agent reloads therefore
|
||||
depend on Paseo's own timeline rows, not on a Pi history replay. Submitted prompts must be
|
||||
emitted as `user_message` timeline rows when the turn starts; assistant text, reasoning,
|
||||
and tool calls arrive later from Pi events and are mapped into the same Paseo timeline.
|
||||
|
||||
## Provider catalog
|
||||
|
||||
Paseo Agent model providers are catalog-driven, but Paseo does not copy Pi's provider
|
||||
registry. `catalog.ts` is only the curated Paseo surface: catalog id, Pi provider id,
|
||||
label, icon, default-model policy, and auth hints Pi cannot infer. Pi supplies the wire
|
||||
API, base URL, headers, model ids, context windows, token limits, costs, reasoning flags,
|
||||
thinking maps, and OAuth registry data at runtime.
|
||||
|
||||
The current catalog contains four entries:
|
||||
|
||||
| id | Pi provider | default models | auth source |
|
||||
| ------------- | -------------- | -------------- | --------------------------------------------- |
|
||||
| `openrouter` | `openrouter` | none | Paseo hint `OPENROUTER_API_KEY` |
|
||||
| `chatgpt` | `openai-codex` | Pi full list | Pi OAuth registry (`openai-codex`) |
|
||||
| `kimi` | `kimi-coding` | Pi full list | Paseo hint `KIMI_API_KEY` (Pi has no env key) |
|
||||
| `opencode-go` | `opencode-go` | Pi full list | Paseo hint `OPENCODE_API_KEY` |
|
||||
|
||||
OpenRouter intentionally has no default model because Pi's OpenRouter registry is large.
|
||||
Users can store the OpenRouter credential first, but they must choose explicit model ids
|
||||
before running Paseo Agent through that provider. The other catalog entries expose Pi's
|
||||
bundled model list unless an instance sets `options.models`.
|
||||
|
||||
## Config shape
|
||||
|
||||
`agents.paseo.providers` is structurally generic. Provider instance names are free-form
|
||||
keys; provider types are catalog ids. Use the catalog id as the default instance name,
|
||||
or create several instances of the same type with different models, keys, or endpoint
|
||||
overrides.
|
||||
|
||||
```jsonc
|
||||
{
|
||||
"agents": {
|
||||
"paseo": {
|
||||
"defaultAgent": "orchestrator",
|
||||
"defaultModel": "openrouter-main/openai/gpt-4o-mini",
|
||||
"providers": {
|
||||
"openrouter-main": {
|
||||
"type": "openrouter",
|
||||
"options": {
|
||||
"apiKey": "$OPENROUTER_API_KEY",
|
||||
"models": [
|
||||
{ "id": "openai/gpt-4o-mini", "label": "GPT-4o mini" },
|
||||
{ "id": "anthropic/claude-3.7-sonnet", "reasoning": true },
|
||||
],
|
||||
},
|
||||
},
|
||||
"chatgpt": {
|
||||
"type": "chatgpt",
|
||||
},
|
||||
"kimi": {
|
||||
"type": "kimi",
|
||||
"options": {
|
||||
"apiKey": "$KIMI_API_KEY",
|
||||
"models": [{ "id": "k2p7" }],
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
```
|
||||
|
||||
Most options are overrides over Pi-derived provider data:
|
||||
|
||||
- `apiKey` may be omitted for API-key providers. Omitted means "use the derived or hinted
|
||||
env var", such as `OPENROUTER_API_KEY` or `KIMI_API_KEY`.
|
||||
- `apiKey` may also be a literal key, `$ENV`, `${ENV}`, or `!command` expression. Paseo
|
||||
mirrors Pi's config-value semantics: literals and commands count as configured; env
|
||||
references count only when every referenced env var is set in the daemon environment.
|
||||
- `baseUrl`, `api`, `headers`, and `authHeader` override or extend the Pi-derived request
|
||||
config.
|
||||
- `models[]` is an instance override. Omit it to use that entry's default policy, which
|
||||
can be an empty list for catalog entries such as OpenRouter. A model may override `api`
|
||||
when a single backend serves mixed protocols or when Pi has no data for a custom id.
|
||||
- `refreshToken` is an advanced OAuth seed path. Prefer the OAuth store described below.
|
||||
|
||||
Env references make config portable: `config.json` can be copied between machines while
|
||||
the secret stays in that machine's daemon environment or keychain command.
|
||||
|
||||
`defaultModel` is optional and uses the same `<providerInstance>/<modelId>` form. An
|
||||
explicit session model wins, then the selected agent definition's model, then
|
||||
`agents.paseo.defaultModel`, then Pi's first available model.
|
||||
|
||||
`defaultProfile` is still accepted as a legacy alias for `defaultAgent`.
|
||||
|
||||
## Authentication
|
||||
|
||||
API-key providers use the catalog auth metadata plus the configured `apiKey` expression.
|
||||
Redacted provider responses include an optional `auth` state:
|
||||
|
||||
- `Connected` means the key or credential expression resolves locally. It does not make a
|
||||
network call, so a fake literal key still reports connected until a real session uses it.
|
||||
- `Needs attention` means the instance exists but the auth expression does not currently
|
||||
resolve, the OAuth store binding does not match, or another auth precondition is missing.
|
||||
- `not configured` is used by older/no-auth responses.
|
||||
|
||||
The redacted provider `available` flag mirrors local credential availability. The Paseo
|
||||
Agent runtime still needs at least one exposed model before it can start a session.
|
||||
|
||||
Secrets are not returned in catalog responses, redacted provider responses, or CLI table
|
||||
output.
|
||||
|
||||
### OAuth store
|
||||
|
||||
OAuth credentials live in Paseo's store, not in another tool's auth file:
|
||||
|
||||
```text
|
||||
$PASEO_HOME/paseo-agent/auth.json
|
||||
```
|
||||
|
||||
The store is created through Pi's `AuthStorage`. The parent directory is private and the
|
||||
file is written mode `0600`; Pi also re-chmods on write. During a Paseo Agent session,
|
||||
Pi reads the credential, refreshes expired access tokens, and persists refresh-token
|
||||
rotation back into the same Paseo-owned file.
|
||||
|
||||
Stored OAuth credentials are bound to the provider instance's `{ flow, baseUrl }`. If the
|
||||
catalog flow or configured base URL changes, the old credential is left on disk but the
|
||||
provider reports `Needs attention` until it is authorized again. This prevents a token
|
||||
for one OAuth target from silently being used against another.
|
||||
|
||||
The OAuth implementation uses Pi's OAuth registry. Catalog OAuth entries are limited to
|
||||
flows that registry knows about: `openai-codex`, `anthropic`, and `github-copilot`.
|
||||
The current catalog only uses `openai-codex` for `chatgpt`.
|
||||
|
||||
## CLI setup
|
||||
|
||||
The provider CLI talks to the selected daemon. Always pass `--host` when smoking an
|
||||
isolated daemon.
|
||||
|
||||
Commands:
|
||||
|
||||
- `paseo provider add [id]` configures a catalog provider. Omit `id` to choose from the
|
||||
daemon catalog. Use `--name`, repeated/comma-separated `--model`, `--api-key-stdin`,
|
||||
`--device-code`, `--json`, and `--host` as needed.
|
||||
- `paseo provider ls` lists configured instances and redacted auth state.
|
||||
- `paseo provider rm <name>` removes one provider instance and clears `defaultModel` if
|
||||
it pointed at that instance.
|
||||
|
||||
Examples:
|
||||
|
||||
```bash
|
||||
printf '%s\n' "$OPENROUTER_API_KEY" |
|
||||
paseo provider add \
|
||||
openrouter \
|
||||
--api-key-stdin \
|
||||
--model openai/gpt-4o-mini \
|
||||
--host 127.0.0.1:7911
|
||||
```
|
||||
|
||||
```bash
|
||||
paseo provider add kimi \
|
||||
--model k2p7 \
|
||||
--host 127.0.0.1:7911
|
||||
# Press Enter at the API-key prompt to store "$KIMI_API_KEY".
|
||||
```
|
||||
|
||||
```bash
|
||||
paseo provider add chatgpt --device-code --host 127.0.0.1:7911
|
||||
```
|
||||
|
||||
Without an `[id]`, `provider add` prints the catalog and prompts for a selection. For
|
||||
API-key providers it writes the configured key expression into `config.json`. For OAuth
|
||||
providers it first writes the provider config, then stores the OAuth credential in the
|
||||
daemon's Paseo-owned auth store.
|
||||
|
||||
`provider add` is idempotent for the same instance name: running it again updates the
|
||||
existing entry instead of creating a duplicate. `provider rm <name>` removes only that
|
||||
provider instance and clears `defaultModel` if it pointed at the removed instance.
|
||||
|
||||
## App setup
|
||||
|
||||
The app uses the same catalog and config RPCs as the CLI. In Settings, open the host
|
||||
settings, then the Paseo Agent provider section. The app fetches the catalog from the
|
||||
connected daemon, shows catalog entries in the picker, and renders either an API-key
|
||||
form or an OAuth sign-in flow from the entry's `auth.kind`. OAuth providers offer two
|
||||
daemon-run starts: browser redirect for the local fast path and device code for
|
||||
remote-safe authorization. Relay connections present device code first; direct TCP does
|
||||
not prove same-machine by itself, so both options remain available.
|
||||
|
||||
Configured rows show the redacted provider state from the daemon: label, provider type,
|
||||
models, availability, and auth state. The app gates this UI on
|
||||
`server_info.features.paseoAgentCatalog`; older daemons show an update-host affordance
|
||||
instead of trying to synthesize the feature through older RPCs.
|
||||
|
||||
## Wire surface
|
||||
|
||||
The catalog surface is gated by:
|
||||
|
||||
```text
|
||||
server_info.features.paseoAgentCatalog
|
||||
```
|
||||
|
||||
RPC names use dotted namespaces:
|
||||
|
||||
| request | response |
|
||||
| --------------------------------------------------- | ---------------------------------------------------- |
|
||||
| `config.paseo_agent.get_catalog.request` | `config.paseo_agent.get_catalog.response` |
|
||||
| `config.paseo_agent.get_providers.request` | `config.paseo_agent.get_providers.response` |
|
||||
| `config.paseo_agent.set_provider.request` | `config.paseo_agent.set_provider.response` |
|
||||
| `config.paseo_agent.remove_provider.request` | `config.paseo_agent.remove_provider.response` |
|
||||
| `config.paseo_agent.oauth.start.request` | `config.paseo_agent.oauth.start.response` |
|
||||
| `config.paseo_agent.oauth.complete.request` | `config.paseo_agent.oauth.complete.response` |
|
||||
| `config.paseo_agent.oauth.store_credential.request` | `config.paseo_agent.oauth.store_credential.response` |
|
||||
|
||||
`oauth.start` accepts `mode?: string`; the daemon currently supports `"browser"` and
|
||||
`"device_code"`, defaulting to `"browser"` because the common app/daemon desktop path can
|
||||
complete the localhost redirect on the daemon host. Browser mode returns
|
||||
`authorization: { kind: "auth_url", url, instructions? }` as soon as the daemon callback
|
||||
listener is ready. `oauth.complete` then waits for the pending redirect flow to resolve,
|
||||
stores the credential, and returns the redacted `auth` state. Device-code mode returns
|
||||
`authorization: { kind: "device_code", userCode?, verificationUri?, intervalSeconds?,
|
||||
expiresInSeconds?, instructions? }` and uses the same `oauth.complete` response after the
|
||||
provider authorizes.
|
||||
|
||||
Protocol strings are intentionally open. Provider ids, auth kinds, OAuth flow names,
|
||||
OAuth start modes, and wire API names are strings, not closed enums. Old clients can parse
|
||||
new catalog entries, and new clients can show "update host" only when the daemon lacks the
|
||||
catalog feature.
|
||||
|
||||
## Adding a catalog entry
|
||||
|
||||
Adding a new model-provider type should be a data change:
|
||||
|
||||
1. Add one entry to `PASEO_AGENT_PROVIDER_CATALOG` in
|
||||
`packages/server/src/server/agent/providers/paseo-agent/catalog.ts`.
|
||||
2. Set `id`, `piProvider`, `label`, and `defaultModels: false` only when the Pi provider
|
||||
should not expose its full model list by default.
|
||||
3. Add `auth` only when Pi cannot infer the auth source or when an explicit flow hint keeps
|
||||
resolution simple.
|
||||
4. Add or update focused tests around catalog assembly, provider resolution, auth state,
|
||||
and CLI/app rendering if the new entry exercises a new shape.
|
||||
|
||||
Do not add provider-specific branches in the runtime, CLI, or app. The catalog entry is
|
||||
what unlocks CLI setup, app setup, redacted provider state, config persistence, and model
|
||||
addressing.
|
||||
|
||||
## MCP tools
|
||||
|
||||
Paseo Agent bridges `AgentSessionConfig.mcpServers` into Pi custom tools, so the
|
||||
daemon-injected `paseo` MCP server (and any other configured MCP server) is available to
|
||||
the model. On session start the provider connects to each server, lists its tools, and
|
||||
registers them as Pi tools named `<serverName>__<toolName>`; tool input schemas (JSON
|
||||
Schema) are converted to TypeBox, calls are proxied to the MCP server, and results map
|
||||
back to the model. Connections are torn down on session close. Servers that fail to
|
||||
connect or list are logged and skipped rather than failing the session.
|
||||
|
||||
Transports: HTTP (streamable) is the primary path (the injected `paseo` server is HTTP);
|
||||
SSE and stdio transports are also wired via the MCP SDK. No extra config is needed: MCP
|
||||
servers come from Paseo's normal injection/config, not from `agents.paseo`.
|
||||
|
||||
The internal `paseo` MCP server is required for Paseo Agent sessions and is injected even
|
||||
when the global `mcp.injectIntoAgents` setting is disabled for other providers.
|
||||
|
||||
## Agent definitions
|
||||
|
||||
Paseo Agent can load a Paseo-owned agent definition from `$PASEO_HOME/agents/*.md`.
|
||||
Configure the default agent in `agents.paseo.defaultAgent`; `orchestrator` resolves
|
||||
to `$PASEO_HOME/agents/orchestrator.md`. Only top-level markdown files are selectable
|
||||
agents. Reusable partials can live anywhere under `$PASEO_HOME/agents`.
|
||||
|
||||
```jsonc
|
||||
{
|
||||
"agents": {
|
||||
"paseo": {
|
||||
"defaultAgent": "orchestrator",
|
||||
"defaultModel": "openrouter-main/openai/gpt-4o-mini",
|
||||
"providers": {},
|
||||
},
|
||||
},
|
||||
}
|
||||
```
|
||||
|
||||
Example `$PASEO_HOME/agents/orchestrator.md`:
|
||||
|
||||
```markdown
|
||||
---
|
||||
name: Orchestrator
|
||||
description: Coordinates work through Paseo-managed agents
|
||||
prompt: extend
|
||||
mcp: [paseo]
|
||||
model: openrouter-main/openai/gpt-4o-mini
|
||||
tools: [read, grep, paseo__list_agents, paseo__create_agent]
|
||||
permissions:
|
||||
- tool: paseo__archive_*
|
||||
action: deny
|
||||
---
|
||||
|
||||
!{{./partials/collaboration.md}}
|
||||
|
||||
Use the Paseo MCP tools to inspect active agents, create focused helper agents, and
|
||||
summarize handoffs clearly.
|
||||
|
||||
!{{./partials/review-rules.md}}
|
||||
```
|
||||
|
||||
`prompt: extend` keeps Pi's default base prompt and prepends the composed agent body to
|
||||
the append list. `prompt: override` uses the agent body as the custom base prompt, so
|
||||
Pi's default base prompt is skipped. In both prompt modes, per-session `systemPrompt` is
|
||||
appended after the agent, and the daemon-level append prompt is appended last.
|
||||
|
||||
Frontmatter supports `name`, `description`, `prompt`, `mcp`, `model`, `tools`,
|
||||
`permissions`, and `projectContext`. `projectContext` is parsed for a future explicit
|
||||
project-context model, but it does not activate implicit `AGENTS.md`/`CLAUDE.md`
|
||||
discovery; Paseo Agent still keeps Pi context discovery off. `model` is an agent default:
|
||||
an explicit session model wins, then the selected agent model, then
|
||||
`agents.paseo.defaultModel`, then Pi's first available model.
|
||||
|
||||
Partials use bang braces and expand exactly where they appear: `!{{./partials/base.md}}`.
|
||||
Paths are relative to the file containing the directive and are confined to
|
||||
`$PASEO_HOME/agents`: absolute paths, directory escapes, cycles, overly deep partial
|
||||
chains, oversized definitions, and frontmatter inside partials are rejected.
|
||||
|
||||
`tools` is the Pi tool allowlist for the agent: it controls what the model sees and can
|
||||
call. Omit it to use Pi's default built-in tools plus bridged MCP tools. `permissions` is
|
||||
an ordered first-match policy for active tool calls. The first matching `tool` pattern
|
||||
wins; unmatched tools are allowed. Denied calls are blocked before execution through Pi's
|
||||
tool preflight hook, so the policy applies to built-in, custom, and bridged MCP tools.
|
||||
|
||||
`mcp: [paseo]` is an expectation check, not a new injection mechanism. The normal daemon
|
||||
MCP injection still supplies the actual server; if an agent declares an MCP server that
|
||||
is not present in the session's `mcpServers`, Paseo Agent logs a warning and continues.
|
||||
@@ -45,7 +45,6 @@ buildNpmPackage rec {
|
||||
|
||||
nodejs = nodejs_22;
|
||||
inherit (paseo) npmDeps;
|
||||
npmDepsFetcherVersion = 2;
|
||||
|
||||
# Prevent onnxruntime-node's install script from running during automatic
|
||||
# npm rebuild. We manually rebuild only node-pty in buildPhase.
|
||||
|
||||
@@ -1 +1 @@
|
||||
sha256-RBD8SQx4szE41qGDIYcaKWVhzfC1kVUBC7M5OOQBQQY=
|
||||
sha256-o+VzG7lK0qpyUXF4F5Hk08ooW5CPoZSsOG7DyIReUKQ=
|
||||
|
||||
@@ -52,7 +52,6 @@ buildNpmPackage rec {
|
||||
# Default hash lives in nix/npm-deps.hash (see arg default above).
|
||||
# CI auto-updates that file when package-lock.json changes (see .github/workflows/).
|
||||
inherit npmDepsHash;
|
||||
npmDepsFetcherVersion = 2;
|
||||
|
||||
# Prevent onnxruntime-node's install script from running during automatic
|
||||
# npm rebuild (it tries to download from api.nuget.org, which fails in the sandbox).
|
||||
|
||||
2908
package-lock.json
generated
2908
package-lock.json
generated
File diff suppressed because it is too large
Load Diff
@@ -1,130 +0,0 @@
|
||||
import type { DaemonClient as InternalDaemonClient } from "@getpaseo/client/internal/daemon-client";
|
||||
import type { Page } from "@playwright/test";
|
||||
import { expect } from "@playwright/test";
|
||||
import { gotoAppShell, openSettings } from "./app";
|
||||
import { connectDaemonClient } from "./daemon-client-loader";
|
||||
import { getServerId } from "./server-id";
|
||||
import { openSettingsHostSection } from "./settings";
|
||||
|
||||
type PaseoAgentDaemonClient = Pick<
|
||||
InternalDaemonClient,
|
||||
| "close"
|
||||
| "connect"
|
||||
| "removePaseoAgentProvider"
|
||||
| "setPaseoAgentProvider"
|
||||
| "storePaseoAgentOAuthCredential"
|
||||
>;
|
||||
|
||||
interface ApiKeyProviderInput {
|
||||
catalogId: string;
|
||||
apiKey: string;
|
||||
}
|
||||
|
||||
interface OAuthProviderInput {
|
||||
catalogId: string;
|
||||
}
|
||||
|
||||
interface ExpectedProvider {
|
||||
name: string;
|
||||
providerLabel: string;
|
||||
auth: "Connected" | "Needs attention";
|
||||
modelCount: number;
|
||||
}
|
||||
|
||||
async function connectPaseoAgentClient(): Promise<PaseoAgentDaemonClient> {
|
||||
return connectDaemonClient<PaseoAgentDaemonClient>({ clientIdPrefix: "paseo-agent-e2e" });
|
||||
}
|
||||
|
||||
export async function openPaseoAgentSettings(page: Page): Promise<void> {
|
||||
await gotoAppShell(page);
|
||||
await openSettings(page);
|
||||
await openSettingsHostSection(page, getServerId(), "providers");
|
||||
await page.getByRole("button", { name: "Paseo Agent provider details", exact: true }).click();
|
||||
const sheet = page.getByTestId("paseo-agent-settings-sheet");
|
||||
await expect(sheet).toBeVisible();
|
||||
await expect(sheet.getByText("Paseo Agent", { exact: true })).toBeVisible();
|
||||
}
|
||||
|
||||
export async function addApiKeyProvider(page: Page, provider: ApiKeyProviderInput): Promise<void> {
|
||||
await page.getByRole("button", { name: "Add model provider", exact: true }).click();
|
||||
await expect(page.getByTestId("paseo-agent-provider-picker")).toBeVisible();
|
||||
await page.getByTestId(`paseo-agent-catalog-select-${provider.catalogId}`).click();
|
||||
await expect(page.getByTestId("paseo-agent-provider-form")).toBeVisible();
|
||||
|
||||
await expect(page.getByLabel("Provider name")).toHaveCount(0);
|
||||
await expect(page.getByLabel("Models")).toHaveCount(0);
|
||||
await page.getByLabel("API key").fill(provider.apiKey);
|
||||
await page.getByRole("button", { name: "Save provider", exact: true }).click();
|
||||
|
||||
await expect(page.getByTestId("paseo-agent-provider-form")).toHaveCount(0);
|
||||
await expect(page.getByText(provider.apiKey, { exact: true })).toHaveCount(0);
|
||||
}
|
||||
|
||||
export async function startOAuthProviderSignIn(
|
||||
page: Page,
|
||||
provider: OAuthProviderInput,
|
||||
): Promise<void> {
|
||||
await page.getByRole("button", { name: "Add model provider", exact: true }).click();
|
||||
await expect(page.getByTestId("paseo-agent-provider-picker")).toBeVisible();
|
||||
await page.getByTestId(`paseo-agent-catalog-select-${provider.catalogId}`).click();
|
||||
|
||||
await expect(page.getByTestId("paseo-agent-provider-form")).toHaveCount(0);
|
||||
await expect(page.getByTestId("paseo-agent-oauth-sign-in")).toBeVisible();
|
||||
await expect(page.getByLabel("Provider name")).toHaveCount(0);
|
||||
await expect(
|
||||
page.getByRole("button", { name: "Sign in with browser", exact: true }),
|
||||
).toBeVisible();
|
||||
await page.getByRole("button", { name: "Use a code instead", exact: true }).click();
|
||||
await expect(page.getByTestId("paseo-agent-oauth-user-code")).toBeVisible();
|
||||
await expect(page.getByTestId("paseo-agent-oauth-verification-link")).toBeVisible();
|
||||
}
|
||||
|
||||
export async function expectModelProviderListed(
|
||||
page: Page,
|
||||
expected: ExpectedProvider,
|
||||
): Promise<void> {
|
||||
const modelLabel = expected.modelCount === 1 ? "1 model" : `${expected.modelCount} models`;
|
||||
await expect(
|
||||
page.getByRole("listitem", {
|
||||
name: new RegExp(
|
||||
`${expected.name}.*${expected.providerLabel}.*${modelLabel}.*${expected.auth}`,
|
||||
),
|
||||
}),
|
||||
).toBeVisible();
|
||||
}
|
||||
|
||||
export async function seedChatGptProvider(providerName: string): Promise<void> {
|
||||
const client = await connectPaseoAgentClient();
|
||||
try {
|
||||
await client.setPaseoAgentProvider({
|
||||
name: providerName,
|
||||
providerType: "chatgpt",
|
||||
options: {
|
||||
models: [{ id: "gpt-5.4-mini", reasoning: true }],
|
||||
},
|
||||
});
|
||||
await client.storePaseoAgentOAuthCredential({
|
||||
name: providerName,
|
||||
credential: {
|
||||
type: "oauth",
|
||||
access: "fake-access-token",
|
||||
refresh: "fake-refresh-token",
|
||||
expires: 4_102_444_800,
|
||||
futureField: { passthrough: true },
|
||||
},
|
||||
});
|
||||
} finally {
|
||||
await client.close().catch(() => undefined);
|
||||
}
|
||||
}
|
||||
|
||||
export async function cleanupPaseoAgentProviders(providerNames: Iterable<string>): Promise<void> {
|
||||
const client = await connectPaseoAgentClient();
|
||||
try {
|
||||
for (const name of providerNames) {
|
||||
await client.removePaseoAgentProvider(name);
|
||||
}
|
||||
} finally {
|
||||
await client.close().catch(() => undefined);
|
||||
}
|
||||
}
|
||||
@@ -13,12 +13,12 @@ import { getServerId } from "./helpers/server-id";
|
||||
import { clickArchiveWorkspaceMenuItem, expectWorkspaceAbsentFromSidebar } from "./helpers/sidebar";
|
||||
import { waitForSidebarHydration } from "./helpers/workspace-ui";
|
||||
|
||||
// Model B entry points into the New Workspace screen. The per-project
|
||||
// "+ New workspace" sidebar row is gone; the surviving entries are the global
|
||||
// button (universal) and each git project's own new-worktree icon (preselects
|
||||
// that project). These specs prove the global entry opens the screen, the
|
||||
// project icon preselects the right project across the reused 'new' screen, and
|
||||
// non-git projects never offer the worktree Isolation control.
|
||||
// Model B entry points into the New Workspace screen. The surviving entries are
|
||||
// the global button (universal) and each project's per-row New workspace icon
|
||||
// (preselects that project) — shown for git projects and for non-git projects on
|
||||
// a multiplicity-capable host. These specs prove the global entry opens the
|
||||
// screen, the project icon preselects the right project across the reused 'new'
|
||||
// screen, and non-git projects never offer the worktree Isolation control.
|
||||
|
||||
function projectRow(page: import("@playwright/test").Page, projectKey: string) {
|
||||
return page.getByTestId(`sidebar-project-row-${projectKey}`);
|
||||
@@ -215,7 +215,7 @@ test.describe("New workspace entry points", () => {
|
||||
await expect(projectRow(page, nonGitProject.projectId)).toBeVisible({ timeout: 30_000 });
|
||||
|
||||
// Open New Workspace for the non-git project via the global button, then
|
||||
// select it in the picker (its row has no new-worktree icon).
|
||||
// select it in the picker (the per-row icon would preselect it too).
|
||||
await openGlobalNewWorkspaceComposer(page);
|
||||
const trigger = page.getByTestId("new-workspace-project-picker-trigger");
|
||||
await expect(trigger).toBeVisible({ timeout: 30_000 });
|
||||
|
||||
@@ -1,153 +0,0 @@
|
||||
import { expect } from "@playwright/test";
|
||||
import { test } from "./fixtures";
|
||||
import {
|
||||
addApiKeyProvider,
|
||||
cleanupPaseoAgentProviders,
|
||||
expectModelProviderListed,
|
||||
openPaseoAgentSettings,
|
||||
seedChatGptProvider,
|
||||
startOAuthProviderSignIn,
|
||||
} from "./helpers/paseo-agent";
|
||||
|
||||
const CHATGPT_PROVIDER = "phase-e-chatgpt-ui";
|
||||
const CLOSE_PROVIDER = "phase-e-close-ui";
|
||||
const DEFAULT_OPENROUTER_PROVIDER = "OpenRouter";
|
||||
const DEFAULT_CHATGPT_PROVIDER = "ChatGPT";
|
||||
const RENAME_PROVIDER = "phase-e-rename-ui";
|
||||
|
||||
test.describe("Paseo Agent provider configuration", () => {
|
||||
const providerNamesToCleanup = new Set<string>();
|
||||
|
||||
test.afterEach(async () => {
|
||||
await cleanupPaseoAgentProviders(providerNamesToCleanup);
|
||||
providerNamesToCleanup.clear();
|
||||
});
|
||||
|
||||
test("adds an OpenRouter model provider from Settings", async ({ page }) => {
|
||||
providerNamesToCleanup.add(DEFAULT_OPENROUTER_PROVIDER);
|
||||
|
||||
await openPaseoAgentSettings(page);
|
||||
await addApiKeyProvider(page, {
|
||||
catalogId: "openrouter",
|
||||
apiKey: "sk-or-phase-e-write-only",
|
||||
});
|
||||
|
||||
await expectModelProviderListed(page, {
|
||||
name: DEFAULT_OPENROUTER_PROVIDER,
|
||||
providerLabel: "OpenRouter",
|
||||
modelCount: 0,
|
||||
auth: "Connected",
|
||||
});
|
||||
});
|
||||
|
||||
test("adds an API-key provider without prompting for a provider name", async ({ page }) => {
|
||||
providerNamesToCleanup.add(DEFAULT_OPENROUTER_PROVIDER);
|
||||
|
||||
await openPaseoAgentSettings(page);
|
||||
await page.getByRole("button", { name: "Add model provider", exact: true }).click();
|
||||
await page.getByTestId("paseo-agent-catalog-select-openrouter").click();
|
||||
|
||||
await expect(page.getByTestId("paseo-agent-provider-form")).toBeVisible();
|
||||
await expect(page.getByLabel("Provider name")).toHaveCount(0);
|
||||
await expect(page.getByLabel("Models")).toHaveCount(0);
|
||||
await page.getByLabel("API key").fill("sk-or-default-name");
|
||||
await page.getByRole("button", { name: "Save provider", exact: true }).click();
|
||||
|
||||
await expectModelProviderListed(page, {
|
||||
name: DEFAULT_OPENROUTER_PROVIDER,
|
||||
providerLabel: "OpenRouter",
|
||||
modelCount: 0,
|
||||
auth: "Connected",
|
||||
});
|
||||
});
|
||||
|
||||
test("starts a ChatGPT sign-in from Settings", async ({ page }) => {
|
||||
providerNamesToCleanup.add(DEFAULT_CHATGPT_PROVIDER);
|
||||
|
||||
await openPaseoAgentSettings(page);
|
||||
await startOAuthProviderSignIn(page, {
|
||||
catalogId: "chatgpt",
|
||||
});
|
||||
});
|
||||
|
||||
test("starts OAuth sign-in without prompting for a provider name", async ({ page }) => {
|
||||
providerNamesToCleanup.add(DEFAULT_CHATGPT_PROVIDER);
|
||||
|
||||
await openPaseoAgentSettings(page);
|
||||
await page.getByRole("button", { name: "Add model provider", exact: true }).click();
|
||||
await page.getByTestId("paseo-agent-catalog-select-chatgpt").click();
|
||||
|
||||
await expect(page.getByTestId("paseo-agent-provider-form")).toHaveCount(0);
|
||||
await expect(page.getByLabel("Provider name")).toHaveCount(0);
|
||||
});
|
||||
|
||||
test("shows user-facing provider picker rows", async ({ page }) => {
|
||||
await openPaseoAgentSettings(page);
|
||||
await page.getByRole("button", { name: "Add model provider", exact: true }).click();
|
||||
|
||||
const picker = page.getByTestId("paseo-agent-provider-picker");
|
||||
await expect(
|
||||
page
|
||||
.getByTestId("paseo-agent-catalog-entry-openrouter")
|
||||
.getByText("API key", { exact: true }),
|
||||
).toBeVisible();
|
||||
await expect(page.getByTestId("paseo-agent-catalog-icon-openrouter")).toBeVisible();
|
||||
await expect(
|
||||
page.getByTestId("paseo-agent-catalog-entry-chatgpt").getByText("Sign in", { exact: true }),
|
||||
).toBeVisible();
|
||||
await expect(page.getByTestId("paseo-agent-catalog-icon-chatgpt")).toBeVisible();
|
||||
await expect(picker.getByText("openai-codex-responses")).toHaveCount(0);
|
||||
await expect(picker.getByText(/\b\d+ models?\b/)).toHaveCount(0);
|
||||
});
|
||||
|
||||
test("shows a stored ChatGPT login as a read-only model provider row", async ({ page }) => {
|
||||
providerNamesToCleanup.add(CHATGPT_PROVIDER);
|
||||
|
||||
await seedChatGptProvider(CHATGPT_PROVIDER);
|
||||
await openPaseoAgentSettings(page);
|
||||
|
||||
await expectModelProviderListed(page, {
|
||||
name: CHATGPT_PROVIDER,
|
||||
providerLabel: "ChatGPT",
|
||||
modelCount: 1,
|
||||
auth: "Connected",
|
||||
});
|
||||
});
|
||||
|
||||
test("renames a configured provider row without changing its stored credential key", async ({
|
||||
page,
|
||||
}) => {
|
||||
providerNamesToCleanup.add(RENAME_PROVIDER);
|
||||
|
||||
await seedChatGptProvider(RENAME_PROVIDER);
|
||||
await openPaseoAgentSettings(page);
|
||||
await page.getByTestId(`paseo-agent-provider-rename-${RENAME_PROVIDER}`).click();
|
||||
await expect(page.getByTestId("paseo-agent-provider-rename-form")).toBeVisible();
|
||||
await page.getByLabel("Provider name").fill("Work account");
|
||||
await page.getByRole("button", { name: "Save", exact: true }).click();
|
||||
|
||||
await expectModelProviderListed(page, {
|
||||
name: "Work account",
|
||||
providerLabel: "ChatGPT",
|
||||
modelCount: 1,
|
||||
auth: "Connected",
|
||||
});
|
||||
});
|
||||
|
||||
test("closes the Paseo Agent settings sheet after providers load", async ({ page }) => {
|
||||
providerNamesToCleanup.add(CLOSE_PROVIDER);
|
||||
|
||||
await seedChatGptProvider(CLOSE_PROVIDER);
|
||||
await openPaseoAgentSettings(page);
|
||||
await expectModelProviderListed(page, {
|
||||
name: CLOSE_PROVIDER,
|
||||
providerLabel: "ChatGPT",
|
||||
modelCount: 1,
|
||||
auth: "Connected",
|
||||
});
|
||||
|
||||
await page.getByLabel("Close", { exact: true }).click();
|
||||
|
||||
await expect(page.getByTestId("paseo-agent-settings-sheet")).toHaveCount(0);
|
||||
});
|
||||
});
|
||||
@@ -76,7 +76,7 @@ test.describe("Schedules", () => {
|
||||
await page.goto(buildSchedulesRoute());
|
||||
const row = page.getByTestId(`schedule-row-${scheduleId}`);
|
||||
await expect(row).toBeVisible({ timeout: 30_000 });
|
||||
await expect(row).toContainText("ten-second-stream");
|
||||
await expect(row).toContainText(workspace.projectDisplayName, { timeout: 30_000 });
|
||||
|
||||
await row.click();
|
||||
await expect(page.getByTestId("schedule-form-sheet")).toBeVisible({ timeout: 10_000 });
|
||||
|
||||
@@ -5,7 +5,6 @@ import {
|
||||
buildFakeScheduleHostWorkspace,
|
||||
installFakeScheduleHost,
|
||||
} from "./helpers/schedule-fake-host";
|
||||
import { getServerId } from "./helpers/server-id";
|
||||
import { seedWorkspace, type SeededWorkspace } from "./helpers/seed-client";
|
||||
import { waitForSidebarHydration } from "./helpers/workspace-ui";
|
||||
import { buildSchedulesRoute } from "../src/utils/host-routes";
|
||||
@@ -82,9 +81,9 @@ test.describe("Schedules project target", () => {
|
||||
await page.getByRole("button", { name: "Schedules" }).click();
|
||||
await expect(page).toHaveURL(/\/schedules$/);
|
||||
await expect(page).not.toHaveURL(/\/h\//);
|
||||
await expect(page.getByTestId(`schedules-section-${getServerId()}`)).toBeVisible();
|
||||
await expect(page.getByTestId("schedules-empty")).toBeVisible();
|
||||
|
||||
await page.getByRole("button", { name: "New schedule" }).click();
|
||||
await page.getByTestId("schedules-empty-new").click();
|
||||
await expect(page.getByTestId("schedule-form-sheet")).toBeVisible({ timeout: 10_000 });
|
||||
await expect(page.getByTestId("schedule-cwd-trigger")).toHaveCount(0);
|
||||
|
||||
@@ -127,10 +126,8 @@ test.describe("Schedules project target", () => {
|
||||
label: "Fake host",
|
||||
port: fakePort,
|
||||
});
|
||||
await expect(page.getByTestId(`schedules-section-${fakeHost.serverId}`)).toBeVisible({
|
||||
timeout: 30_000,
|
||||
});
|
||||
await page.getByRole("button", { name: "New schedule" }).click();
|
||||
await expect(page.getByTestId("schedules-empty")).toBeVisible({ timeout: 30_000 });
|
||||
await page.getByTestId("schedules-empty-new").click();
|
||||
await expect(page.getByTestId("schedule-form-sheet")).toBeVisible({ timeout: 10_000 });
|
||||
|
||||
await page.getByRole("button", { name: /select project/i }).click();
|
||||
|
||||
@@ -38,7 +38,7 @@ async function seedSecondWorkspace(seeded: SeededWorkspace, title: string): Prom
|
||||
test.describe("Model B sidebar shape", () => {
|
||||
test.describe.configure({ timeout: 180_000 });
|
||||
|
||||
test("git and non-git projects both render as expandable parents; git keeps a per-row new-worktree icon, the global button covers both", async ({
|
||||
test("git and non-git projects both render as expandable parents, both show a per-row New workspace icon, and the global button covers both", async ({
|
||||
page,
|
||||
}) => {
|
||||
const gitProject = await seedWorkspace({ repoPrefix: "model-b-git-" });
|
||||
@@ -62,14 +62,17 @@ test.describe("Model B sidebar shape", () => {
|
||||
await expect(workspaceRow(page, nonGitProject.workspaceId)).toBeVisible({ timeout: 30_000 });
|
||||
await expect(workspaceRow(page, nonGitSecondId)).toBeVisible({ timeout: 30_000 });
|
||||
|
||||
// The per-project "+ New workspace" row is gone. The git project keeps a
|
||||
// per-row new-worktree icon (revealed on hover); the non-git project has
|
||||
// none, since worktree creation needs a git checkout.
|
||||
// Both projects show a per-row New workspace icon (revealed on hover): the
|
||||
// git project can branch off a worktree, and the non-git project can add
|
||||
// another workspace because the host supports workspaceMultiplicity.
|
||||
await projectRow(page, gitProject.projectId).hover();
|
||||
await expect(projectNewWorktreeIcon(page, gitProject.projectId)).toBeVisible({
|
||||
timeout: 30_000,
|
||||
});
|
||||
await expect(projectNewWorktreeIcon(page, nonGitProject.projectId)).toHaveCount(0);
|
||||
await projectRow(page, nonGitProject.projectId).hover();
|
||||
await expect(projectNewWorktreeIcon(page, nonGitProject.projectId)).toBeVisible({
|
||||
timeout: 30_000,
|
||||
});
|
||||
|
||||
// The global new-workspace button is the universal entry — present for both
|
||||
// kinds regardless of their per-row affordance.
|
||||
|
||||
118
packages/app/src/components/hosts/host-filter.tsx
Normal file
118
packages/app/src/components/hosts/host-filter.tsx
Normal file
@@ -0,0 +1,118 @@
|
||||
import { useCallback, useMemo, useRef, useState, type ReactElement } from "react";
|
||||
import { Pressable, Text, View, type PressableStateCallbackType } from "react-native";
|
||||
import { ChevronDown, Server } from "lucide-react-native";
|
||||
import { StyleSheet, withUnistyles } from "react-native-unistyles";
|
||||
import type { HostProfile } from "@/types/host-connection";
|
||||
import type { Theme } from "@/styles/theme";
|
||||
import {
|
||||
ALL_HOSTS_OPTION_ID,
|
||||
getHostPickerLabel,
|
||||
HostPicker,
|
||||
HostStatusDotSlot,
|
||||
} from "@/components/hosts/host-picker";
|
||||
|
||||
const ThemedServer = withUnistyles(Server);
|
||||
const ThemedChevronDown = withUnistyles(ChevronDown);
|
||||
const mutedColorMapping = (theme: Theme) => ({ color: theme.colors.foregroundMuted });
|
||||
|
||||
export interface HostFilterProps {
|
||||
hosts: HostProfile[];
|
||||
selectedHost: string;
|
||||
onSelectHost: (serverId: string) => void;
|
||||
triggerTestID?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* The "All hosts / <host>" filter pill shared by the History and Schedules
|
||||
* screens: an anchored HostPicker with `includeAllHost`, hidden by the caller
|
||||
* when only one host exists. Copies the History layout exactly.
|
||||
*/
|
||||
export function HostFilter({
|
||||
hosts,
|
||||
selectedHost,
|
||||
onSelectHost,
|
||||
triggerTestID,
|
||||
}: HostFilterProps): ReactElement {
|
||||
const [isFilterOpen, setIsFilterOpen] = useState(false);
|
||||
const filterAnchorRef = useRef<View>(null);
|
||||
|
||||
const selectedHostLabel = useMemo(
|
||||
() => getHostPickerLabel(hosts, selectedHost, { includeAllHost: true }),
|
||||
[hosts, selectedHost],
|
||||
);
|
||||
|
||||
const handleFilterOpen = useCallback(() => setIsFilterOpen(true), []);
|
||||
|
||||
const filterTriggerStyle = useCallback(
|
||||
({ pressed, hovered = false }: PressableStateCallbackType & { hovered?: boolean }) => [
|
||||
styles.filterTrigger,
|
||||
Boolean(hovered) && styles.filterTriggerHovered,
|
||||
pressed && styles.filterTriggerPressed,
|
||||
],
|
||||
[],
|
||||
);
|
||||
|
||||
return (
|
||||
<HostPicker
|
||||
hosts={hosts}
|
||||
value={selectedHost}
|
||||
onSelect={onSelectHost}
|
||||
open={isFilterOpen}
|
||||
onOpenChange={setIsFilterOpen}
|
||||
anchorRef={filterAnchorRef}
|
||||
includeAllHost
|
||||
searchable={false}
|
||||
title="Filter by host"
|
||||
desktopPlacement="bottom-start"
|
||||
>
|
||||
<View ref={filterAnchorRef} collapsable={false} style={styles.filterTriggerWrap}>
|
||||
<Pressable
|
||||
onPress={handleFilterOpen}
|
||||
style={filterTriggerStyle}
|
||||
testID={triggerTestID}
|
||||
accessibilityRole="button"
|
||||
accessibilityLabel={`Filter: ${selectedHostLabel}`}
|
||||
>
|
||||
{selectedHost === ALL_HOSTS_OPTION_ID ? (
|
||||
<ThemedServer size={14} uniProps={mutedColorMapping} />
|
||||
) : (
|
||||
<HostStatusDotSlot serverId={selectedHost} />
|
||||
)}
|
||||
<Text style={styles.filterTriggerText} numberOfLines={1}>
|
||||
{selectedHostLabel}
|
||||
</Text>
|
||||
<ThemedChevronDown size={14} uniProps={mutedColorMapping} />
|
||||
</Pressable>
|
||||
</View>
|
||||
</HostPicker>
|
||||
);
|
||||
}
|
||||
|
||||
const styles = StyleSheet.create((theme) => ({
|
||||
filterTriggerWrap: {
|
||||
alignSelf: "flex-start",
|
||||
},
|
||||
filterTrigger: {
|
||||
flexDirection: "row",
|
||||
alignItems: "center",
|
||||
gap: theme.spacing[1.5],
|
||||
alignSelf: "flex-start",
|
||||
paddingVertical: theme.spacing[1.5],
|
||||
paddingHorizontal: theme.spacing[3],
|
||||
borderRadius: theme.borderRadius.md,
|
||||
backgroundColor: theme.colors.surface1,
|
||||
borderWidth: theme.borderWidth[1],
|
||||
borderColor: theme.colors.border,
|
||||
},
|
||||
filterTriggerHovered: {
|
||||
backgroundColor: theme.colors.surface2,
|
||||
},
|
||||
filterTriggerPressed: {
|
||||
backgroundColor: theme.colors.surface3,
|
||||
},
|
||||
filterTriggerText: {
|
||||
color: theme.colors.foreground,
|
||||
fontSize: theme.fontSize.sm,
|
||||
fontWeight: theme.fontWeight.medium,
|
||||
},
|
||||
}));
|
||||
@@ -1,20 +0,0 @@
|
||||
import Svg, { Path } from "react-native-svg";
|
||||
|
||||
interface OpenRouterIconProps {
|
||||
size?: number;
|
||||
color?: string;
|
||||
}
|
||||
|
||||
export function OpenRouterIcon({ size = 16, color = "currentColor" }: OpenRouterIconProps) {
|
||||
return (
|
||||
<Svg width={size} height={size} viewBox="0 0 24 24" fill="none">
|
||||
<Path
|
||||
d="M3 7H15.5L21 12L15.5 17H3M10 3V21M15 7V17"
|
||||
stroke={color}
|
||||
strokeWidth={2}
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
/>
|
||||
</Svg>
|
||||
);
|
||||
}
|
||||
@@ -1,212 +0,0 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import type {
|
||||
PaseoAgentCatalogEntry,
|
||||
RedactedPaseoAgentProviderConfig,
|
||||
} from "@getpaseo/protocol/messages";
|
||||
|
||||
import {
|
||||
createPaseoAgentProviderInput,
|
||||
getPaseoAgentApiKeyAuth,
|
||||
getPaseoAgentOAuthAuth,
|
||||
isPaseoAgentCatalogEntrySupported,
|
||||
nextPaseoAgentProviderName,
|
||||
parsePaseoAgentModelIds,
|
||||
paseoAgentAuthBadge,
|
||||
paseoAgentProviderLabel,
|
||||
preferredPaseoAgentOAuthMode,
|
||||
} from "./paseo-agent-settings-sheet-model";
|
||||
|
||||
function catalogEntry(overrides: Partial<PaseoAgentCatalogEntry>): PaseoAgentCatalogEntry {
|
||||
return {
|
||||
id: "catalog-alpha",
|
||||
label: "Catalog Alpha",
|
||||
api: "responses",
|
||||
baseUrl: "https://alpha.example.test",
|
||||
auth: { kind: "api_key", envVar: "ALPHA_API_KEY" },
|
||||
models: [{ id: "alpha-fast", label: "Alpha Fast", reasoning: true }],
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
function providerConfig(
|
||||
overrides: Partial<RedactedPaseoAgentProviderConfig>,
|
||||
): RedactedPaseoAgentProviderConfig {
|
||||
return {
|
||||
name: "catalog-alpha",
|
||||
providerType: "catalog-alpha",
|
||||
models: [{ id: "alpha-fast" }],
|
||||
available: true,
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
describe("paseo-agent-settings-sheet-model", () => {
|
||||
it("recognizes supported catalog auth manifests", () => {
|
||||
const apiKeyEntry = catalogEntry({
|
||||
auth: {
|
||||
kind: "api_key",
|
||||
envVar: "ALPHA_API_KEY",
|
||||
keyUrl: "https://alpha.example.test/key",
|
||||
placeholder: "alpha-key",
|
||||
hint: "Paste the key from Alpha",
|
||||
},
|
||||
});
|
||||
const oauthEntry = catalogEntry({
|
||||
auth: { kind: "oauth", flow: "alpha-oauth" },
|
||||
});
|
||||
|
||||
expect(getPaseoAgentApiKeyAuth(apiKeyEntry)).toEqual({
|
||||
kind: "api_key",
|
||||
envVar: "ALPHA_API_KEY",
|
||||
keyUrl: "https://alpha.example.test/key",
|
||||
placeholder: "alpha-key",
|
||||
hint: "Paste the key from Alpha",
|
||||
});
|
||||
expect(getPaseoAgentOAuthAuth(oauthEntry)).toEqual({
|
||||
kind: "oauth",
|
||||
flow: "alpha-oauth",
|
||||
});
|
||||
expect(isPaseoAgentCatalogEntrySupported(apiKeyEntry)).toBe(true);
|
||||
expect(isPaseoAgentCatalogEntrySupported(oauthEntry)).toBe(true);
|
||||
});
|
||||
|
||||
it("keeps unknown catalog auth kinds visible but unsupported", () => {
|
||||
const entry = catalogEntry({ auth: { kind: "future_auth", prompt: "later" } });
|
||||
|
||||
expect(getPaseoAgentApiKeyAuth(entry)).toBeNull();
|
||||
expect(getPaseoAgentOAuthAuth(entry)).toBeNull();
|
||||
expect(isPaseoAgentCatalogEntrySupported(entry)).toBe(false);
|
||||
});
|
||||
|
||||
it("prefers device-code OAuth only for a relay connection", () => {
|
||||
expect(preferredPaseoAgentOAuthMode({ type: "relay" })).toBe("device_code");
|
||||
expect(preferredPaseoAgentOAuthMode({ type: "directSocket" })).toBe("browser");
|
||||
expect(preferredPaseoAgentOAuthMode({ type: "directPipe" })).toBe("browser");
|
||||
expect(preferredPaseoAgentOAuthMode({ type: "directTcp" })).toBe("browser");
|
||||
expect(preferredPaseoAgentOAuthMode(null)).toBe("browser");
|
||||
});
|
||||
|
||||
it("parses model ids from comma and newline separated input", () => {
|
||||
expect(
|
||||
parsePaseoAgentModelIds(`
|
||||
alpha/fast, beta/steady
|
||||
alpha/fast
|
||||
gamma/deep
|
||||
`),
|
||||
).toEqual(["alpha/fast", "beta/steady", "gamma/deep"]);
|
||||
});
|
||||
|
||||
it("uses the catalog label for the first provider instance", () => {
|
||||
expect(nextPaseoAgentProviderName(catalogEntry({ label: "Acme Cloud" }), [])).toBe(
|
||||
"Acme Cloud",
|
||||
);
|
||||
});
|
||||
|
||||
it("numbers later provider instances from the catalog label", () => {
|
||||
expect(
|
||||
nextPaseoAgentProviderName(catalogEntry({ label: "Acme Cloud" }), [
|
||||
providerConfig({ name: "Acme Cloud" }),
|
||||
providerConfig({ name: "Acme Cloud (1)" }),
|
||||
]),
|
||||
).toBe("Acme Cloud (2)");
|
||||
});
|
||||
|
||||
it("builds a generic provider payload with a trimmed explicit key", () => {
|
||||
expect(
|
||||
createPaseoAgentProviderInput({
|
||||
entry: catalogEntry({ id: "catalog-beta" }),
|
||||
name: " beta-main ",
|
||||
apiKey: " beta-secret ",
|
||||
}),
|
||||
).toEqual({
|
||||
name: "beta-main",
|
||||
providerType: "catalog-beta",
|
||||
options: {
|
||||
apiKey: "beta-secret",
|
||||
models: [{ id: "alpha-fast", label: "Alpha Fast", reasoning: true }],
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
it("builds a generic provider payload with custom model ids", () => {
|
||||
expect(
|
||||
createPaseoAgentProviderInput({
|
||||
entry: catalogEntry({ models: [] }),
|
||||
name: "alpha-main",
|
||||
apiKey: "alpha-secret",
|
||||
modelIds: ["alpha/fast", "beta/steady"],
|
||||
}),
|
||||
).toEqual({
|
||||
name: "alpha-main",
|
||||
providerType: "catalog-alpha",
|
||||
options: {
|
||||
apiKey: "alpha-secret",
|
||||
models: [{ id: "alpha/fast" }, { id: "beta/steady" }],
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
it("omits model overrides when the catalog has no defaults", () => {
|
||||
expect(
|
||||
createPaseoAgentProviderInput({
|
||||
entry: catalogEntry({ models: [] }),
|
||||
name: "alpha-main",
|
||||
apiKey: "alpha-secret",
|
||||
}),
|
||||
).toEqual({
|
||||
name: "alpha-main",
|
||||
providerType: "catalog-alpha",
|
||||
options: {
|
||||
apiKey: "alpha-secret",
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
it("builds a generic provider payload with an env reference for an empty key", () => {
|
||||
expect(
|
||||
createPaseoAgentProviderInput({
|
||||
entry: catalogEntry({ auth: { kind: "api_key", envVar: "BETA_API_KEY" } }),
|
||||
name: "beta-main",
|
||||
apiKey: " ",
|
||||
}),
|
||||
).toEqual({
|
||||
name: "beta-main",
|
||||
providerType: "catalog-alpha",
|
||||
options: {
|
||||
apiKey: "$BETA_API_KEY",
|
||||
models: [{ id: "alpha-fast", label: "Alpha Fast", reasoning: true }],
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
it("builds an oauth provider payload without key material", () => {
|
||||
expect(
|
||||
createPaseoAgentProviderInput({
|
||||
entry: catalogEntry({ auth: { kind: "oauth", flow: "alpha-oauth" } }),
|
||||
name: "alpha-login",
|
||||
}),
|
||||
).toEqual({
|
||||
name: "alpha-login",
|
||||
providerType: "catalog-alpha",
|
||||
options: {
|
||||
models: [{ id: "alpha-fast", label: "Alpha Fast", reasoning: true }],
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
it("uses catalog labels and generic auth badges for instance rows", () => {
|
||||
expect(paseoAgentProviderLabel(providerConfig({}), catalogEntry({}))).toBe("Catalog Alpha");
|
||||
expect(
|
||||
paseoAgentProviderLabel(providerConfig({ providerType: "custom-alpha" }), undefined),
|
||||
).toBe("custom-alpha");
|
||||
expect(paseoAgentAuthBadge({ kind: "api_key", configured: true })).toEqual({
|
||||
label: "Connected",
|
||||
variant: "success",
|
||||
});
|
||||
expect(paseoAgentAuthBadge({ kind: "oauth", configured: false })).toEqual({
|
||||
label: "Needs attention",
|
||||
variant: "error",
|
||||
});
|
||||
expect(paseoAgentAuthBadge(undefined)).toBeNull();
|
||||
});
|
||||
});
|
||||
@@ -1,139 +0,0 @@
|
||||
import type {
|
||||
PaseoAgentCatalogEntry,
|
||||
RedactedPaseoAgentProviderConfig,
|
||||
} from "@getpaseo/protocol/messages";
|
||||
import type { PaseoAgentSetProviderInput } from "@/hooks/use-paseo-agent-providers";
|
||||
|
||||
export interface PaseoAgentApiKeyAuthManifest {
|
||||
kind: "api_key";
|
||||
envVar: string;
|
||||
keyUrl?: string;
|
||||
placeholder?: string;
|
||||
hint?: string;
|
||||
}
|
||||
|
||||
export interface PaseoAgentOAuthAuthManifest {
|
||||
kind: "oauth";
|
||||
flow: string;
|
||||
}
|
||||
|
||||
export interface PaseoAgentAuthBadge {
|
||||
label: string;
|
||||
variant: "success" | "error" | "muted";
|
||||
}
|
||||
|
||||
export type PaseoAgentOAuthMode = "browser" | "device_code";
|
||||
|
||||
export interface PaseoAgentOAuthConnectionSignal {
|
||||
type: string;
|
||||
}
|
||||
|
||||
export function preferredPaseoAgentOAuthMode(
|
||||
activeConnection: PaseoAgentOAuthConnectionSignal | null,
|
||||
): PaseoAgentOAuthMode {
|
||||
return activeConnection?.type === "relay" ? "device_code" : "browser";
|
||||
}
|
||||
|
||||
export function getPaseoAgentApiKeyAuth(
|
||||
entry: PaseoAgentCatalogEntry,
|
||||
): PaseoAgentApiKeyAuthManifest | null {
|
||||
if (entry.auth.kind !== "api_key" || typeof entry.auth.envVar !== "string") {
|
||||
return null;
|
||||
}
|
||||
return {
|
||||
kind: "api_key",
|
||||
envVar: entry.auth.envVar,
|
||||
...(typeof entry.auth.keyUrl === "string" ? { keyUrl: entry.auth.keyUrl } : {}),
|
||||
...(typeof entry.auth.placeholder === "string" ? { placeholder: entry.auth.placeholder } : {}),
|
||||
...(typeof entry.auth.hint === "string" ? { hint: entry.auth.hint } : {}),
|
||||
};
|
||||
}
|
||||
|
||||
export function getPaseoAgentOAuthAuth(
|
||||
entry: PaseoAgentCatalogEntry,
|
||||
): PaseoAgentOAuthAuthManifest | null {
|
||||
if (entry.auth.kind !== "oauth" || typeof entry.auth.flow !== "string") {
|
||||
return null;
|
||||
}
|
||||
return { kind: "oauth", flow: entry.auth.flow };
|
||||
}
|
||||
|
||||
export function isPaseoAgentCatalogEntrySupported(entry: PaseoAgentCatalogEntry): boolean {
|
||||
return getPaseoAgentApiKeyAuth(entry) !== null || getPaseoAgentOAuthAuth(entry) !== null;
|
||||
}
|
||||
|
||||
export function paseoAgentProviderLabel(
|
||||
provider: RedactedPaseoAgentProviderConfig,
|
||||
catalogEntry: PaseoAgentCatalogEntry | undefined,
|
||||
): string {
|
||||
return catalogEntry?.label ?? provider.providerType;
|
||||
}
|
||||
|
||||
export function paseoAgentAuthBadge(
|
||||
auth: RedactedPaseoAgentProviderConfig["auth"],
|
||||
): PaseoAgentAuthBadge | null {
|
||||
if (!auth || auth.kind === "none") {
|
||||
return null;
|
||||
}
|
||||
return auth.configured
|
||||
? { label: "Connected", variant: "success" }
|
||||
: { label: "Needs attention", variant: "error" };
|
||||
}
|
||||
|
||||
export function parsePaseoAgentModelIds(raw: string): string[] {
|
||||
const seen = new Set<string>();
|
||||
const ids: string[] = [];
|
||||
for (const part of raw.split(/[\n,]/)) {
|
||||
const id = part.trim();
|
||||
if (id.length > 0 && !seen.has(id)) {
|
||||
seen.add(id);
|
||||
ids.push(id);
|
||||
}
|
||||
}
|
||||
return ids;
|
||||
}
|
||||
|
||||
export function nextPaseoAgentProviderName(
|
||||
entry: PaseoAgentCatalogEntry,
|
||||
providers: RedactedPaseoAgentProviderConfig[],
|
||||
): string {
|
||||
const baseName = entry.label.trim() || entry.id;
|
||||
const existingNames = new Set(providers.map((provider) => provider.name));
|
||||
if (!existingNames.has(baseName)) {
|
||||
return baseName;
|
||||
}
|
||||
|
||||
for (let index = 1; ; index += 1) {
|
||||
const candidate = `${baseName} (${index})`;
|
||||
if (!existingNames.has(candidate)) {
|
||||
return candidate;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export function createPaseoAgentProviderInput(input: {
|
||||
entry: PaseoAgentCatalogEntry;
|
||||
name: string;
|
||||
apiKey?: string;
|
||||
modelIds?: string[];
|
||||
}): PaseoAgentSetProviderInput {
|
||||
const apiKeyAuth = getPaseoAgentApiKeyAuth(input.entry);
|
||||
const trimmedKey = input.apiKey?.trim() ?? "";
|
||||
let apiKey: string | undefined;
|
||||
if (apiKeyAuth) {
|
||||
apiKey = trimmedKey.length > 0 ? trimmedKey : `$${apiKeyAuth.envVar}`;
|
||||
}
|
||||
const models =
|
||||
input.modelIds && input.modelIds.length > 0
|
||||
? input.modelIds.map((id) => ({ id }))
|
||||
: input.entry.models.map((model) => ({ ...model }));
|
||||
|
||||
return {
|
||||
name: input.name.trim(),
|
||||
providerType: input.entry.id,
|
||||
options: {
|
||||
...(models.length > 0 ? { models } : {}),
|
||||
...(apiKey ? { apiKey } : {}),
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -1,571 +0,0 @@
|
||||
/**
|
||||
* @vitest-environment jsdom
|
||||
*/
|
||||
import React, { type ReactNode } from "react";
|
||||
import { cleanup, fireEvent, render, screen, waitFor } from "@testing-library/react";
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import type {
|
||||
PaseoAgentCatalogEntry,
|
||||
RedactedPaseoAgentProviderConfig,
|
||||
} from "@getpaseo/protocol/messages";
|
||||
import type {
|
||||
PaseoAgentOAuthCompleteResult,
|
||||
PaseoAgentOAuthStartResult,
|
||||
PaseoAgentSetProviderInput,
|
||||
} from "@/hooks/use-paseo-agent-providers";
|
||||
|
||||
interface PaseoAgentProvidersHookMock {
|
||||
supported: boolean;
|
||||
catalogSupported: boolean;
|
||||
providers: RedactedPaseoAgentProviderConfig[];
|
||||
catalog: PaseoAgentCatalogEntry[];
|
||||
defaultModel: string | null;
|
||||
isLoading: boolean;
|
||||
isCatalogLoading: boolean;
|
||||
error: string | null;
|
||||
catalogError: string | null;
|
||||
refresh: () => Promise<void>;
|
||||
setProvider: (
|
||||
input: PaseoAgentSetProviderInput,
|
||||
) => Promise<RedactedPaseoAgentProviderConfig | null>;
|
||||
startOAuth: (name: string, mode?: string) => Promise<PaseoAgentOAuthStartResult>;
|
||||
completeOAuth: (name: string) => Promise<PaseoAgentOAuthCompleteResult>;
|
||||
}
|
||||
|
||||
const { hookState, hostRuntimeSnapshot, openExternalUrls, theme } = vi.hoisted(() => {
|
||||
const initialHookState: { current: PaseoAgentProvidersHookMock } = {
|
||||
current: {
|
||||
supported: true,
|
||||
catalogSupported: true,
|
||||
providers: [] as RedactedPaseoAgentProviderConfig[],
|
||||
catalog: [] as PaseoAgentCatalogEntry[],
|
||||
defaultModel: null as string | null,
|
||||
isLoading: false,
|
||||
isCatalogLoading: false,
|
||||
error: null as string | null,
|
||||
catalogError: null as string | null,
|
||||
refresh: vi.fn(async () => undefined),
|
||||
setProvider: vi.fn(async () => null as RedactedPaseoAgentProviderConfig | null),
|
||||
startOAuth: vi.fn(
|
||||
async (): Promise<PaseoAgentOAuthStartResult> => ({
|
||||
requestId: "oauth-start",
|
||||
success: true,
|
||||
name: "catalog-login",
|
||||
authorization: null,
|
||||
error: null,
|
||||
}),
|
||||
),
|
||||
completeOAuth: vi.fn(
|
||||
async (): Promise<PaseoAgentOAuthCompleteResult> => ({
|
||||
requestId: "oauth-complete",
|
||||
success: true,
|
||||
name: "catalog-login",
|
||||
auth: { kind: "oauth", configured: true },
|
||||
error: null,
|
||||
}),
|
||||
),
|
||||
},
|
||||
};
|
||||
|
||||
return {
|
||||
hookState: initialHookState,
|
||||
hostRuntimeSnapshot: {
|
||||
current: {
|
||||
activeConnection: { type: "directSocket", endpoint: "socket", display: "socket" },
|
||||
} as {
|
||||
activeConnection: { type: string; endpoint: string; display: string } | null;
|
||||
} | null,
|
||||
},
|
||||
openExternalUrls: [] as string[],
|
||||
theme: {
|
||||
spacing: { 1: 4, 2: 8, 3: 12, 4: 16 },
|
||||
borderRadius: { md: 6, lg: 8 },
|
||||
fontSize: { xs: 11, sm: 13, base: 15 },
|
||||
fontWeight: { normal: "400", medium: "500" },
|
||||
opacity: { 50: 0.5 },
|
||||
colors: {
|
||||
foreground: "#fff",
|
||||
foregroundMuted: "#aaa",
|
||||
surface1: "#111",
|
||||
surface2: "#222",
|
||||
border: "#444",
|
||||
destructive: "#f00",
|
||||
statusSuccess: "#0f0",
|
||||
},
|
||||
},
|
||||
};
|
||||
});
|
||||
|
||||
vi.mock("react-native", () => ({
|
||||
View: ({
|
||||
children,
|
||||
testID,
|
||||
role,
|
||||
accessibilityRole,
|
||||
accessibilityLabel,
|
||||
}: {
|
||||
children?: ReactNode;
|
||||
testID?: string;
|
||||
role?: string;
|
||||
accessibilityRole?: string;
|
||||
accessibilityLabel?: string;
|
||||
}) =>
|
||||
React.createElement(
|
||||
"div",
|
||||
{
|
||||
"data-testid": testID,
|
||||
role: role ?? accessibilityRole,
|
||||
"aria-label": accessibilityLabel,
|
||||
},
|
||||
children,
|
||||
),
|
||||
Text: ({ children, testID }: { children?: ReactNode; testID?: string; numberOfLines?: number }) =>
|
||||
React.createElement("span", { "data-testid": testID }, children),
|
||||
}));
|
||||
|
||||
vi.mock("react-native-unistyles", () => ({
|
||||
StyleSheet: {
|
||||
create: (factory: unknown) =>
|
||||
typeof factory === "function" ? (factory as (t: typeof theme) => unknown)(theme) : factory,
|
||||
},
|
||||
withUnistyles:
|
||||
(Component: React.ComponentType<Record<string, unknown>>) =>
|
||||
({
|
||||
uniProps,
|
||||
...rest
|
||||
}: {
|
||||
uniProps?: (theme: unknown) => Record<string, unknown>;
|
||||
} & Record<string, unknown>) => {
|
||||
const themed = uniProps ? uniProps(theme) : {};
|
||||
return React.createElement(Component, { ...rest, ...themed });
|
||||
},
|
||||
}));
|
||||
|
||||
vi.mock("lucide-react-native", () => {
|
||||
const icon = (name: string) => {
|
||||
const Icon = () => React.createElement("span", { "data-icon": name });
|
||||
Icon.displayName = name;
|
||||
return Icon;
|
||||
};
|
||||
return {
|
||||
Bot: icon("Bot"),
|
||||
Plus: icon("Plus"),
|
||||
};
|
||||
});
|
||||
|
||||
vi.mock("@/components/adaptive-modal-sheet", () => ({
|
||||
AdaptiveModalSheet: ({
|
||||
visible,
|
||||
header,
|
||||
children,
|
||||
footer,
|
||||
testID,
|
||||
}: {
|
||||
visible: boolean;
|
||||
header?: { title: string; back?: { label?: string; onPress: () => void } };
|
||||
children: ReactNode;
|
||||
footer?: ReactNode;
|
||||
testID?: string;
|
||||
}) =>
|
||||
visible ? (
|
||||
<section data-testid={testID}>
|
||||
<h1>{header?.title}</h1>
|
||||
{header?.back ? (
|
||||
<button type="button" onClick={header.back.onPress}>
|
||||
{header.back.label ?? "Back"}
|
||||
</button>
|
||||
) : null}
|
||||
{children}
|
||||
{footer ? <footer>{footer}</footer> : null}
|
||||
</section>
|
||||
) : null,
|
||||
AdaptiveTextInput: ({
|
||||
value,
|
||||
onChangeText,
|
||||
accessibilityLabel,
|
||||
testID,
|
||||
placeholder,
|
||||
secureTextEntry,
|
||||
editable = true,
|
||||
multiline,
|
||||
}: {
|
||||
value?: string;
|
||||
onChangeText?: (value: string) => void;
|
||||
accessibilityLabel?: string;
|
||||
testID?: string;
|
||||
placeholder?: string;
|
||||
secureTextEntry?: boolean;
|
||||
editable?: boolean;
|
||||
multiline?: boolean;
|
||||
}) => {
|
||||
function handleChange(event: React.ChangeEvent<HTMLInputElement | HTMLTextAreaElement>): void {
|
||||
onChangeText?.(event.currentTarget.value);
|
||||
}
|
||||
|
||||
const inputProps = {
|
||||
"aria-label": accessibilityLabel,
|
||||
"data-testid": testID,
|
||||
disabled: !editable,
|
||||
onChange: handleChange,
|
||||
placeholder,
|
||||
value: value ?? "",
|
||||
};
|
||||
|
||||
if (multiline) {
|
||||
return React.createElement("textarea", inputProps);
|
||||
}
|
||||
|
||||
return React.createElement("input", {
|
||||
...inputProps,
|
||||
type: secureTextEntry ? "password" : "text",
|
||||
});
|
||||
},
|
||||
}));
|
||||
|
||||
vi.mock("@/components/provider-icons", () => ({
|
||||
getProviderIcon: (provider: string) => () =>
|
||||
React.createElement("span", { "data-icon": `provider-${provider}` }),
|
||||
}));
|
||||
|
||||
vi.mock("@/components/provider-icon-name", () => ({
|
||||
resolveProviderIconName: (provider: string) =>
|
||||
provider === "known-icon" ? { kind: "builtin", id: provider } : { kind: "bot" },
|
||||
}));
|
||||
|
||||
vi.mock("@/components/ui/external-link", () => ({
|
||||
ExternalLink: ({
|
||||
href,
|
||||
label,
|
||||
testID,
|
||||
accessibilityLabel,
|
||||
}: {
|
||||
href: string;
|
||||
label: string;
|
||||
testID?: string;
|
||||
accessibilityLabel?: string;
|
||||
}) => (
|
||||
<a href={href} data-testid={testID} aria-label={accessibilityLabel ?? label}>
|
||||
{label}
|
||||
</a>
|
||||
),
|
||||
}));
|
||||
|
||||
vi.mock("@/components/ui/button", () => ({
|
||||
Button: ({
|
||||
children,
|
||||
disabled,
|
||||
onPress,
|
||||
testID,
|
||||
}: {
|
||||
children?: ReactNode;
|
||||
disabled?: boolean;
|
||||
onPress?: () => void;
|
||||
testID?: string;
|
||||
}) => (
|
||||
<button type="button" disabled={disabled} data-testid={testID} onClick={onPress}>
|
||||
{children}
|
||||
</button>
|
||||
),
|
||||
}));
|
||||
|
||||
vi.mock("@/components/ui/status-badge", () => ({
|
||||
StatusBadge: ({ label }: { label: string }) => <span>{label}</span>,
|
||||
}));
|
||||
|
||||
vi.mock("@/hooks/use-paseo-agent-providers", () => ({
|
||||
usePaseoAgentProviders: () => hookState.current,
|
||||
}));
|
||||
|
||||
vi.mock("@/runtime/host-runtime", () => ({
|
||||
useHostRuntimeSnapshot: () => hostRuntimeSnapshot.current,
|
||||
}));
|
||||
|
||||
vi.mock("@/utils/open-external-url", () => ({
|
||||
openExternalUrl: async (url: string) => {
|
||||
openExternalUrls.push(url);
|
||||
},
|
||||
}));
|
||||
|
||||
import { PaseoAgentSettingsSheet } from "./paseo-agent-settings-sheet";
|
||||
|
||||
function catalogEntry(overrides: Partial<PaseoAgentCatalogEntry>): PaseoAgentCatalogEntry {
|
||||
return {
|
||||
id: "catalog-alpha",
|
||||
label: "Catalog Alpha",
|
||||
iconName: "known-icon",
|
||||
docsUrl: "https://alpha.example.test/docs",
|
||||
api: "responses",
|
||||
baseUrl: "https://alpha.example.test",
|
||||
auth: { kind: "api_key", envVar: "ALPHA_API_KEY", keyUrl: "https://alpha.example.test/key" },
|
||||
models: [{ id: "alpha-fast", label: "Alpha Fast" }],
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
function providerConfig(
|
||||
overrides: Partial<RedactedPaseoAgentProviderConfig>,
|
||||
): RedactedPaseoAgentProviderConfig {
|
||||
return {
|
||||
name: "catalog-alpha",
|
||||
providerType: "catalog-alpha",
|
||||
models: [{ id: "alpha-fast" }],
|
||||
auth: { kind: "api_key", configured: true },
|
||||
available: true,
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
function renderSheet() {
|
||||
return render(<PaseoAgentSettingsSheet serverId="server-1" visible onClose={vi.fn()} />);
|
||||
}
|
||||
|
||||
function resetHookState() {
|
||||
hookState.current.supported = true;
|
||||
hookState.current.catalogSupported = true;
|
||||
hookState.current.providers = [];
|
||||
hookState.current.catalog = [];
|
||||
hookState.current.defaultModel = null;
|
||||
hookState.current.isLoading = false;
|
||||
hookState.current.isCatalogLoading = false;
|
||||
hookState.current.error = null;
|
||||
hookState.current.catalogError = null;
|
||||
hookState.current.refresh = vi.fn(async () => undefined);
|
||||
hookState.current.setProvider = vi.fn(async () => providerConfig({}));
|
||||
hookState.current.startOAuth = vi.fn(async () => ({
|
||||
requestId: "oauth-start",
|
||||
success: true,
|
||||
name: "catalog-login",
|
||||
authorization: null,
|
||||
error: null,
|
||||
}));
|
||||
hookState.current.completeOAuth = vi.fn(async () => ({
|
||||
requestId: "oauth-complete",
|
||||
success: true,
|
||||
name: "catalog-login",
|
||||
auth: { kind: "oauth", configured: true },
|
||||
error: null,
|
||||
}));
|
||||
hostRuntimeSnapshot.current = {
|
||||
activeConnection: { type: "directSocket", endpoint: "socket", display: "socket" },
|
||||
};
|
||||
openExternalUrls.splice(0);
|
||||
}
|
||||
|
||||
describe("PaseoAgentSettingsSheet", () => {
|
||||
beforeEach(() => {
|
||||
resetHookState();
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
cleanup();
|
||||
});
|
||||
|
||||
it("shows the catalog feature-gate message exactly once", () => {
|
||||
hookState.current.catalogSupported = false;
|
||||
|
||||
renderSheet();
|
||||
|
||||
expect(screen.getAllByText("Update the Paseo daemon to use this.")).toHaveLength(1);
|
||||
expect(screen.queryByRole("button", { name: "Add model provider" })).toBeNull();
|
||||
});
|
||||
|
||||
it("lists catalog entries in the provider picker and disables unknown auth kinds", () => {
|
||||
hookState.current.catalog = [
|
||||
catalogEntry({ label: "Alpha Provider" }),
|
||||
catalogEntry({
|
||||
id: "catalog-future",
|
||||
label: "Future Provider",
|
||||
auth: { kind: "future_auth", prompt: "not yet" },
|
||||
}),
|
||||
];
|
||||
|
||||
renderSheet();
|
||||
fireEvent.click(screen.getByRole("button", { name: "Add model provider" }));
|
||||
|
||||
expect(screen.getByText("Alpha Provider")).toBeTruthy();
|
||||
expect(screen.getByText("Future Provider")).toBeTruthy();
|
||||
expect(screen.getByTestId("paseo-agent-catalog-docs-catalog-alpha").getAttribute("href")).toBe(
|
||||
"https://alpha.example.test/docs",
|
||||
);
|
||||
expect(screen.getByText("Update the app to use this provider")).toBeTruthy();
|
||||
expect(
|
||||
(screen.getByTestId("paseo-agent-catalog-select-catalog-future") as HTMLButtonElement)
|
||||
.disabled,
|
||||
).toBe(true);
|
||||
});
|
||||
|
||||
it("submits an api-key provider with an explicit key", async () => {
|
||||
hookState.current.catalog = [catalogEntry({ models: [] })];
|
||||
|
||||
renderSheet();
|
||||
fireEvent.click(screen.getByRole("button", { name: "Add model provider" }));
|
||||
fireEvent.click(screen.getByTestId("paseo-agent-catalog-select-catalog-alpha"));
|
||||
expect(screen.queryByLabelText("Provider name")).toBeNull();
|
||||
expect(screen.queryByLabelText("Models")).toBeNull();
|
||||
fireEvent.change(screen.getByLabelText("API key"), {
|
||||
target: { value: " alpha-secret " },
|
||||
});
|
||||
fireEvent.click(screen.getByRole("button", { name: "Save provider" }));
|
||||
|
||||
await waitFor(() => {
|
||||
expect(hookState.current.setProvider).toHaveBeenCalledWith({
|
||||
name: "Catalog Alpha",
|
||||
providerType: "catalog-alpha",
|
||||
options: {
|
||||
apiKey: "alpha-secret",
|
||||
},
|
||||
} satisfies PaseoAgentSetProviderInput);
|
||||
});
|
||||
});
|
||||
|
||||
it("submits an empty api-key field as a host env reference", async () => {
|
||||
hookState.current.catalog = [catalogEntry({})];
|
||||
|
||||
renderSheet();
|
||||
fireEvent.click(screen.getByRole("button", { name: "Add model provider" }));
|
||||
fireEvent.click(screen.getByTestId("paseo-agent-catalog-select-catalog-alpha"));
|
||||
fireEvent.click(screen.getByRole("button", { name: "Save provider" }));
|
||||
|
||||
await waitFor(() => {
|
||||
expect(hookState.current.setProvider).toHaveBeenCalledWith({
|
||||
name: "Catalog Alpha",
|
||||
providerType: "catalog-alpha",
|
||||
options: {
|
||||
apiKey: "$ALPHA_API_KEY",
|
||||
models: [{ id: "alpha-fast", label: "Alpha Fast" }],
|
||||
},
|
||||
} satisfies PaseoAgentSetProviderInput);
|
||||
});
|
||||
});
|
||||
|
||||
it("starts browser oauth, opens the returned auth URL, and renders it by authorization kind", async () => {
|
||||
hookState.current.catalog = [
|
||||
catalogEntry({
|
||||
id: "catalog-login",
|
||||
label: "Catalog Login",
|
||||
auth: { kind: "oauth", flow: "login-flow" },
|
||||
}),
|
||||
];
|
||||
hookState.current.startOAuth = vi.fn(async () => ({
|
||||
requestId: "oauth-start",
|
||||
success: true,
|
||||
name: "catalog-login",
|
||||
authorization: {
|
||||
kind: "auth_url",
|
||||
url: "https://login.example.test/oauth/authorize?state=abc",
|
||||
instructions: "Open the sign-in page",
|
||||
},
|
||||
error: null,
|
||||
}));
|
||||
|
||||
renderSheet();
|
||||
fireEvent.click(screen.getByRole("button", { name: "Add model provider" }));
|
||||
fireEvent.click(screen.getByTestId("paseo-agent-catalog-select-catalog-login"));
|
||||
fireEvent.click(screen.getByRole("button", { name: "Sign in with browser" }));
|
||||
|
||||
await waitFor(() => {
|
||||
expect(openExternalUrls).toEqual(["https://login.example.test/oauth/authorize?state=abc"]);
|
||||
expect(hookState.current.startOAuth).toHaveBeenCalledWith("Catalog Login", "browser");
|
||||
});
|
||||
expect(screen.getByText("https://login.example.test/oauth/authorize?state=abc")).toBeTruthy();
|
||||
expect(screen.getByTestId("paseo-agent-oauth-url").getAttribute("href")).toBe(
|
||||
"https://login.example.test/oauth/authorize?state=abc",
|
||||
);
|
||||
});
|
||||
|
||||
it("renders a device-code oauth authorization and completes it", async () => {
|
||||
hookState.current.catalog = [
|
||||
catalogEntry({
|
||||
id: "catalog-login",
|
||||
label: "Catalog Login",
|
||||
auth: { kind: "oauth", flow: "login-flow" },
|
||||
}),
|
||||
];
|
||||
hookState.current.startOAuth = vi.fn(async () => ({
|
||||
requestId: "oauth-start",
|
||||
success: true,
|
||||
name: "catalog-login",
|
||||
authorization: {
|
||||
kind: "device_code",
|
||||
userCode: "CODE-123",
|
||||
verificationUri: "https://login.example.test/device",
|
||||
intervalSeconds: 5,
|
||||
expiresInSeconds: 600,
|
||||
instructions: "Enter the code",
|
||||
},
|
||||
error: null,
|
||||
}));
|
||||
|
||||
renderSheet();
|
||||
fireEvent.click(screen.getByRole("button", { name: "Add model provider" }));
|
||||
fireEvent.click(screen.getByTestId("paseo-agent-catalog-select-catalog-login"));
|
||||
fireEvent.click(screen.getByRole("button", { name: "Use a code instead" }));
|
||||
|
||||
expect((await screen.findByTestId("paseo-agent-oauth-user-code")).textContent).toBe("CODE-123");
|
||||
expect(screen.getByText("https://login.example.test/device")).toBeTruthy();
|
||||
fireEvent.click(screen.getByRole("button", { name: "Complete sign in" }));
|
||||
|
||||
await waitFor(() => {
|
||||
expect(hookState.current.setProvider).toHaveBeenCalledWith({
|
||||
name: "Catalog Login",
|
||||
providerType: "catalog-login",
|
||||
options: {
|
||||
models: [{ id: "alpha-fast", label: "Alpha Fast" }],
|
||||
},
|
||||
} satisfies PaseoAgentSetProviderInput);
|
||||
expect(hookState.current.startOAuth).toHaveBeenCalledWith("Catalog Login", "device_code");
|
||||
expect(hookState.current.completeOAuth).toHaveBeenCalledWith("Catalog Login");
|
||||
});
|
||||
});
|
||||
|
||||
it("offers device-code first over relay while keeping browser available", () => {
|
||||
hostRuntimeSnapshot.current = {
|
||||
activeConnection: { type: "relay", endpoint: "relay.example.test:443", display: "relay" },
|
||||
};
|
||||
hookState.current.catalog = [
|
||||
catalogEntry({
|
||||
id: "catalog-login",
|
||||
label: "Catalog Login",
|
||||
auth: { kind: "oauth", flow: "login-flow" },
|
||||
}),
|
||||
];
|
||||
|
||||
renderSheet();
|
||||
fireEvent.click(screen.getByRole("button", { name: "Add model provider" }));
|
||||
fireEvent.click(screen.getByTestId("paseo-agent-catalog-select-catalog-login"));
|
||||
|
||||
const actionButtons = screen
|
||||
.getByTestId("paseo-agent-oauth-sign-in")
|
||||
.querySelectorAll("button");
|
||||
expect(Array.from(actionButtons).map((button) => button.textContent)).toEqual([
|
||||
"Providers",
|
||||
"Cancel",
|
||||
"Use a code instead",
|
||||
"Sign in with browser",
|
||||
]);
|
||||
});
|
||||
|
||||
it("shows auth-state badges from configured instances", () => {
|
||||
hookState.current.catalog = [
|
||||
catalogEntry({ id: "catalog-alpha", label: "Catalog Alpha" }),
|
||||
catalogEntry({ id: "catalog-beta", label: "Catalog Beta" }),
|
||||
];
|
||||
hookState.current.providers = [
|
||||
providerConfig({ name: "alpha", providerType: "catalog-alpha" }),
|
||||
providerConfig({
|
||||
name: "beta",
|
||||
providerType: "catalog-beta",
|
||||
auth: { kind: "api_key", configured: false },
|
||||
}),
|
||||
providerConfig({ name: "legacy", providerType: "legacy-provider", auth: undefined }),
|
||||
];
|
||||
|
||||
renderSheet();
|
||||
|
||||
expect(screen.getByText("Catalog Alpha · 1 model")).toBeTruthy();
|
||||
expect(screen.getByText("Catalog Beta · 1 model")).toBeTruthy();
|
||||
expect(screen.getByText("legacy-provider · 1 model")).toBeTruthy();
|
||||
expect(screen.getByText("Connected")).toBeTruthy();
|
||||
expect(screen.getByText("Needs attention")).toBeTruthy();
|
||||
});
|
||||
});
|
||||
File diff suppressed because it is too large
Load Diff
@@ -13,17 +13,6 @@ describe("resolveProviderIconName", () => {
|
||||
expect(resolveProviderIconName("claude")).toEqual({ kind: "builtin", id: "claude" });
|
||||
expect(resolveProviderIconName("omp")).toEqual({ kind: "builtin", id: "omp" });
|
||||
expect(resolveProviderIconName("minimax")).toEqual({ kind: "builtin", id: "minimax" });
|
||||
expect(resolveProviderIconName("paseo")).toEqual({ kind: "builtin", id: "paseo" });
|
||||
});
|
||||
|
||||
it("resolves Paseo Agent model-provider catalog icon names", () => {
|
||||
expect(resolveProviderIconName("openai")).toEqual({ kind: "builtin", id: "openai" });
|
||||
expect(resolveProviderIconName("openrouter")).toEqual({
|
||||
kind: "builtin",
|
||||
id: "openrouter",
|
||||
});
|
||||
expect(resolveProviderIconName("kimi")).toEqual({ kind: "catalog", id: "kimi" });
|
||||
expect(resolveProviderIconName("opencode")).toEqual({ kind: "builtin", id: "opencode" });
|
||||
});
|
||||
|
||||
it("returns the catalog identifier for ACP catalog provider ids that ship an icon", () => {
|
||||
|
||||
@@ -6,9 +6,7 @@ import { CodexIcon } from "@/components/icons/codex-icon";
|
||||
import { CopilotIcon } from "@/components/icons/copilot-icon";
|
||||
import { MiniMaxIcon } from "@/components/icons/minimax-icon";
|
||||
import { OpenCodeIcon } from "@/components/icons/opencode-icon";
|
||||
import { OpenRouterIcon } from "@/components/icons/openrouter-icon";
|
||||
import { OmpIcon } from "@/components/icons/omp-icon";
|
||||
import { PaseoLogo } from "@/components/icons/paseo-logo";
|
||||
import { PiIcon } from "@/components/icons/pi-icon";
|
||||
import { ACP_PROVIDER_CATALOG } from "@/data/acp-provider-catalog";
|
||||
import { resolveProviderIconName } from "@/components/provider-icon-name";
|
||||
@@ -27,10 +25,7 @@ const BUILTIN_PROVIDER_ICONS: Record<string, ProviderIconComponent> = {
|
||||
kiro: PackagePlus,
|
||||
minimax: MiniMaxIcon as unknown as ProviderIconComponent,
|
||||
omp: OmpIcon as unknown as ProviderIconComponent,
|
||||
openai: CodexIcon as unknown as ProviderIconComponent,
|
||||
opencode: OpenCodeIcon as unknown as ProviderIconComponent,
|
||||
openrouter: OpenRouterIcon as unknown as ProviderIconComponent,
|
||||
paseo: PaseoLogo as unknown as ProviderIconComponent,
|
||||
pi: PiIcon as unknown as ProviderIconComponent,
|
||||
};
|
||||
|
||||
|
||||
@@ -1,10 +1,7 @@
|
||||
import { useCallback } from "react";
|
||||
import { PaseoAgentSettingsSheet } from "@/components/paseo-agent-settings-sheet";
|
||||
import { ProviderDiagnosticSheet } from "@/components/provider-diagnostic-sheet";
|
||||
import { useProviderSettingsStore } from "@/stores/provider-settings-store";
|
||||
|
||||
const PASEO_AGENT_PROVIDER = "paseo";
|
||||
|
||||
export function ProviderSettingsHost() {
|
||||
const serverId = useProviderSettingsStore((state) => state.serverId);
|
||||
const provider = useProviderSettingsStore((state) => state.provider);
|
||||
@@ -19,10 +16,6 @@ export function ProviderSettingsHost() {
|
||||
return null;
|
||||
}
|
||||
|
||||
if (provider === PASEO_AGENT_PROVIDER) {
|
||||
return <PaseoAgentSettingsSheet serverId={serverId} visible={visible} onClose={handleClose} />;
|
||||
}
|
||||
|
||||
return (
|
||||
<ProviderDiagnosticSheet
|
||||
key={`${serverId}:${provider}`}
|
||||
|
||||
@@ -4,7 +4,6 @@ import type { PressableStateCallbackType } from "react-native";
|
||||
import { StyleSheet } from "react-native-unistyles";
|
||||
import { AdaptiveTextInput } from "@/components/adaptive-modal-sheet";
|
||||
import { SegmentedControl } from "@/components/ui/segmented-control";
|
||||
import { isWeb } from "@/constants/platform";
|
||||
import {
|
||||
describeCron,
|
||||
everyMsToParts,
|
||||
@@ -200,7 +199,6 @@ export function CadenceEditor({ value, onChange, error }: CadenceEditorProps) {
|
||||
value={intervalUnit}
|
||||
onValueChange={handleUnitChange}
|
||||
options={UNIT_OPTIONS}
|
||||
style={styles.unitControl}
|
||||
testID="cadence-interval-unit"
|
||||
/>
|
||||
</View>
|
||||
@@ -283,8 +281,6 @@ function CronPresetChip({
|
||||
);
|
||||
}
|
||||
|
||||
const MONOSPACE_FONT = isWeb ? "ui-monospace, SFMono-Regular, Menlo, monospace" : "Menlo";
|
||||
|
||||
const styles = StyleSheet.create((theme) => ({
|
||||
container: {
|
||||
gap: theme.spacing[3],
|
||||
@@ -308,15 +304,10 @@ const styles = StyleSheet.create((theme) => ({
|
||||
borderColor: theme.colors.border,
|
||||
fontSize: theme.fontSize.base,
|
||||
},
|
||||
// Both cadence segmented controls hug their options and stand at the form's
|
||||
// field height, so the interval row reads as input + toggle rather than a
|
||||
// full-width track with the controls floating inside it.
|
||||
// The mode toggle hugs its options at the left rather than stretching to a
|
||||
// full-width track; the interval row then reads as input + toggle.
|
||||
modeControl: {
|
||||
alignSelf: "flex-start",
|
||||
height: 44,
|
||||
},
|
||||
unitControl: {
|
||||
height: 44,
|
||||
},
|
||||
presetRow: {
|
||||
flexDirection: "row",
|
||||
@@ -337,9 +328,11 @@ const styles = StyleSheet.create((theme) => ({
|
||||
chipHover: {
|
||||
backgroundColor: theme.colors.surface3,
|
||||
},
|
||||
// Selected preset reads as a chosen surface, not a second accent fill
|
||||
// competing with the sheet's primary CTA.
|
||||
chipSelected: {
|
||||
backgroundColor: theme.colors.accent,
|
||||
borderColor: theme.colors.accent,
|
||||
backgroundColor: theme.colors.surface3,
|
||||
borderColor: theme.colors.borderAccent,
|
||||
},
|
||||
chipLabel: {
|
||||
fontSize: theme.fontSize.xs,
|
||||
@@ -347,7 +340,7 @@ const styles = StyleSheet.create((theme) => ({
|
||||
color: theme.colors.foregroundMuted,
|
||||
},
|
||||
chipLabelSelected: {
|
||||
color: theme.colors.accentForeground,
|
||||
color: theme.colors.foreground,
|
||||
},
|
||||
cronInput: {
|
||||
minHeight: 44,
|
||||
@@ -358,7 +351,7 @@ const styles = StyleSheet.create((theme) => ({
|
||||
borderWidth: 1,
|
||||
borderColor: theme.colors.border,
|
||||
fontSize: theme.fontSize.sm,
|
||||
fontFamily: MONOSPACE_FONT,
|
||||
fontFamily: theme.fontFamily.mono,
|
||||
},
|
||||
preview: {
|
||||
fontSize: theme.fontSize.xs,
|
||||
@@ -370,6 +363,6 @@ const styles = StyleSheet.create((theme) => ({
|
||||
},
|
||||
error: {
|
||||
fontSize: theme.fontSize.xs,
|
||||
color: theme.colors.destructive,
|
||||
color: theme.colors.palette.red[300],
|
||||
},
|
||||
}));
|
||||
|
||||
@@ -24,7 +24,13 @@ import { getProviderIcon } from "@/components/provider-icons";
|
||||
import { CadenceEditor } from "@/components/schedules/cadence-editor";
|
||||
import { useScheduleMutations } from "@/hooks/use-schedule-mutations";
|
||||
import { useAgentFormState, type FormInitialValues } from "@/hooks/use-agent-form-state";
|
||||
import { useAggregatedAgents } from "@/hooks/use-aggregated-agents";
|
||||
import { useProjects } from "@/hooks/use-projects";
|
||||
import {
|
||||
buildScheduleProjectTargets,
|
||||
PROJECT_OPTION_PREFIX,
|
||||
type ScheduleProjectTarget,
|
||||
} from "@/schedules/schedule-project-targets";
|
||||
import { validateCron } from "@/utils/schedule-format";
|
||||
import { toErrorMessage } from "@/utils/error-messages";
|
||||
import { shortenPath } from "@/utils/shorten-path";
|
||||
@@ -32,7 +38,6 @@ import type { ProjectSummary } from "@/utils/projects";
|
||||
import type { ProviderSelectorProvider } from "@/provider-selection/provider-selection";
|
||||
|
||||
const DEFAULT_CADENCE: ScheduleCadence = { type: "every", everyMs: 60 * 60 * 1000 };
|
||||
const PROJECT_OPTION_PREFIX = "project:";
|
||||
|
||||
export interface ScheduleFormSheetProps {
|
||||
serverId?: string;
|
||||
@@ -42,15 +47,6 @@ export interface ScheduleFormSheetProps {
|
||||
schedule?: ScheduleSummary;
|
||||
}
|
||||
|
||||
interface ScheduleProjectTarget {
|
||||
optionId: string;
|
||||
serverId: string;
|
||||
serverName: string;
|
||||
projectKey: string;
|
||||
projectName: string;
|
||||
cwd: string;
|
||||
}
|
||||
|
||||
interface ScheduleProjectOptions {
|
||||
targets: ScheduleProjectTarget[];
|
||||
options: ComboboxOption[];
|
||||
@@ -79,44 +75,19 @@ function buildInitialValues(schedule: ScheduleSummary | undefined): FormInitialV
|
||||
};
|
||||
}
|
||||
|
||||
function buildProjectOptionId(serverId: string, projectKey: string): string {
|
||||
return `${PROJECT_OPTION_PREFIX}${serverId}:${projectKey}`;
|
||||
}
|
||||
|
||||
function buildProjectOptionTestId(optionId: string): string {
|
||||
const targetKey = optionId.slice(PROJECT_OPTION_PREFIX.length).replace(/^[^:]+:/, "");
|
||||
return `schedule-project-option-${targetKey}`;
|
||||
}
|
||||
|
||||
function buildScheduleProjectOptions(projects: readonly ProjectSummary[]): ScheduleProjectOptions {
|
||||
const targets: ScheduleProjectTarget[] = [];
|
||||
const targetByOptionId = new Map<string, ScheduleProjectTarget>();
|
||||
const options: ComboboxOption[] = [];
|
||||
|
||||
for (const project of projects) {
|
||||
for (const host of project.hosts) {
|
||||
const cwd = host.repoRoot.trim();
|
||||
if (!host.isOnline || !cwd) {
|
||||
continue;
|
||||
}
|
||||
const target: ScheduleProjectTarget = {
|
||||
optionId: buildProjectOptionId(host.serverId, project.projectKey),
|
||||
serverId: host.serverId,
|
||||
serverName: host.serverName,
|
||||
projectKey: project.projectKey,
|
||||
projectName: project.projectName,
|
||||
cwd,
|
||||
};
|
||||
targets.push(target);
|
||||
targetByOptionId.set(target.optionId, target);
|
||||
options.push({
|
||||
id: target.optionId,
|
||||
label: target.projectName,
|
||||
description: `${target.serverName} - ${shortenPath(cwd)}`,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
const targets = buildScheduleProjectTargets(projects);
|
||||
const targetByOptionId = new Map(targets.map((target) => [target.optionId, target]));
|
||||
const options: ComboboxOption[] = targets.map((target) => ({
|
||||
id: target.optionId,
|
||||
label: target.projectName,
|
||||
description: `${target.serverName} - ${shortenPath(target.cwd)}`,
|
||||
}));
|
||||
return { targets, options, targetByOptionId };
|
||||
}
|
||||
|
||||
@@ -153,6 +124,35 @@ function isSelectedModelValidForProviders(input: {
|
||||
return provider.modelSelection.rows.some((row) => row.modelId === selectedModel);
|
||||
}
|
||||
|
||||
function parseMaxRuns(raw: string): number | null {
|
||||
const parsed = Number.parseInt(raw, 10);
|
||||
return Number.isFinite(parsed) && parsed > 0 ? parsed : null;
|
||||
}
|
||||
|
||||
function canSubmitScheduleForm(input: {
|
||||
isAgentTarget: boolean;
|
||||
isEdit: boolean;
|
||||
promptTrimmed: string;
|
||||
cadenceError: string | null;
|
||||
isSubmitting: boolean;
|
||||
selectedModelIsValid: boolean;
|
||||
hasWorkingDir: boolean;
|
||||
hasSelectedProject: boolean;
|
||||
}): boolean {
|
||||
if (input.promptTrimmed.length === 0 || input.cadenceError !== null || input.isSubmitting) {
|
||||
return false;
|
||||
}
|
||||
// Agent targets only edit name/prompt/cadence. New-agent edit accepts any
|
||||
// non-empty stored cwd; create requires a matched project.
|
||||
if (input.isAgentTarget) {
|
||||
return true;
|
||||
}
|
||||
if (!input.selectedModelIsValid) {
|
||||
return false;
|
||||
}
|
||||
return input.isEdit ? input.hasWorkingDir : input.hasSelectedProject;
|
||||
}
|
||||
|
||||
export function ScheduleFormSheet({
|
||||
serverId,
|
||||
visible,
|
||||
@@ -161,10 +161,26 @@ export function ScheduleFormSheet({
|
||||
schedule,
|
||||
}: ScheduleFormSheetProps): ReactElement {
|
||||
const isEdit = mode === "edit";
|
||||
const editConfig = newAgentConfig(schedule);
|
||||
// Agent-targeted schedules can only update name/prompt/cadence/maxRuns
|
||||
// (service.ts rejects newAgentConfig for them), so the form drops the
|
||||
// project/model/mode pickers and shows the target agent read-only instead.
|
||||
const isAgentTarget = isEdit && schedule?.target.type === "agent";
|
||||
const { projects } = useProjects();
|
||||
const { agents } = useAggregatedAgents({ includeArchived: true });
|
||||
const projectOptions = useMemo(() => buildScheduleProjectOptions(projects), [projects]);
|
||||
|
||||
const agentTargetLabel = useMemo(() => {
|
||||
if (!schedule || schedule.target.type !== "agent") {
|
||||
return null;
|
||||
}
|
||||
const { agentId } = schedule.target;
|
||||
const agent = agents.find((entry) => entry.serverId === serverId && entry.id === agentId);
|
||||
if (!agent) {
|
||||
return "Agent unavailable";
|
||||
}
|
||||
return agent.title?.trim() || "Untitled agent";
|
||||
}, [agents, schedule, serverId]);
|
||||
|
||||
const onlineServerIds = useMemo(
|
||||
() => Array.from(new Set(projectOptions.targets.map((target) => target.serverId))),
|
||||
[projectOptions.targets],
|
||||
@@ -197,7 +213,9 @@ export function ScheduleFormSheet({
|
||||
setProviderAndModelFromUser,
|
||||
clearProviderSelectionFromUser,
|
||||
setModeFromUser,
|
||||
setSelectedServerId,
|
||||
setSelectedServerIdFromUser,
|
||||
setWorkingDir,
|
||||
setWorkingDirFromUser,
|
||||
modeOptions,
|
||||
modelSelectorProviders,
|
||||
@@ -219,7 +237,10 @@ export function ScheduleFormSheet({
|
||||
|
||||
const handleSelectProject = useCallback(
|
||||
(target: ScheduleProjectTarget) => {
|
||||
if (selectedProjectTarget && selectedProjectTarget.serverId !== target.serverId) {
|
||||
// Compare against the current server, not the matched target: an unmatched
|
||||
// stored cwd has no target but still lives on a host, and switching hosts
|
||||
// must still clear a provider/model that may not exist on the new one.
|
||||
if (selectedServerId && selectedServerId !== target.serverId) {
|
||||
clearProviderSelectionFromUser();
|
||||
}
|
||||
setSelectedServerIdFromUser(target.serverId);
|
||||
@@ -227,7 +248,7 @@ export function ScheduleFormSheet({
|
||||
},
|
||||
[
|
||||
clearProviderSelectionFromUser,
|
||||
selectedProjectTarget,
|
||||
selectedServerId,
|
||||
setSelectedServerIdFromUser,
|
||||
setWorkingDirFromUser,
|
||||
],
|
||||
@@ -293,90 +314,127 @@ export function ScheduleFormSheet({
|
||||
setCadence(schedule?.cadence ?? DEFAULT_CADENCE);
|
||||
setSubmitError(null);
|
||||
setFieldResetKey((key) => key + 1);
|
||||
// The sheet stays mounted, and the form reducer's reset-on-close only
|
||||
// clears user-modified flags — not the picker values — so a create opened
|
||||
// after an edit would inherit that schedule's server/cwd (including a
|
||||
// stale ghost path). Clear them so create always starts fresh; provider
|
||||
// and model re-resolve from preferences.
|
||||
if (!isEdit) {
|
||||
setSelectedServerId(null);
|
||||
setWorkingDir("");
|
||||
}
|
||||
}
|
||||
wasVisibleRef.current = visible;
|
||||
}, [visible, schedule]);
|
||||
}, [visible, schedule, isEdit, setSelectedServerId, setWorkingDir]);
|
||||
|
||||
const promptTrimmed = prompt.trim();
|
||||
const trimmedWorkingDir = workingDir.trim();
|
||||
const cadenceError = cadence.type === "cron" ? validateCron(cadence.expression) : null;
|
||||
const selectedModelIsValid = isSelectedModelValidForProviders({
|
||||
providers: modelSelectorProviders,
|
||||
selectedProvider,
|
||||
selectedModel,
|
||||
});
|
||||
const canSubmit =
|
||||
promptTrimmed.length > 0 &&
|
||||
selectedModelIsValid &&
|
||||
Boolean(selectedProjectTarget) &&
|
||||
cadenceError === null &&
|
||||
!isSubmitting;
|
||||
const canSubmit = canSubmitScheduleForm({
|
||||
isAgentTarget,
|
||||
isEdit,
|
||||
promptTrimmed,
|
||||
cadenceError,
|
||||
isSubmitting,
|
||||
selectedModelIsValid,
|
||||
hasWorkingDir: trimmedWorkingDir.length > 0,
|
||||
hasSelectedProject: Boolean(selectedProjectTarget),
|
||||
});
|
||||
|
||||
const handleSubmit = useCallback(async () => {
|
||||
if (!selectedProvider || !selectedProjectTarget || !promptTrimmed) {
|
||||
return;
|
||||
// Agent target: the update RPC only accepts name/prompt/cadence/maxRuns.
|
||||
const submitAgentTarget = useCallback(async (): Promise<boolean> => {
|
||||
if (!schedule) {
|
||||
return false;
|
||||
}
|
||||
setSubmitError(null);
|
||||
try {
|
||||
await persistFormPreferences();
|
||||
const parsedMaxRuns = Number.parseInt(maxRuns, 10);
|
||||
const maxRunsValue =
|
||||
Number.isFinite(parsedMaxRuns) && parsedMaxRuns > 0 ? parsedMaxRuns : null;
|
||||
await updateSchedule({
|
||||
id: schedule.id,
|
||||
name: name.trim() || null,
|
||||
prompt: promptTrimmed,
|
||||
cadence,
|
||||
maxRuns: parseMaxRuns(maxRuns),
|
||||
});
|
||||
return true;
|
||||
}, [cadence, maxRuns, name, promptTrimmed, schedule, updateSchedule]);
|
||||
|
||||
if (isEdit && schedule) {
|
||||
await updateSchedule({
|
||||
id: schedule.id,
|
||||
name: name.trim() || null,
|
||||
prompt: promptTrimmed,
|
||||
cadence,
|
||||
newAgentConfig: {
|
||||
provider: selectedProvider,
|
||||
model: selectedModel || null,
|
||||
modeId: selectedMode || null,
|
||||
cwd: selectedProjectTarget.cwd,
|
||||
},
|
||||
maxRuns: maxRunsValue,
|
||||
});
|
||||
} else {
|
||||
await createSchedule({
|
||||
prompt: promptTrimmed,
|
||||
name: name.trim() || undefined,
|
||||
cadence,
|
||||
target: {
|
||||
type: "new-agent",
|
||||
config: {
|
||||
provider: selectedProvider,
|
||||
cwd: selectedProjectTarget.cwd,
|
||||
model: selectedModel || undefined,
|
||||
modeId: selectedMode || undefined,
|
||||
thinkingOptionId: selectedThinkingOptionId || undefined,
|
||||
title: name.trim() || undefined,
|
||||
},
|
||||
},
|
||||
...(maxRunsValue != null ? { maxRuns: maxRunsValue } : {}),
|
||||
});
|
||||
}
|
||||
onClose();
|
||||
} catch (error) {
|
||||
setSubmitError(toErrorMessage(error));
|
||||
// New-agent target: submit the current working directory. On edit an untouched
|
||||
// picker leaves this as the stored cwd, so it round-trips unchanged.
|
||||
const submitNewAgent = useCallback(async (): Promise<boolean> => {
|
||||
if (!selectedProvider || !trimmedWorkingDir) {
|
||||
return false;
|
||||
}
|
||||
await persistFormPreferences();
|
||||
const maxRunsValue = parseMaxRuns(maxRuns);
|
||||
if (isEdit && schedule) {
|
||||
await updateSchedule({
|
||||
id: schedule.id,
|
||||
name: name.trim() || null,
|
||||
prompt: promptTrimmed,
|
||||
cadence,
|
||||
newAgentConfig: {
|
||||
provider: selectedProvider,
|
||||
model: selectedModel || null,
|
||||
modeId: selectedMode || null,
|
||||
cwd: trimmedWorkingDir,
|
||||
},
|
||||
maxRuns: maxRunsValue,
|
||||
});
|
||||
return true;
|
||||
}
|
||||
await createSchedule({
|
||||
prompt: promptTrimmed,
|
||||
name: name.trim() || undefined,
|
||||
cadence,
|
||||
target: {
|
||||
type: "new-agent",
|
||||
config: {
|
||||
provider: selectedProvider,
|
||||
cwd: trimmedWorkingDir,
|
||||
model: selectedModel || undefined,
|
||||
modeId: selectedMode || undefined,
|
||||
thinkingOptionId: selectedThinkingOptionId || undefined,
|
||||
title: name.trim() || undefined,
|
||||
},
|
||||
},
|
||||
...(maxRunsValue != null ? { maxRuns: maxRunsValue } : {}),
|
||||
});
|
||||
return true;
|
||||
}, [
|
||||
cadence,
|
||||
createSchedule,
|
||||
isEdit,
|
||||
maxRuns,
|
||||
name,
|
||||
onClose,
|
||||
persistFormPreferences,
|
||||
promptTrimmed,
|
||||
schedule,
|
||||
selectedMode,
|
||||
selectedModel,
|
||||
selectedProjectTarget,
|
||||
selectedProvider,
|
||||
selectedThinkingOptionId,
|
||||
trimmedWorkingDir,
|
||||
updateSchedule,
|
||||
]);
|
||||
|
||||
const handleSubmit = useCallback(async () => {
|
||||
if (!promptTrimmed) {
|
||||
return;
|
||||
}
|
||||
setSubmitError(null);
|
||||
try {
|
||||
const submitted = isAgentTarget ? await submitAgentTarget() : await submitNewAgent();
|
||||
if (submitted) {
|
||||
onClose();
|
||||
}
|
||||
} catch (error) {
|
||||
setSubmitError(toErrorMessage(error));
|
||||
}
|
||||
}, [isAgentTarget, onClose, promptTrimmed, submitAgentTarget, submitNewAgent]);
|
||||
|
||||
const handleSubmitPress = useCallback(() => {
|
||||
void handleSubmit();
|
||||
}, [handleSubmit]);
|
||||
@@ -454,34 +512,53 @@ export function ScheduleFormSheet({
|
||||
/>
|
||||
</View>
|
||||
|
||||
<View style={styles.field}>
|
||||
<Text style={styles.label}>Project</Text>
|
||||
<ProjectField
|
||||
options={projectOptions.options}
|
||||
targetByOptionId={projectOptions.targetByOptionId}
|
||||
value={selectedProjectOptionId}
|
||||
selectedTarget={selectedProjectTarget}
|
||||
onSelect={handleSelectProject}
|
||||
/>
|
||||
</View>
|
||||
{isAgentTarget ? (
|
||||
<View style={styles.field}>
|
||||
<Text style={styles.label}>Target</Text>
|
||||
<View style={styles.readonlyField} testID="schedule-agent-target">
|
||||
<Text style={styles.selectTriggerText} numberOfLines={1}>
|
||||
{agentTargetLabel}
|
||||
</Text>
|
||||
</View>
|
||||
<Text style={styles.hint}>Runs against this existing agent.</Text>
|
||||
</View>
|
||||
) : (
|
||||
<>
|
||||
<View style={styles.field}>
|
||||
<Text style={styles.label}>Project</Text>
|
||||
<ProjectField
|
||||
options={projectOptions.options}
|
||||
targetByOptionId={projectOptions.targetByOptionId}
|
||||
value={selectedProjectOptionId}
|
||||
selectedTarget={selectedProjectTarget}
|
||||
fallbackCwd={workingDir}
|
||||
onSelect={handleSelectProject}
|
||||
/>
|
||||
</View>
|
||||
|
||||
<View style={styles.field}>
|
||||
<Text style={styles.label}>Model</Text>
|
||||
<CombinedModelSelector
|
||||
providers={modelSelectorProviders}
|
||||
selectedProvider={selectedProvider ?? ""}
|
||||
selectedModel={selectedModel}
|
||||
onSelect={setProviderAndModelFromUser}
|
||||
isLoading={isAllModelsLoading}
|
||||
renderTrigger={renderModelTrigger}
|
||||
triggerFill
|
||||
serverId={mutationServerId}
|
||||
/>
|
||||
</View>
|
||||
<View style={styles.field}>
|
||||
<Text style={styles.label}>Model</Text>
|
||||
<CombinedModelSelector
|
||||
providers={modelSelectorProviders}
|
||||
selectedProvider={selectedProvider ?? ""}
|
||||
selectedModel={selectedModel}
|
||||
onSelect={setProviderAndModelFromUser}
|
||||
isLoading={isAllModelsLoading}
|
||||
renderTrigger={renderModelTrigger}
|
||||
triggerFill
|
||||
serverId={mutationServerId}
|
||||
/>
|
||||
</View>
|
||||
|
||||
{modeOptions.length > 0 ? (
|
||||
<ModeField options={modeOptions} selectedMode={selectedMode} onSelect={setModeFromUser} />
|
||||
) : null}
|
||||
{modeOptions.length > 0 ? (
|
||||
<ModeField
|
||||
options={modeOptions}
|
||||
selectedMode={selectedMode}
|
||||
onSelect={setModeFromUser}
|
||||
/>
|
||||
) : null}
|
||||
</>
|
||||
)}
|
||||
|
||||
<View style={styles.field}>
|
||||
<Text style={styles.label}>Cadence</Text>
|
||||
@@ -504,10 +581,6 @@ export function ScheduleFormSheet({
|
||||
<Text style={styles.hint}>Leave blank to run indefinitely</Text>
|
||||
</View>
|
||||
|
||||
{editConfig === null && isEdit ? (
|
||||
<Text style={styles.hint}>This schedule does not target a new agent.</Text>
|
||||
) : null}
|
||||
|
||||
{submitError ? <Text style={styles.error}>{submitError}</Text> : null}
|
||||
</AdaptiveModalSheet>
|
||||
);
|
||||
@@ -594,12 +667,15 @@ function ProjectField({
|
||||
targetByOptionId,
|
||||
value,
|
||||
selectedTarget,
|
||||
fallbackCwd,
|
||||
onSelect,
|
||||
}: {
|
||||
options: ComboboxOption[];
|
||||
targetByOptionId: Map<string, ScheduleProjectTarget>;
|
||||
value: string;
|
||||
selectedTarget: ScheduleProjectTarget | null;
|
||||
/** Stored cwd for an edited schedule whose path matches no known project. */
|
||||
fallbackCwd: string;
|
||||
onSelect: (target: ScheduleProjectTarget) => void;
|
||||
}): ReactElement {
|
||||
const anchorRef = useRef<View>(null);
|
||||
@@ -629,7 +705,13 @@ function ProjectField({
|
||||
[open],
|
||||
);
|
||||
|
||||
const displayValue = selectedTarget?.projectName ?? "Select project";
|
||||
// Honest hydration: a stored cwd that matches no known project shows the
|
||||
// shortened path itself (not the blank "Select project"), and stays put until
|
||||
// the user deliberately picks a project.
|
||||
const storedPath = fallbackCwd.trim();
|
||||
const displayValue =
|
||||
selectedTarget?.projectName ?? (storedPath ? shortenPath(storedPath) : "Select project");
|
||||
const isPlaceholder = !selectedTarget && !storedPath;
|
||||
const description = selectedTarget
|
||||
? `${selectedTarget.serverName} - ${shortenPath(selectedTarget.cwd)}`
|
||||
: null;
|
||||
@@ -662,7 +744,7 @@ function ProjectField({
|
||||
testID="schedule-project-trigger"
|
||||
>
|
||||
<Text
|
||||
style={selectedTarget ? styles.selectTriggerText : styles.selectTriggerPlaceholder}
|
||||
style={isPlaceholder ? styles.selectTriggerPlaceholder : styles.selectTriggerText}
|
||||
numberOfLines={1}
|
||||
>
|
||||
{displayValue}
|
||||
@@ -809,9 +891,20 @@ const styles = StyleSheet.create((theme) => ({
|
||||
fontSize: theme.fontSize.xs,
|
||||
},
|
||||
error: {
|
||||
color: theme.colors.destructive,
|
||||
color: theme.colors.palette.red[300],
|
||||
fontSize: theme.fontSize.xs,
|
||||
},
|
||||
readonlyField: {
|
||||
flexDirection: "row",
|
||||
alignItems: "center",
|
||||
backgroundColor: theme.colors.surface2,
|
||||
borderRadius: theme.borderRadius.lg,
|
||||
borderWidth: 1,
|
||||
borderColor: theme.colors.border,
|
||||
paddingHorizontal: theme.spacing[4],
|
||||
paddingVertical: theme.spacing[3],
|
||||
minHeight: 44,
|
||||
},
|
||||
selectTrigger: {
|
||||
flexDirection: "row",
|
||||
alignItems: "center",
|
||||
|
||||
@@ -15,7 +15,9 @@ import { isNative } from "@/constants/platform";
|
||||
import { useIsCompactFormFactor } from "@/constants/layout";
|
||||
import { settingsStyles } from "@/styles/settings";
|
||||
import type { Theme } from "@/styles/theme";
|
||||
import type { ScheduleDerivedState } from "@/schedules/schedule-derivation";
|
||||
import { formatCadence, formatNextRun, resolveScheduleTitle } from "@/utils/schedule-format";
|
||||
import { formatTimeAgo } from "@/utils/time";
|
||||
import type { ScheduleSummary } from "@getpaseo/protocol/schedule/types";
|
||||
|
||||
// Themed lucide wrappers — module-scope so only the icon re-renders on theme
|
||||
@@ -53,43 +55,62 @@ export interface ScheduleRowActions {
|
||||
|
||||
interface ScheduleRowProps extends ScheduleRowActions {
|
||||
schedule: ScheduleSummary;
|
||||
/** Client-derived target line (agent title / project / shortened path). */
|
||||
targetLabel: string;
|
||||
/** Provider glyph, resolved from the schedule config or the target agent. */
|
||||
provider: string | null;
|
||||
/** Client-derived state — the single source for the badge and next-run copy. */
|
||||
state: ScheduleDerivedState;
|
||||
/** Host name, rendered when the list spans more than one host. */
|
||||
serverName?: string;
|
||||
/** True when only one host exists and the host name would be redundant. */
|
||||
singleHost?: boolean;
|
||||
pending?: ScheduleRowPending;
|
||||
isFirst: boolean;
|
||||
}
|
||||
|
||||
function resolveProvider(schedule: ScheduleSummary): string | null {
|
||||
return schedule.target.type === "new-agent" ? schedule.target.config.provider : null;
|
||||
function stateBadge(state: ScheduleDerivedState): {
|
||||
label: string;
|
||||
variant: "success" | "error" | "muted";
|
||||
} {
|
||||
switch (state) {
|
||||
case "active":
|
||||
return { label: "Active", variant: "success" };
|
||||
case "paused":
|
||||
return { label: "Paused", variant: "muted" };
|
||||
case "expired":
|
||||
return { label: "Expired", variant: "muted" };
|
||||
case "finished":
|
||||
return { label: "Finished", variant: "muted" };
|
||||
case "targetGone":
|
||||
return { label: "Target gone", variant: "error" };
|
||||
}
|
||||
}
|
||||
|
||||
function resolveModelLabel(schedule: ScheduleSummary): string {
|
||||
if (schedule.target.type === "new-agent" && schedule.target.config.model) {
|
||||
return schedule.target.config.model;
|
||||
// Meta reads left-to-right as identity → history → future: how often, when it
|
||||
// was created, when it last ran, and (only while it can still run) when it runs
|
||||
// next. Status lives on the badge, never repeated here.
|
||||
function buildMeta(
|
||||
schedule: ScheduleSummary,
|
||||
state: ScheduleDerivedState,
|
||||
serverName: string | undefined,
|
||||
singleHost: boolean,
|
||||
): string {
|
||||
const parts = [
|
||||
formatCadence(schedule.cadence),
|
||||
`Created ${formatTimeAgo(new Date(schedule.createdAt))}`,
|
||||
schedule.lastRunAt ? `Last run ${formatTimeAgo(new Date(schedule.lastRunAt))}` : "Never run",
|
||||
];
|
||||
if (state === "active") {
|
||||
const next = formatNextRun(schedule.nextRunAt);
|
||||
if (next) {
|
||||
parts.push(`Next run ${next}`);
|
||||
}
|
||||
}
|
||||
return "Default model";
|
||||
}
|
||||
|
||||
function statusVariant(status: ScheduleSummary["status"]): "success" | "muted" {
|
||||
return status === "active" ? "success" : "muted";
|
||||
}
|
||||
|
||||
function statusLabel(status: ScheduleSummary["status"]): string {
|
||||
if (status === "active") {
|
||||
return "Active";
|
||||
if (serverName && !singleHost) {
|
||||
parts.unshift(serverName);
|
||||
}
|
||||
if (status === "paused") {
|
||||
return "Paused";
|
||||
}
|
||||
return "Completed";
|
||||
}
|
||||
|
||||
function nextRunLabel(schedule: ScheduleSummary): string {
|
||||
if (schedule.status === "paused") {
|
||||
return "Paused";
|
||||
}
|
||||
if (schedule.status === "completed") {
|
||||
return "Completed";
|
||||
}
|
||||
return formatNextRun(schedule.nextRunAt) || "—";
|
||||
return parts.join(" · ");
|
||||
}
|
||||
|
||||
/** Small provider glyph. Reads the icon color off a StyleSheet object so the
|
||||
@@ -113,6 +134,11 @@ function ProviderGlyph({ provider }: { provider: string | null }): ReactElement
|
||||
*/
|
||||
export function ScheduleRow({
|
||||
schedule,
|
||||
targetLabel,
|
||||
provider,
|
||||
state,
|
||||
serverName,
|
||||
singleHost,
|
||||
pending,
|
||||
isFirst,
|
||||
onEdit,
|
||||
@@ -126,15 +152,10 @@ export function ScheduleRow({
|
||||
const handlePointerEnter = useCallback(() => setIsHovered(true), []);
|
||||
const handlePointerLeave = useCallback(() => setIsHovered(false), []);
|
||||
|
||||
const provider = resolveProvider(schedule);
|
||||
const title = resolveScheduleTitle(schedule);
|
||||
const meta = [
|
||||
resolveModelLabel(schedule),
|
||||
formatCadence(schedule.cadence),
|
||||
nextRunLabel(schedule),
|
||||
]
|
||||
.filter(Boolean)
|
||||
.join(" · ");
|
||||
const badge = stateBadge(state);
|
||||
const meta = buildMeta(schedule, state, serverName, singleHost ?? false);
|
||||
const canRun = state === "active" || state === "paused";
|
||||
|
||||
const rowStyle = useCallback(
|
||||
({ pressed }: PressableStateCallbackType) => [
|
||||
@@ -168,6 +189,9 @@ export function ScheduleRow({
|
||||
<Text style={settingsStyles.rowTitle} numberOfLines={1}>
|
||||
{title}
|
||||
</Text>
|
||||
<Text style={styles.target} numberOfLines={1}>
|
||||
{targetLabel}
|
||||
</Text>
|
||||
<Text style={settingsStyles.rowHint} numberOfLines={1}>
|
||||
{meta}
|
||||
</Text>
|
||||
@@ -175,12 +199,10 @@ export function ScheduleRow({
|
||||
</View>
|
||||
|
||||
<View style={styles.trailing}>
|
||||
<StatusBadge
|
||||
label={statusLabel(schedule.status)}
|
||||
variant={statusVariant(schedule.status)}
|
||||
/>
|
||||
<StatusBadge label={badge.label} variant={badge.variant} />
|
||||
<ScheduleKebabMenu
|
||||
schedule={schedule}
|
||||
canRun={canRun}
|
||||
pending={pending}
|
||||
onEdit={onEdit}
|
||||
onPause={onPause}
|
||||
@@ -211,13 +233,19 @@ function renderKebabTriggerIcon({ hovered }: { hovered?: boolean }): ReactElemen
|
||||
|
||||
function ScheduleKebabMenu({
|
||||
schedule,
|
||||
canRun,
|
||||
pending,
|
||||
onEdit,
|
||||
onPause,
|
||||
onResume,
|
||||
onRunNow,
|
||||
onDelete,
|
||||
}: Omit<ScheduleRowProps, "isFirst">): ReactElement {
|
||||
}: Pick<
|
||||
ScheduleRowProps,
|
||||
"schedule" | "pending" | "onEdit" | "onPause" | "onResume" | "onRunNow" | "onDelete"
|
||||
> & {
|
||||
canRun: boolean;
|
||||
}): ReactElement {
|
||||
return (
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger
|
||||
@@ -240,6 +268,7 @@ function ScheduleKebabMenu({
|
||||
{schedule.status === "paused" ? (
|
||||
<DropdownMenuItem
|
||||
leading={resumeLeading}
|
||||
disabled={!canRun}
|
||||
status={pending?.resume ? "pending" : "idle"}
|
||||
pendingLabel="Resuming..."
|
||||
onSelect={onResume}
|
||||
@@ -250,7 +279,7 @@ function ScheduleKebabMenu({
|
||||
) : (
|
||||
<DropdownMenuItem
|
||||
leading={pauseLeading}
|
||||
disabled={schedule.status === "completed"}
|
||||
disabled={schedule.status === "completed" || !canRun}
|
||||
status={pending?.pause ? "pending" : "idle"}
|
||||
pendingLabel="Pausing..."
|
||||
onSelect={onPause}
|
||||
@@ -261,6 +290,7 @@ function ScheduleKebabMenu({
|
||||
)}
|
||||
<DropdownMenuItem
|
||||
leading={runLeading}
|
||||
disabled={!canRun}
|
||||
status={pending?.runNow ? "pending" : "idle"}
|
||||
pendingLabel="Starting..."
|
||||
onSelect={onRunNow}
|
||||
@@ -324,6 +354,11 @@ const styles = StyleSheet.create((theme) => ({
|
||||
flex: 1,
|
||||
minWidth: 0,
|
||||
},
|
||||
target: {
|
||||
marginTop: theme.spacing[1],
|
||||
color: theme.colors.foregroundMuted,
|
||||
fontSize: theme.fontSize.sm,
|
||||
},
|
||||
trailing: {
|
||||
flexDirection: "row",
|
||||
alignItems: "center",
|
||||
|
||||
@@ -3,44 +3,48 @@ import { View } from "react-native";
|
||||
import { StyleSheet } from "react-native-unistyles";
|
||||
import { ScheduleRow, type ScheduleRowPending } from "@/components/schedules/schedule-row";
|
||||
import { useScheduleMutations } from "@/hooks/use-schedule-mutations";
|
||||
import type { AggregatedSchedule } from "@/hooks/use-schedules";
|
||||
import type { ScheduleDerivedState } from "@/schedules/schedule-derivation";
|
||||
import { settingsStyles } from "@/styles/settings";
|
||||
import { confirmDialog } from "@/utils/confirm-dialog";
|
||||
import { resolveScheduleTitle } from "@/utils/schedule-format";
|
||||
import type { ScheduleSummary } from "@getpaseo/protocol/schedule/types";
|
||||
|
||||
/** A schedule plus the client-derived fields the row renders. */
|
||||
export interface ScheduleRowView {
|
||||
schedule: AggregatedSchedule;
|
||||
targetLabel: string;
|
||||
provider: string | null;
|
||||
state: ScheduleDerivedState;
|
||||
serverName: string;
|
||||
/** True when only one host exists, so the host name is redundant in rows. */
|
||||
singleHost: boolean;
|
||||
}
|
||||
|
||||
interface SchedulesTableProps {
|
||||
serverId: string;
|
||||
schedules: ScheduleSummary[];
|
||||
rows: ScheduleRowView[];
|
||||
/**
|
||||
* The form sheet is owned by the screen (it serves both create and edit and
|
||||
* shares the header's "New schedule" button), so the table delegates edit
|
||||
* shares the screen's "New schedule" button), so the table delegates edit
|
||||
* upward rather than mounting a second sheet here.
|
||||
*/
|
||||
onEditSchedule: (schedule: ScheduleSummary) => void;
|
||||
onEditSchedule: (schedule: AggregatedSchedule) => void;
|
||||
}
|
||||
|
||||
/**
|
||||
* The schedules list: a single settings-style card of rows in a centered,
|
||||
* width-constrained reading column, matching the projects list. Owns row-level
|
||||
* actions (pause/resume/run/delete via the mutations hook + a destructive
|
||||
* confirm for delete) and delegates editing to the parent.
|
||||
* The schedules list: a single settings-style card of rows across every
|
||||
* connected host, in a full-width list matching the History screen. Rows own
|
||||
* their host-scoped mutations (pause/resume/run/delete via the mutations hook +
|
||||
* a destructive confirm) and delegate editing upward.
|
||||
*/
|
||||
export function SchedulesTable({
|
||||
serverId,
|
||||
schedules,
|
||||
onEditSchedule,
|
||||
}: SchedulesTableProps): ReactElement {
|
||||
const mutations = useScheduleMutations({ serverId });
|
||||
|
||||
export function SchedulesTable({ rows, onEditSchedule }: SchedulesTableProps): ReactElement {
|
||||
return (
|
||||
<View style={styles.listContent} testID="schedules-table">
|
||||
<View style={settingsStyles.card}>
|
||||
{schedules.map((schedule, index) => (
|
||||
{rows.map((row, index) => (
|
||||
<SchedulesTableRow
|
||||
key={schedule.id}
|
||||
schedule={schedule}
|
||||
key={`${row.schedule.serverId}:${row.schedule.id}`}
|
||||
row={row}
|
||||
isFirst={index === 0}
|
||||
mutations={mutations}
|
||||
onEditSchedule={onEditSchedule}
|
||||
/>
|
||||
))}
|
||||
@@ -50,28 +54,26 @@ export function SchedulesTable({
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Per-row wrapper owns local in-flight state and binds the table's mutation
|
||||
// callbacks to this schedule. Local state keeps pending precise to the acting
|
||||
// row even when several rows are acted on at once (the mutations hook exposes
|
||||
// only a single global pending flag per action).
|
||||
// Per-row wrapper owns local in-flight state and binds mutations to this
|
||||
// schedule's host. Local state keeps pending precise to the acting row even
|
||||
// when several rows are acted on at once (the mutations hook exposes only a
|
||||
// single global pending flag per action).
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
type ScheduleMutations = ReturnType<typeof useScheduleMutations>;
|
||||
|
||||
const NO_PENDING: ScheduleRowPending = {};
|
||||
|
||||
function SchedulesTableRow({
|
||||
schedule,
|
||||
row,
|
||||
isFirst,
|
||||
mutations,
|
||||
onEditSchedule,
|
||||
}: {
|
||||
schedule: ScheduleSummary;
|
||||
row: ScheduleRowView;
|
||||
isFirst: boolean;
|
||||
mutations: ScheduleMutations;
|
||||
onEditSchedule: (schedule: ScheduleSummary) => void;
|
||||
onEditSchedule: (schedule: AggregatedSchedule) => void;
|
||||
}): ReactElement {
|
||||
const { id } = schedule;
|
||||
const { schedule } = row;
|
||||
const { id, serverId } = schedule;
|
||||
const mutations = useScheduleMutations({ serverId });
|
||||
const [pending, setPending] = useState<ScheduleRowPending>(NO_PENDING);
|
||||
|
||||
const runAction = useCallback(
|
||||
@@ -127,6 +129,11 @@ function SchedulesTableRow({
|
||||
return (
|
||||
<ScheduleRow
|
||||
schedule={schedule}
|
||||
targetLabel={row.targetLabel}
|
||||
provider={row.provider}
|
||||
state={row.state}
|
||||
serverName={row.serverName}
|
||||
singleHost={row.singleHost}
|
||||
isFirst={isFirst}
|
||||
pending={pending}
|
||||
onEdit={handleEdit}
|
||||
@@ -138,14 +145,9 @@ function SchedulesTableRow({
|
||||
);
|
||||
}
|
||||
|
||||
const CONTENT_MAX_WIDTH = 720;
|
||||
|
||||
const styles = StyleSheet.create((theme) => ({
|
||||
// Center the card in a readable column, matching settings and projects.
|
||||
// Full-width list padding matching the History screen.
|
||||
listContent: {
|
||||
width: "100%",
|
||||
maxWidth: CONTENT_MAX_WIDTH,
|
||||
alignSelf: "center",
|
||||
paddingHorizontal: theme.spacing[4],
|
||||
paddingHorizontal: { xs: theme.spacing[3], md: theme.spacing[6] },
|
||||
},
|
||||
}));
|
||||
|
||||
@@ -56,6 +56,7 @@ import { NestableScrollContainer } from "react-native-draggable-flatlist";
|
||||
import { DraggableList, type DraggableRenderItemInfo } from "./draggable-list";
|
||||
import type { DraggableListDragHandleProps } from "./draggable-list.types";
|
||||
import { getHostRuntimeStore, useHosts } from "@/runtime/host-runtime";
|
||||
import { useHostFeatureMap } from "@/runtime/host-features";
|
||||
import { useIsCompactFormFactor } from "@/constants/layout";
|
||||
import { useProjectIconDataByProjectKey } from "@/projects/project-icons";
|
||||
import {
|
||||
@@ -1881,6 +1882,7 @@ function ProjectBlock({
|
||||
activeWorkspaceSelection,
|
||||
hostLabelByServerId,
|
||||
showHostLabels,
|
||||
supportsMultiplicityByServerId,
|
||||
}: {
|
||||
project: SidebarProjectEntry;
|
||||
collapsed: boolean;
|
||||
@@ -1902,14 +1904,16 @@ function ProjectBlock({
|
||||
activeWorkspaceSelection: ActiveWorkspaceSelection | null;
|
||||
hostLabelByServerId: ReadonlyMap<string, string>;
|
||||
showHostLabels: boolean;
|
||||
supportsMultiplicityByServerId: ReadonlyMap<string, boolean>;
|
||||
}) {
|
||||
const rowModel = useMemo(
|
||||
() =>
|
||||
buildSidebarProjectRowModel({
|
||||
project,
|
||||
collapsed,
|
||||
supportsMultiplicityByServerId,
|
||||
}),
|
||||
[collapsed, project],
|
||||
[collapsed, project, supportsMultiplicityByServerId],
|
||||
);
|
||||
|
||||
const active = isProjectSelectedByRoute({
|
||||
@@ -2062,7 +2066,7 @@ function ProjectBlock({
|
||||
containerStyle={styles.workspaceListContainer}
|
||||
/>
|
||||
);
|
||||
} else if (rowModel.trailingAction.kind === "new_worktree") {
|
||||
} else if (rowModel.trailingAction.kind === "new_workspace") {
|
||||
projectChildren = (
|
||||
<NewWorkspaceGhostRow
|
||||
project={project}
|
||||
@@ -2085,7 +2089,7 @@ function ProjectBlock({
|
||||
chevron={rowModel.chevron}
|
||||
onPress={handleToggleCollapsed}
|
||||
worktreeTarget={
|
||||
rowModel.trailingAction.kind === "new_worktree" ? rowModel.trailingAction.target : null
|
||||
rowModel.trailingAction.kind === "new_workspace" ? rowModel.trailingAction.target : null
|
||||
}
|
||||
isProjectActive={active}
|
||||
onWorkspacePress={onWorkspacePress}
|
||||
@@ -2106,6 +2110,7 @@ function ProjectBlock({
|
||||
|
||||
type ProjectBlockProps = Parameters<typeof ProjectBlock>[0];
|
||||
|
||||
// oxlint-disable-next-line complexity
|
||||
function areProjectBlockPropsEqual(previous: ProjectBlockProps, next: ProjectBlockProps): boolean {
|
||||
return (
|
||||
previous.project === next.project &&
|
||||
@@ -2117,6 +2122,7 @@ function areProjectBlockPropsEqual(previous: ProjectBlockProps, next: ProjectBlo
|
||||
previous.shortcutIndexByWorkspaceKey === next.shortcutIndexByWorkspaceKey &&
|
||||
previous.hostLabelByServerId === next.hostLabelByServerId &&
|
||||
previous.showHostLabels === next.showHostLabels &&
|
||||
previous.supportsMultiplicityByServerId === next.supportsMultiplicityByServerId &&
|
||||
previous.parentGestureRef === next.parentGestureRef &&
|
||||
previous.onToggleCollapsed === next.onToggleCollapsed &&
|
||||
previous.onWorkspacePress === next.onWorkspacePress &&
|
||||
@@ -2183,6 +2189,8 @@ export function SidebarWorkspaceList({
|
||||
}
|
||||
return labels;
|
||||
}, [hosts]);
|
||||
const serverIds = useMemo(() => hosts.map((host) => host.serverId), [hosts]);
|
||||
const supportsMultiplicityByServerId = useHostFeatureMap(serverIds, "workspaceMultiplicity");
|
||||
const showHostLabels = useMemo(() => shouldShowSidebarHostLabels(projects), [projects]);
|
||||
|
||||
const content =
|
||||
@@ -2208,6 +2216,7 @@ export function SidebarWorkspaceList({
|
||||
pathname={pathname}
|
||||
hostLabelByServerId={hostLabelByServerId}
|
||||
showHostLabels={showHostLabels}
|
||||
supportsMultiplicityByServerId={supportsMultiplicityByServerId}
|
||||
/>
|
||||
);
|
||||
|
||||
@@ -2256,6 +2265,7 @@ function ProjectModeList({
|
||||
pathname,
|
||||
hostLabelByServerId,
|
||||
showHostLabels,
|
||||
supportsMultiplicityByServerId,
|
||||
}: Omit<
|
||||
SidebarWorkspaceListProps,
|
||||
"statusWorkspacePlacements" | "projectNamesByKey" | "groupMode" | "isRefreshing" | "onRefresh"
|
||||
@@ -2263,6 +2273,7 @@ function ProjectModeList({
|
||||
pathname: string;
|
||||
hostLabelByServerId: ReadonlyMap<string, string>;
|
||||
showHostLabels: boolean;
|
||||
supportsMultiplicityByServerId: ReadonlyMap<string, boolean>;
|
||||
}) {
|
||||
const { t } = useTranslation();
|
||||
const [creatingWorkspaceIds, setCreatingWorkspaceIds] = useState<Set<string>>(() => new Set());
|
||||
@@ -2451,6 +2462,7 @@ function ProjectModeList({
|
||||
activeWorkspaceSelection={activeWorkspaceSelection}
|
||||
hostLabelByServerId={hostLabelByServerId}
|
||||
showHostLabels={showHostLabels}
|
||||
supportsMultiplicityByServerId={supportsMultiplicityByServerId}
|
||||
/>
|
||||
);
|
||||
},
|
||||
@@ -2461,6 +2473,7 @@ function ProjectModeList({
|
||||
handleWorkspaceReorder,
|
||||
hostLabelByServerId,
|
||||
showHostLabels,
|
||||
supportsMultiplicityByServerId,
|
||||
onWorkspacePress,
|
||||
onToggleProjectCollapsed,
|
||||
parentGestureRef,
|
||||
|
||||
@@ -50,7 +50,7 @@ const styles = StyleSheet.create((theme) => ({
|
||||
borderRadius: theme.borderRadius.md,
|
||||
},
|
||||
sm: {
|
||||
paddingVertical: theme.spacing[2],
|
||||
paddingVertical: theme.spacing[1.5],
|
||||
paddingHorizontal: theme.spacing[3],
|
||||
borderRadius: theme.borderRadius.md,
|
||||
},
|
||||
|
||||
@@ -161,7 +161,7 @@ const styles = StyleSheet.create((theme) => ({
|
||||
gap: theme.spacing[1],
|
||||
},
|
||||
segmentSm: {
|
||||
paddingVertical: theme.spacing[2],
|
||||
paddingVertical: theme.spacing[1.5],
|
||||
paddingHorizontal: theme.spacing[4],
|
||||
},
|
||||
segmentMd: {
|
||||
|
||||
@@ -1,210 +0,0 @@
|
||||
import { useCallback, useMemo } from "react";
|
||||
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import type {
|
||||
PaseoAgentCatalogEntry,
|
||||
PaseoAgentOAuthCompleteResponse,
|
||||
PaseoAgentOAuthStartResponse,
|
||||
PaseoAgentRenameProviderRequest,
|
||||
PaseoAgentSetProviderRequest,
|
||||
RedactedPaseoAgentProviderConfig,
|
||||
} from "@getpaseo/protocol/messages";
|
||||
import { useHostRuntimeClient, useHostRuntimeIsConnected } from "@/runtime/host-runtime";
|
||||
import { useSessionStore } from "@/stores/session-store";
|
||||
|
||||
export function paseoAgentProvidersQueryKey(serverId: string | null) {
|
||||
return ["paseo-agent-providers", serverId] as const;
|
||||
}
|
||||
|
||||
export function paseoAgentCatalogQueryKey(serverId: string | null) {
|
||||
return ["paseo-agent-catalog", serverId] as const;
|
||||
}
|
||||
|
||||
function describeQueryError(error: unknown): string | null {
|
||||
if (!error) {
|
||||
return null;
|
||||
}
|
||||
return error instanceof Error ? error.message : String(error);
|
||||
}
|
||||
|
||||
export type PaseoAgentSetProviderInput = Omit<PaseoAgentSetProviderRequest, "type" | "requestId">;
|
||||
export type PaseoAgentRenameProviderInput = Omit<
|
||||
PaseoAgentRenameProviderRequest,
|
||||
"type" | "requestId"
|
||||
>;
|
||||
export type PaseoAgentOAuthStartResult = PaseoAgentOAuthStartResponse["payload"];
|
||||
export type PaseoAgentOAuthCompleteResult = PaseoAgentOAuthCompleteResponse["payload"];
|
||||
|
||||
interface UsePaseoAgentProvidersResult {
|
||||
supported: boolean;
|
||||
catalogSupported: boolean;
|
||||
providers: RedactedPaseoAgentProviderConfig[];
|
||||
catalog: PaseoAgentCatalogEntry[];
|
||||
defaultModel: string | null;
|
||||
isLoading: boolean;
|
||||
isCatalogLoading: boolean;
|
||||
error: string | null;
|
||||
catalogError: string | null;
|
||||
refresh: () => Promise<void>;
|
||||
setProvider: (
|
||||
input: PaseoAgentSetProviderInput,
|
||||
) => Promise<RedactedPaseoAgentProviderConfig | null>;
|
||||
renameProvider: (
|
||||
input: PaseoAgentRenameProviderInput,
|
||||
) => Promise<RedactedPaseoAgentProviderConfig | null>;
|
||||
startOAuth: (name: string, mode?: string) => Promise<PaseoAgentOAuthStartResult>;
|
||||
completeOAuth: (name: string) => Promise<PaseoAgentOAuthCompleteResult>;
|
||||
}
|
||||
|
||||
export function usePaseoAgentProviders(serverId: string | null): UsePaseoAgentProvidersResult {
|
||||
const { t } = useTranslation();
|
||||
const queryClient = useQueryClient();
|
||||
const client = useHostRuntimeClient(serverId ?? "");
|
||||
const isConnected = useHostRuntimeIsConnected(serverId ?? "");
|
||||
const hostDisconnectedMessage = t("workspace.terminal.hostDisconnected");
|
||||
const saveProviderFailedMessage = t("settings.host.providers.addErrorTitle");
|
||||
// COMPAT(paseoAgentConfig): added in v0.1.85, remove gate after 2026-11-30.
|
||||
const supported = useSessionStore(
|
||||
(state) => state.sessions[serverId ?? ""]?.serverInfo?.features?.paseoAgentConfig === true,
|
||||
);
|
||||
// COMPAT(paseoAgentCatalog): added in v0.1.104, drop the gate when floor >= v0.1.104.
|
||||
const catalogSupported = useSessionStore(
|
||||
(state) => state.sessions[serverId ?? ""]?.serverInfo?.features?.paseoAgentCatalog === true,
|
||||
);
|
||||
const queryKey = useMemo(() => paseoAgentProvidersQueryKey(serverId), [serverId]);
|
||||
const catalogQueryKey = useMemo(() => paseoAgentCatalogQueryKey(serverId), [serverId]);
|
||||
|
||||
const query = useQuery({
|
||||
queryKey,
|
||||
enabled: Boolean(supported && serverId && client && isConnected),
|
||||
staleTime: 30_000,
|
||||
queryFn: async () => {
|
||||
if (!client) {
|
||||
throw new Error(hostDisconnectedMessage);
|
||||
}
|
||||
return client.getPaseoAgentProviders();
|
||||
},
|
||||
});
|
||||
|
||||
const catalogQuery = useQuery({
|
||||
queryKey: catalogQueryKey,
|
||||
enabled: Boolean(supported && catalogSupported && serverId && client && isConnected),
|
||||
staleTime: 30_000,
|
||||
queryFn: async () => {
|
||||
if (!client) {
|
||||
throw new Error(hostDisconnectedMessage);
|
||||
}
|
||||
return client.getPaseoAgentCatalog();
|
||||
},
|
||||
});
|
||||
|
||||
const refresh = useCallback(async () => {
|
||||
await queryClient.invalidateQueries({ queryKey });
|
||||
await queryClient.invalidateQueries({ queryKey: catalogQueryKey });
|
||||
}, [catalogQueryKey, queryClient, queryKey]);
|
||||
|
||||
const error = query.data?.error ?? describeQueryError(query.error);
|
||||
const catalogError = catalogQuery.data?.error ?? describeQueryError(catalogQuery.error);
|
||||
|
||||
const setProviderMutation = useMutation({
|
||||
mutationFn: async (input: PaseoAgentSetProviderInput) => {
|
||||
if (!client) {
|
||||
throw new Error(hostDisconnectedMessage);
|
||||
}
|
||||
const result = await client.setPaseoAgentProvider(input);
|
||||
if (!result.success) {
|
||||
throw new Error(result.error ?? saveProviderFailedMessage);
|
||||
}
|
||||
return result.provider;
|
||||
},
|
||||
onSuccess: () => {
|
||||
void queryClient.invalidateQueries({ queryKey });
|
||||
},
|
||||
});
|
||||
const { mutateAsync: setProviderAsync } = setProviderMutation;
|
||||
|
||||
const setProvider = useCallback(
|
||||
(input: PaseoAgentSetProviderInput) => setProviderAsync(input),
|
||||
[setProviderAsync],
|
||||
);
|
||||
|
||||
const renameProviderMutation = useMutation({
|
||||
mutationFn: async (input: PaseoAgentRenameProviderInput) => {
|
||||
if (!client) {
|
||||
throw new Error(hostDisconnectedMessage);
|
||||
}
|
||||
const result = await client.renamePaseoAgentProvider(input);
|
||||
if (!result.success) {
|
||||
throw new Error(result.error ?? saveProviderFailedMessage);
|
||||
}
|
||||
return result.provider;
|
||||
},
|
||||
onSuccess: () => {
|
||||
void queryClient.invalidateQueries({ queryKey });
|
||||
},
|
||||
});
|
||||
const { mutateAsync: renameProviderAsync } = renameProviderMutation;
|
||||
|
||||
const renameProvider = useCallback(
|
||||
(input: PaseoAgentRenameProviderInput) => renameProviderAsync(input),
|
||||
[renameProviderAsync],
|
||||
);
|
||||
|
||||
const startOAuthMutation = useMutation({
|
||||
mutationFn: async (input: { name: string; mode?: string }) => {
|
||||
if (!client) {
|
||||
throw new Error(hostDisconnectedMessage);
|
||||
}
|
||||
const result = await client.startPaseoAgentOAuth(input.name, { mode: input.mode });
|
||||
if (!result.success) {
|
||||
throw new Error(result.error ?? saveProviderFailedMessage);
|
||||
}
|
||||
return result;
|
||||
},
|
||||
});
|
||||
const { mutateAsync: startOAuthAsync } = startOAuthMutation;
|
||||
|
||||
const startOAuth = useCallback(
|
||||
(name: string, mode?: string) => startOAuthAsync(mode ? { name, mode } : { name }),
|
||||
[startOAuthAsync],
|
||||
);
|
||||
|
||||
const completeOAuthMutation = useMutation({
|
||||
mutationFn: async (name: string) => {
|
||||
if (!client) {
|
||||
throw new Error(hostDisconnectedMessage);
|
||||
}
|
||||
const result = await client.completePaseoAgentOAuth(name);
|
||||
if (!result.success) {
|
||||
throw new Error(result.error ?? saveProviderFailedMessage);
|
||||
}
|
||||
return result;
|
||||
},
|
||||
onSuccess: () => {
|
||||
void queryClient.invalidateQueries({ queryKey });
|
||||
},
|
||||
});
|
||||
const { mutateAsync: completeOAuthAsync } = completeOAuthMutation;
|
||||
|
||||
const completeOAuth = useCallback(
|
||||
(name: string) => completeOAuthAsync(name),
|
||||
[completeOAuthAsync],
|
||||
);
|
||||
|
||||
return {
|
||||
supported,
|
||||
catalogSupported,
|
||||
providers: query.data?.providers ?? [],
|
||||
catalog: catalogQuery.data?.catalog ?? [],
|
||||
defaultModel: query.data?.defaultModel ?? null,
|
||||
isLoading: query.isLoading,
|
||||
isCatalogLoading: catalogQuery.isLoading,
|
||||
error,
|
||||
catalogError,
|
||||
refresh,
|
||||
setProvider,
|
||||
renameProvider,
|
||||
startOAuth,
|
||||
completeOAuth,
|
||||
};
|
||||
}
|
||||
@@ -13,7 +13,10 @@ import type {
|
||||
} from "@getpaseo/client/internal/daemon-client";
|
||||
import type { ScheduleSummary } from "@getpaseo/protocol/schedule/types";
|
||||
import { schedulesQueryBaseKey } from "@/hooks/use-schedules";
|
||||
import type { FetchAggregatedSchedulesResult } from "@/schedules/aggregated-schedules";
|
||||
import type {
|
||||
AggregatedSchedule,
|
||||
FetchAggregatedSchedulesResult,
|
||||
} from "@/schedules/aggregated-schedules";
|
||||
import { useSessionStore } from "@/stores/session-store";
|
||||
|
||||
export type CreateScheduleInput = Omit<CreateScheduleOptions, "requestId">;
|
||||
@@ -60,11 +63,9 @@ function restoreSchedules(queryClient: QueryClient, snapshot: ScheduleListSnapsh
|
||||
}
|
||||
}
|
||||
|
||||
function updateScheduleSections(
|
||||
function updateSchedulesData(
|
||||
queryClient: QueryClient,
|
||||
updateSection: (
|
||||
section: FetchAggregatedSchedulesResult["sections"][number],
|
||||
) => FetchAggregatedSchedulesResult["sections"][number],
|
||||
updateSchedules: (schedules: AggregatedSchedule[]) => AggregatedSchedule[],
|
||||
): void {
|
||||
queryClient.setQueriesData<FetchAggregatedSchedulesResult>(
|
||||
{ queryKey: schedulesQueryBaseKey },
|
||||
@@ -72,7 +73,7 @@ function updateScheduleSections(
|
||||
if (!current) {
|
||||
return current;
|
||||
}
|
||||
return { sections: current.sections.map(updateSection) };
|
||||
return { ...current, schedules: updateSchedules(current.schedules) };
|
||||
},
|
||||
);
|
||||
}
|
||||
@@ -84,26 +85,18 @@ function optimisticallySetStatus(
|
||||
status: ScheduleSummary["status"],
|
||||
): void {
|
||||
const pausedAt = status === "paused" ? new Date().toISOString() : null;
|
||||
updateScheduleSections(queryClient, (section) =>
|
||||
section.serverId === serverId
|
||||
? {
|
||||
...section,
|
||||
schedules: section.schedules.map((schedule) =>
|
||||
schedule.id === id ? { ...schedule, status, pausedAt } : schedule,
|
||||
),
|
||||
}
|
||||
: section,
|
||||
updateSchedulesData(queryClient, (schedules) =>
|
||||
schedules.map((schedule) =>
|
||||
schedule.serverId === serverId && schedule.id === id
|
||||
? { ...schedule, status, pausedAt }
|
||||
: schedule,
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
function optimisticallyRemove(queryClient: QueryClient, serverId: string, id: string): void {
|
||||
updateScheduleSections(queryClient, (section) =>
|
||||
section.serverId === serverId
|
||||
? {
|
||||
...section,
|
||||
schedules: section.schedules.filter((schedule) => schedule.id !== id),
|
||||
}
|
||||
: section,
|
||||
updateSchedulesData(queryClient, (schedules) =>
|
||||
schedules.filter((schedule) => !(schedule.serverId === serverId && schedule.id === id)),
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -1,23 +1,30 @@
|
||||
import { useMemo, useSyncExternalStore } from "react";
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import { keepPreviousData, useQuery } from "@tanstack/react-query";
|
||||
import { getHostRuntimeStore, useHosts } from "@/runtime/host-runtime";
|
||||
import {
|
||||
fetchAggregatedSchedules,
|
||||
type AggregatedSchedule,
|
||||
type ScheduleHostError,
|
||||
type ScheduleHostInput,
|
||||
type ScheduleHostSection,
|
||||
} from "@/schedules/aggregated-schedules";
|
||||
|
||||
export type { ScheduleHostSection } from "@/schedules/aggregated-schedules";
|
||||
export type { AggregatedSchedule, ScheduleHostError } from "@/schedules/aggregated-schedules";
|
||||
|
||||
export const schedulesQueryBaseKey = ["schedules"] as const;
|
||||
|
||||
export function schedulesQueryKey(hosts: readonly ScheduleHostInput[]) {
|
||||
return [...schedulesQueryBaseKey, hosts.map((host) => host.serverId).join("|")] as const;
|
||||
// Cache identity for the host set. The query also carries the runtime version
|
||||
// (below) so it retries as connectivity changes and reliably fetches once a host
|
||||
// comes online — even on a cold deep-link. The full-screen spinner flash that
|
||||
// keying on the version used to cause is prevented by keepPreviousData plus the
|
||||
// isInitialLoad(data === undefined) gate, not by dropping the version.
|
||||
export function schedulesQueryKey(serverIds: readonly string[]) {
|
||||
return [...schedulesQueryBaseKey, [...serverIds].sort().join("|")] as const;
|
||||
}
|
||||
|
||||
export interface UseSchedulesResult {
|
||||
sections: ScheduleHostSection[];
|
||||
isLoading: boolean;
|
||||
schedules: AggregatedSchedule[];
|
||||
hostErrors: ScheduleHostError[];
|
||||
isInitialLoad: boolean;
|
||||
isError: boolean;
|
||||
error: Error | null;
|
||||
refetch: () => void;
|
||||
@@ -33,23 +40,21 @@ export function useSchedules(): UseSchedulesResult {
|
||||
() => runtime.getVersion(),
|
||||
);
|
||||
const hostInputs = useMemo<ScheduleHostInput[]>(
|
||||
() =>
|
||||
hosts.map((host) => ({
|
||||
serverId: host.serverId,
|
||||
serverName: host.label,
|
||||
})),
|
||||
() => hosts.map((host) => ({ serverId: host.serverId, serverName: host.label })),
|
||||
[hosts],
|
||||
);
|
||||
|
||||
const query = useQuery({
|
||||
queryKey: [...schedulesQueryKey(hostInputs), runtimeVersion] as const,
|
||||
queryKey: [...schedulesQueryKey(hostInputs.map((host) => host.serverId)), runtimeVersion],
|
||||
queryFn: () => fetchAggregatedSchedules({ hosts: hostInputs, runtime }),
|
||||
staleTime: 5_000,
|
||||
placeholderData: keepPreviousData,
|
||||
});
|
||||
|
||||
return {
|
||||
sections: query.data?.sections ?? [],
|
||||
isLoading: query.isLoading,
|
||||
schedules: query.data?.schedules ?? [],
|
||||
hostErrors: query.data?.hostErrors ?? [],
|
||||
isInitialLoad: query.isLoading && query.data === undefined,
|
||||
isError: query.isError,
|
||||
error: query.error,
|
||||
refetch: () => {
|
||||
|
||||
@@ -1,8 +1,9 @@
|
||||
import type { DaemonClient } from "@getpaseo/client/internal/daemon-client";
|
||||
import type { ScheduleSummary } from "@getpaseo/protocol/schedule/types";
|
||||
import { isNewAgentSchedule } from "@/utils/schedule-format";
|
||||
import { toErrorMessage } from "@/utils/error-messages";
|
||||
|
||||
export const ALL_SCHEDULE_HOSTS_FAILED_MESSAGE = "No connected hosts could load schedules";
|
||||
|
||||
export interface ScheduleHostInput {
|
||||
serverId: string;
|
||||
serverName: string;
|
||||
@@ -17,65 +18,76 @@ export interface ScheduleRuntime {
|
||||
getSnapshot(serverId: string): ScheduleRuntimeSnapshot | null | undefined;
|
||||
}
|
||||
|
||||
export interface ScheduleHostSection {
|
||||
/** A schedule tagged with the host it came from, so the flat list can render a
|
||||
* per-row host label and scope mutations without host sections. */
|
||||
export interface AggregatedSchedule extends ScheduleSummary {
|
||||
serverId: string;
|
||||
serverName: string;
|
||||
isOnline: boolean;
|
||||
schedules: ScheduleSummary[];
|
||||
error: string | null;
|
||||
}
|
||||
|
||||
export interface FetchAggregatedSchedulesInput {
|
||||
hosts: ScheduleHostInput[];
|
||||
runtime: ScheduleRuntime;
|
||||
export interface ScheduleHostError {
|
||||
serverId: string;
|
||||
serverName: string;
|
||||
message: string;
|
||||
}
|
||||
|
||||
export interface FetchAggregatedSchedulesResult {
|
||||
sections: ScheduleHostSection[];
|
||||
schedules: AggregatedSchedule[];
|
||||
hostErrors: ScheduleHostError[];
|
||||
}
|
||||
|
||||
export interface FetchAggregatedSchedulesInput {
|
||||
hosts: readonly ScheduleHostInput[];
|
||||
runtime: ScheduleRuntime;
|
||||
}
|
||||
|
||||
/**
|
||||
* Fetch schedules across connected hosts and merge them into one flat list.
|
||||
* Connectivity is checked here at execution time (not pre-filtered by the hook)
|
||||
* so the query — retried as the runtime version changes — reliably picks a host
|
||||
* up the moment it comes online, including on a cold deep-link.
|
||||
*
|
||||
* Offline hosts are skipped. A connected host that fails contributes to
|
||||
* `hostErrors` (surfaced as a banner) while the rest still render; only when
|
||||
* every connected host fails do we throw so the screen shows a full error.
|
||||
*/
|
||||
export async function fetchAggregatedSchedules(
|
||||
input: FetchAggregatedSchedulesInput,
|
||||
): Promise<FetchAggregatedSchedulesResult> {
|
||||
const sections = await Promise.all(
|
||||
input.hosts.map(async (host): Promise<ScheduleHostSection> => {
|
||||
const schedules: AggregatedSchedule[] = [];
|
||||
const hostErrors: ScheduleHostError[] = [];
|
||||
let connectedAttempts = 0;
|
||||
|
||||
await Promise.all(
|
||||
input.hosts.map(async (host) => {
|
||||
const snapshot = input.runtime.getSnapshot(host.serverId);
|
||||
const isOnline = snapshot?.connectionStatus === "online";
|
||||
const client = input.runtime.getClient(host.serverId);
|
||||
|
||||
if (!client || !isOnline) {
|
||||
return {
|
||||
serverId: host.serverId,
|
||||
serverName: host.serverName,
|
||||
isOnline,
|
||||
schedules: [],
|
||||
error: null,
|
||||
};
|
||||
return;
|
||||
}
|
||||
|
||||
connectedAttempts += 1;
|
||||
try {
|
||||
const payload = await client.scheduleList();
|
||||
if (payload.error) {
|
||||
throw new Error(payload.error);
|
||||
}
|
||||
return {
|
||||
serverId: host.serverId,
|
||||
serverName: host.serverName,
|
||||
isOnline,
|
||||
schedules: payload.schedules.filter(isNewAgentSchedule),
|
||||
error: null,
|
||||
};
|
||||
for (const schedule of payload.schedules) {
|
||||
schedules.push({ ...schedule, serverId: host.serverId, serverName: host.serverName });
|
||||
}
|
||||
} catch (error) {
|
||||
return {
|
||||
hostErrors.push({
|
||||
serverId: host.serverId,
|
||||
serverName: host.serverName,
|
||||
isOnline,
|
||||
schedules: [],
|
||||
error: toErrorMessage(error),
|
||||
};
|
||||
message: toErrorMessage(error),
|
||||
});
|
||||
}
|
||||
}),
|
||||
);
|
||||
|
||||
return { sections };
|
||||
if (connectedAttempts > 0 && schedules.length === 0 && hostErrors.length === connectedAttempts) {
|
||||
throw new Error(ALL_SCHEDULE_HOSTS_FAILED_MESSAGE);
|
||||
}
|
||||
|
||||
return { schedules, hostErrors };
|
||||
}
|
||||
|
||||
133
packages/app/src/schedules/schedule-derivation.test.ts
Normal file
133
packages/app/src/schedules/schedule-derivation.test.ts
Normal file
@@ -0,0 +1,133 @@
|
||||
import type { ScheduleSummary } from "@getpaseo/protocol/schedule/types";
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { resolveSchedule, scheduleBucket, type ScheduleTargetAgent } from "./schedule-derivation";
|
||||
|
||||
const NOW = Date.parse("2026-07-02T00:00:00.000Z");
|
||||
const AGENT_ID = "00000000-0000-4000-8000-000000000000";
|
||||
|
||||
function makeSchedule(overrides: Partial<ScheduleSummary>): ScheduleSummary {
|
||||
return {
|
||||
id: "schedule-1",
|
||||
name: "Nightly",
|
||||
prompt: "Run the task",
|
||||
cadence: { type: "every", everyMs: 60_000 },
|
||||
target: { type: "new-agent", config: { provider: "codex", cwd: "/tmp/project" } },
|
||||
status: "active",
|
||||
createdAt: "2026-07-01T00:00:00.000Z",
|
||||
updatedAt: "2026-07-01T00:00:00.000Z",
|
||||
nextRunAt: "2026-07-02T01:00:00.000Z",
|
||||
lastRunAt: null,
|
||||
pausedAt: null,
|
||||
expiresAt: null,
|
||||
maxRuns: null,
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
function resolve(
|
||||
schedule: ScheduleSummary,
|
||||
options?: {
|
||||
agents?: Array<[string, ScheduleTargetAgent]>;
|
||||
projects?: Array<[string, string]>;
|
||||
agentDataLoaded?: boolean;
|
||||
},
|
||||
) {
|
||||
return resolveSchedule({
|
||||
schedule,
|
||||
serverId: "host-1",
|
||||
now: NOW,
|
||||
agentsByKey: new Map(options?.agents ?? []),
|
||||
projectNameByCwd: new Map(options?.projects ?? []),
|
||||
agentDataLoaded: options?.agentDataLoaded ?? true,
|
||||
});
|
||||
}
|
||||
|
||||
describe("resolveSchedule state", () => {
|
||||
it("keeps active and paused schedules runnable", () => {
|
||||
expect(resolve(makeSchedule({ status: "active" })).state).toBe("active");
|
||||
expect(resolve(makeSchedule({ status: "paused" })).state).toBe("paused");
|
||||
expect(scheduleBucket("active")).toBe("runnable");
|
||||
expect(scheduleBucket("paused")).toBe("runnable");
|
||||
});
|
||||
|
||||
it("treats a past expiresAt as expired regardless of status", () => {
|
||||
const result = resolve(
|
||||
makeSchedule({ status: "active", expiresAt: "2026-07-01T00:00:00.000Z" }),
|
||||
);
|
||||
expect(result.state).toBe("expired");
|
||||
expect(result.bucket).toBe("ended");
|
||||
});
|
||||
|
||||
it("ignores an unparseable expiresAt", () => {
|
||||
expect(resolve(makeSchedule({ expiresAt: "not-a-date" })).state).toBe("active");
|
||||
});
|
||||
|
||||
it("derives finished only from completed-and-not-expired", () => {
|
||||
expect(resolve(makeSchedule({ status: "completed" })).state).toBe("finished");
|
||||
expect(
|
||||
resolve(makeSchedule({ status: "completed", expiresAt: "2026-07-01T00:00:00.000Z" })).state,
|
||||
).toBe("expired");
|
||||
});
|
||||
|
||||
it("marks an agent target gone when the client has no such agent", () => {
|
||||
const schedule = makeSchedule({ target: { type: "agent", agentId: AGENT_ID } });
|
||||
expect(resolve(schedule).state).toBe("targetGone");
|
||||
expect(resolve(schedule).bucket).toBe("ended");
|
||||
});
|
||||
|
||||
it("does not claim gone before the agent directory has loaded", () => {
|
||||
const schedule = makeSchedule({ target: { type: "agent", agentId: AGENT_ID } });
|
||||
expect(resolve(schedule, { agentDataLoaded: false }).state).toBe("active");
|
||||
});
|
||||
|
||||
it("prefers target-gone over the raw paused/completed status for a live agent target", () => {
|
||||
const paused = makeSchedule({
|
||||
status: "paused",
|
||||
target: { type: "agent", agentId: AGENT_ID },
|
||||
});
|
||||
expect(resolve(paused).state).toBe("targetGone");
|
||||
});
|
||||
|
||||
it("never claims a new-agent cwd is gone", () => {
|
||||
expect(resolve(makeSchedule({ status: "active" })).state).toBe("active");
|
||||
});
|
||||
});
|
||||
|
||||
describe("resolveSchedule target line", () => {
|
||||
it("names an agent target by its client title and provider", () => {
|
||||
const schedule = makeSchedule({ target: { type: "agent", agentId: AGENT_ID } });
|
||||
const result = resolve(schedule, {
|
||||
agents: [[`host-1:${AGENT_ID}`, { title: "Fix build", provider: "claude" }]],
|
||||
});
|
||||
expect(result.target).toEqual({ label: "Fix build", provider: "claude" });
|
||||
expect(result.state).toBe("active");
|
||||
});
|
||||
|
||||
it("falls back to Untitled agent when the agent has no title", () => {
|
||||
const schedule = makeSchedule({ target: { type: "agent", agentId: AGENT_ID } });
|
||||
const result = resolve(schedule, {
|
||||
agents: [[`host-1:${AGENT_ID}`, { title: " ", provider: "codex" }]],
|
||||
});
|
||||
expect(result.target.label).toBe("Untitled agent");
|
||||
});
|
||||
|
||||
it("labels a gone agent target as unavailable with no glyph", () => {
|
||||
const schedule = makeSchedule({ target: { type: "agent", agentId: AGENT_ID } });
|
||||
expect(resolve(schedule).target).toEqual({ label: "Agent unavailable", provider: null });
|
||||
});
|
||||
|
||||
it("names a new-agent cwd by matched project, else the shortened path", () => {
|
||||
const matched = makeSchedule({
|
||||
target: { type: "new-agent", config: { provider: "codex", cwd: "/tmp/project" } },
|
||||
});
|
||||
expect(resolve(matched, { projects: [["host-1:/tmp/project", "My Project"]] }).target).toEqual({
|
||||
label: "My Project",
|
||||
provider: "codex",
|
||||
});
|
||||
|
||||
const unmatched = makeSchedule({
|
||||
target: { type: "new-agent", config: { provider: "codex", cwd: "/Users/alex/work/api" } },
|
||||
});
|
||||
expect(resolve(unmatched).target).toEqual({ label: "~/work/api", provider: "codex" });
|
||||
});
|
||||
});
|
||||
109
packages/app/src/schedules/schedule-derivation.ts
Normal file
109
packages/app/src/schedules/schedule-derivation.ts
Normal file
@@ -0,0 +1,109 @@
|
||||
import type { ScheduleSummary } from "@getpaseo/protocol/schedule/types";
|
||||
import { describeScheduleCwd } from "@/schedules/schedule-project-targets";
|
||||
|
||||
// Derived from existing fields only — no new protocol state. "active"/"paused"
|
||||
// mirror the stored status; the rest are computed truths the daemon does not
|
||||
// spell out in a single field.
|
||||
export type ScheduleDerivedState = "active" | "paused" | "expired" | "finished" | "targetGone";
|
||||
|
||||
export type ScheduleBucket = "runnable" | "ended";
|
||||
|
||||
export interface ScheduleTargetAgent {
|
||||
title: string | null;
|
||||
provider: string | null;
|
||||
}
|
||||
|
||||
export interface ScheduleTargetResolution {
|
||||
/** The target line: agent title, project name, or the shortened cwd. */
|
||||
label: string;
|
||||
/** Provider glyph for the row, when known. */
|
||||
provider: string | null;
|
||||
}
|
||||
|
||||
export interface ResolvedSchedule {
|
||||
state: ScheduleDerivedState;
|
||||
bucket: ScheduleBucket;
|
||||
target: ScheduleTargetResolution;
|
||||
}
|
||||
|
||||
export interface ResolveScheduleInput {
|
||||
schedule: ScheduleSummary;
|
||||
serverId: string;
|
||||
now: number;
|
||||
/** Client agent directory keyed by `${serverId}:${agentId}`. */
|
||||
agentsByKey: ReadonlyMap<string, ScheduleTargetAgent>;
|
||||
/** Known project roots keyed by `${serverId}:${cwd}`. */
|
||||
projectNameByCwd: ReadonlyMap<string, string>;
|
||||
/**
|
||||
* Whether the agent directory has finished its first load. While false we do
|
||||
* not claim an agent target is gone — absence would just be a cold cache.
|
||||
*/
|
||||
agentDataLoaded: boolean;
|
||||
}
|
||||
|
||||
function agentKey(serverId: string, agentId: string): string {
|
||||
return `${serverId}:${agentId}`;
|
||||
}
|
||||
|
||||
function isExpired(schedule: ScheduleSummary, now: number): boolean {
|
||||
if (!schedule.expiresAt) {
|
||||
return false;
|
||||
}
|
||||
const expiresAt = Date.parse(schedule.expiresAt);
|
||||
return Number.isFinite(expiresAt) && expiresAt <= now;
|
||||
}
|
||||
|
||||
function isAgentTargetGone(input: ResolveScheduleInput): boolean {
|
||||
const { schedule, serverId, agentsByKey, agentDataLoaded } = input;
|
||||
if (schedule.target.type !== "agent" || !agentDataLoaded) {
|
||||
return false;
|
||||
}
|
||||
return !agentsByKey.has(agentKey(serverId, schedule.target.agentId));
|
||||
}
|
||||
|
||||
function resolveTarget(input: ResolveScheduleInput): ScheduleTargetResolution {
|
||||
const { schedule, serverId, agentsByKey, projectNameByCwd } = input;
|
||||
if (schedule.target.type === "agent") {
|
||||
const agent = agentsByKey.get(agentKey(serverId, schedule.target.agentId));
|
||||
if (agent) {
|
||||
return { label: agent.title?.trim() || "Untitled agent", provider: agent.provider };
|
||||
}
|
||||
return { label: "Agent unavailable", provider: null };
|
||||
}
|
||||
return {
|
||||
label: describeScheduleCwd({ serverId, cwd: schedule.target.config.cwd, projectNameByCwd }),
|
||||
provider: schedule.target.config.provider,
|
||||
};
|
||||
}
|
||||
|
||||
// One badge, one truth. Order matters: expiry and a missing target are more
|
||||
// informative than the raw "completed"/"paused" status, so they win.
|
||||
function deriveState(input: ResolveScheduleInput): ScheduleDerivedState {
|
||||
const { schedule, now } = input;
|
||||
if (isExpired(schedule, now)) {
|
||||
return "expired";
|
||||
}
|
||||
if (isAgentTargetGone(input)) {
|
||||
return "targetGone";
|
||||
}
|
||||
if (schedule.status === "completed") {
|
||||
return "finished";
|
||||
}
|
||||
if (schedule.status === "paused") {
|
||||
return "paused";
|
||||
}
|
||||
return "active";
|
||||
}
|
||||
|
||||
export function scheduleBucket(state: ScheduleDerivedState): ScheduleBucket {
|
||||
return state === "active" || state === "paused" ? "runnable" : "ended";
|
||||
}
|
||||
|
||||
export function resolveSchedule(input: ResolveScheduleInput): ResolvedSchedule {
|
||||
const state = deriveState(input);
|
||||
return {
|
||||
state,
|
||||
bucket: scheduleBucket(state),
|
||||
target: resolveTarget(input),
|
||||
};
|
||||
}
|
||||
73
packages/app/src/schedules/schedule-project-targets.test.ts
Normal file
73
packages/app/src/schedules/schedule-project-targets.test.ts
Normal file
@@ -0,0 +1,73 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import type { ProjectSummary } from "@/utils/projects";
|
||||
import {
|
||||
buildProjectNameByCwd,
|
||||
buildScheduleProjectTargets,
|
||||
describeScheduleCwd,
|
||||
} from "./schedule-project-targets";
|
||||
|
||||
function makeProject(overrides: Partial<ProjectSummary>): ProjectSummary {
|
||||
return {
|
||||
projectKey: "proj",
|
||||
projectName: "Project",
|
||||
hosts: [],
|
||||
totalWorkspaceCount: 0,
|
||||
hostCount: 0,
|
||||
onlineHostCount: 0,
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
function makeHost(overrides: Partial<ProjectSummary["hosts"][number]>) {
|
||||
return {
|
||||
serverId: "host-1",
|
||||
serverName: "Host 1",
|
||||
isOnline: true,
|
||||
repoRoot: "/tmp/project",
|
||||
workspaceCount: 0,
|
||||
workspaces: [],
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
describe("buildScheduleProjectTargets", () => {
|
||||
it("emits one target per online host with a repo root", () => {
|
||||
const targets = buildScheduleProjectTargets([
|
||||
makeProject({
|
||||
projectName: "Alpha",
|
||||
hosts: [makeHost({ repoRoot: "/tmp/alpha" }), makeHost({ serverId: "host-2" })],
|
||||
}),
|
||||
]);
|
||||
expect(targets).toHaveLength(2);
|
||||
expect(targets[0]).toMatchObject({
|
||||
serverId: "host-1",
|
||||
cwd: "/tmp/alpha",
|
||||
projectName: "Alpha",
|
||||
});
|
||||
});
|
||||
|
||||
it("skips offline hosts and blank repo roots", () => {
|
||||
const targets = buildScheduleProjectTargets([
|
||||
makeProject({
|
||||
hosts: [makeHost({ isOnline: false }), makeHost({ serverId: "host-3", repoRoot: " " })],
|
||||
}),
|
||||
]);
|
||||
expect(targets).toHaveLength(0);
|
||||
});
|
||||
});
|
||||
|
||||
describe("describeScheduleCwd", () => {
|
||||
it("prefers a matched project name and shortens unmatched paths", () => {
|
||||
const byCwd = buildProjectNameByCwd(
|
||||
buildScheduleProjectTargets([
|
||||
makeProject({ projectName: "Alpha", hosts: [makeHost({ repoRoot: "/tmp/alpha" })] }),
|
||||
]),
|
||||
);
|
||||
expect(
|
||||
describeScheduleCwd({ serverId: "host-1", cwd: "/tmp/alpha", projectNameByCwd: byCwd }),
|
||||
).toBe("Alpha");
|
||||
expect(
|
||||
describeScheduleCwd({ serverId: "host-1", cwd: "/Users/sam/api", projectNameByCwd: byCwd }),
|
||||
).toBe("~/api");
|
||||
});
|
||||
});
|
||||
75
packages/app/src/schedules/schedule-project-targets.ts
Normal file
75
packages/app/src/schedules/schedule-project-targets.ts
Normal file
@@ -0,0 +1,75 @@
|
||||
import type { ProjectSummary } from "@/utils/projects";
|
||||
import { shortenPath } from "@/utils/shorten-path";
|
||||
|
||||
export const PROJECT_OPTION_PREFIX = "project:";
|
||||
|
||||
export interface ScheduleProjectTarget {
|
||||
optionId: string;
|
||||
serverId: string;
|
||||
serverName: string;
|
||||
projectKey: string;
|
||||
projectName: string;
|
||||
cwd: string;
|
||||
}
|
||||
|
||||
export function buildProjectOptionId(serverId: string, projectKey: string): string {
|
||||
return `${PROJECT_OPTION_PREFIX}${serverId}:${projectKey}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* The project roots the schedule form can target: one per online host of each
|
||||
* project, keyed by (serverId, cwd). The schedules list reuses this set to name
|
||||
* a schedule's stored cwd; the two surfaces must agree on what "a project" is.
|
||||
*/
|
||||
export function buildScheduleProjectTargets(
|
||||
projects: readonly ProjectSummary[],
|
||||
): ScheduleProjectTarget[] {
|
||||
const targets: ScheduleProjectTarget[] = [];
|
||||
for (const project of projects) {
|
||||
for (const host of project.hosts) {
|
||||
const cwd = host.repoRoot.trim();
|
||||
if (!host.isOnline || !cwd) {
|
||||
continue;
|
||||
}
|
||||
targets.push({
|
||||
optionId: buildProjectOptionId(host.serverId, project.projectKey),
|
||||
serverId: host.serverId,
|
||||
serverName: host.serverName,
|
||||
projectKey: project.projectKey,
|
||||
projectName: project.projectName,
|
||||
cwd,
|
||||
});
|
||||
}
|
||||
}
|
||||
return targets;
|
||||
}
|
||||
|
||||
function projectNameKey(serverId: string, cwd: string): string {
|
||||
return `${serverId}:${cwd.trim()}`;
|
||||
}
|
||||
|
||||
/** Map (serverId, cwd) -> project name for naming a schedule's stored cwd. */
|
||||
export function buildProjectNameByCwd(
|
||||
targets: readonly ScheduleProjectTarget[],
|
||||
): Map<string, string> {
|
||||
const byCwd = new Map<string, string>();
|
||||
for (const target of targets) {
|
||||
byCwd.set(projectNameKey(target.serverId, target.cwd), target.projectName);
|
||||
}
|
||||
return byCwd;
|
||||
}
|
||||
|
||||
/**
|
||||
* Name a stored cwd for display: the matching project name when the client
|
||||
* knows this root on this host, otherwise the shortened path itself. Never
|
||||
* blank, never a claim the client cannot back up.
|
||||
*/
|
||||
export function describeScheduleCwd(input: {
|
||||
serverId: string;
|
||||
cwd: string;
|
||||
projectNameByCwd: ReadonlyMap<string, string>;
|
||||
}): string {
|
||||
return (
|
||||
input.projectNameByCwd.get(projectNameKey(input.serverId, input.cwd)) ?? shortenPath(input.cwd)
|
||||
);
|
||||
}
|
||||
@@ -1,14 +1,40 @@
|
||||
import { useCallback, useMemo, useState, type ReactElement } from "react";
|
||||
import {
|
||||
useCallback,
|
||||
useEffect,
|
||||
useMemo,
|
||||
useState,
|
||||
useSyncExternalStore,
|
||||
type ReactElement,
|
||||
} from "react";
|
||||
import { ScrollView, Text, View } from "react-native";
|
||||
import { useIsFocused } from "@react-navigation/native";
|
||||
import { Plus } from "lucide-react-native";
|
||||
import { StyleSheet } from "react-native-unistyles";
|
||||
import { MenuHeader } from "@/components/headers/menu-header";
|
||||
import { HostFilter } from "@/components/hosts/host-filter";
|
||||
import { ALL_HOSTS_OPTION_ID } from "@/components/hosts/host-picker";
|
||||
import { ScheduleFormSheet } from "@/components/schedules/schedule-form-sheet";
|
||||
import { SchedulesTable } from "@/components/schedules/schedules-table";
|
||||
import { SchedulesTable, type ScheduleRowView } from "@/components/schedules/schedules-table";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { LoadingSpinner } from "@/components/ui/loading-spinner";
|
||||
import { useSchedules, type ScheduleHostSection } from "@/hooks/use-schedules";
|
||||
import { SegmentedControl } from "@/components/ui/segmented-control";
|
||||
import { useAggregatedAgents } from "@/hooks/use-aggregated-agents";
|
||||
import { useProjects } from "@/hooks/use-projects";
|
||||
import {
|
||||
useSchedules,
|
||||
type AggregatedSchedule,
|
||||
type ScheduleHostError,
|
||||
} from "@/hooks/use-schedules";
|
||||
import { getHostRuntimeStore, useHosts } from "@/runtime/host-runtime";
|
||||
import {
|
||||
resolveSchedule,
|
||||
type ScheduleBucket,
|
||||
type ScheduleTargetAgent,
|
||||
} from "@/schedules/schedule-derivation";
|
||||
import {
|
||||
buildProjectNameByCwd,
|
||||
buildScheduleProjectTargets,
|
||||
} from "@/schedules/schedule-project-targets";
|
||||
import type { ScheduleSummary } from "@getpaseo/protocol/schedule/types";
|
||||
|
||||
type FormState =
|
||||
@@ -16,6 +42,11 @@ type FormState =
|
||||
| { mode: "create" }
|
||||
| { mode: "edit"; serverId: string; schedule: ScheduleSummary };
|
||||
|
||||
const STATUS_FILTER_OPTIONS: { value: ScheduleBucket; label: string; testID: string }[] = [
|
||||
{ value: "runnable", label: "Active", testID: "schedules-filter-active" },
|
||||
{ value: "ended", label: "Ended", testID: "schedules-filter-ended" },
|
||||
];
|
||||
|
||||
export function SchedulesScreen(): ReactElement {
|
||||
const isFocused = useIsFocused();
|
||||
|
||||
@@ -27,38 +58,120 @@ export function SchedulesScreen(): ReactElement {
|
||||
}
|
||||
|
||||
function SchedulesScreenContent(): ReactElement {
|
||||
const { sections, isLoading, isError, error, refetch } = useSchedules();
|
||||
const [form, setForm] = useState<FormState>({ mode: "closed" });
|
||||
|
||||
const openCreate = useCallback(() => {
|
||||
setForm({ mode: "create" });
|
||||
}, []);
|
||||
|
||||
const openEdit = useCallback((serverId: string, schedule: ScheduleSummary) => {
|
||||
setForm({ mode: "edit", serverId, schedule });
|
||||
}, []);
|
||||
|
||||
const closeForm = useCallback(() => {
|
||||
setForm({ mode: "closed" });
|
||||
}, []);
|
||||
|
||||
const headerAction = useMemo(
|
||||
() => (
|
||||
<Button leftIcon={Plus} onPress={openCreate} size="sm" testID="schedules-new">
|
||||
New schedule
|
||||
</Button>
|
||||
),
|
||||
[openCreate],
|
||||
const { schedules, hostErrors, isInitialLoad, isError, refetch } = useSchedules();
|
||||
const { agents } = useAggregatedAgents({ includeArchived: true });
|
||||
const { projects } = useProjects();
|
||||
const hosts = useHosts();
|
||||
const runtime = getHostRuntimeStore();
|
||||
const runtimeVersion = useSyncExternalStore(
|
||||
(onStoreChange) => runtime.subscribeAll(onStoreChange),
|
||||
() => runtime.getVersion(),
|
||||
() => runtime.getVersion(),
|
||||
);
|
||||
|
||||
// Per-host agent-directory readiness from the runtime, not the aggregate agent
|
||||
// flag: the aggregate `isInitialLoad` flips false as soon as *any* host has
|
||||
// agents, so a still-loading host would falsely mark its agent-target
|
||||
// schedules "gone". `hasEverLoadedAgentDirectory` is true only once that
|
||||
// host's directory has loaded at least once.
|
||||
const agentDirReadyHosts = useMemo(() => {
|
||||
void runtimeVersion;
|
||||
const ready = new Set<string>();
|
||||
for (const host of hosts) {
|
||||
if (runtime.getSnapshot(host.serverId)?.hasEverLoadedAgentDirectory) {
|
||||
ready.add(host.serverId);
|
||||
}
|
||||
}
|
||||
return ready;
|
||||
}, [hosts, runtime, runtimeVersion]);
|
||||
|
||||
const [form, setForm] = useState<FormState>({ mode: "closed" });
|
||||
const [selectedHost, setSelectedHost] = useState(ALL_HOSTS_OPTION_ID);
|
||||
const [statusFilter, setStatusFilter] = useState<ScheduleBucket>("runnable");
|
||||
|
||||
useEffect(() => {
|
||||
if (
|
||||
selectedHost !== ALL_HOSTS_OPTION_ID &&
|
||||
!hosts.some((host) => host.serverId === selectedHost)
|
||||
) {
|
||||
setSelectedHost(ALL_HOSTS_OPTION_ID);
|
||||
}
|
||||
}, [hosts, selectedHost]);
|
||||
|
||||
const openCreate = useCallback(() => setForm({ mode: "create" }), []);
|
||||
const openEdit = useCallback((schedule: AggregatedSchedule) => {
|
||||
setForm({ mode: "edit", serverId: schedule.serverId, schedule });
|
||||
}, []);
|
||||
const closeForm = useCallback(() => setForm({ mode: "closed" }), []);
|
||||
|
||||
const agentsByKey = useMemo(() => {
|
||||
const map = new Map<string, ScheduleTargetAgent>();
|
||||
for (const agent of agents) {
|
||||
map.set(`${agent.serverId}:${agent.id}`, { title: agent.title, provider: agent.provider });
|
||||
}
|
||||
return map;
|
||||
}, [agents]);
|
||||
|
||||
const projectNameByCwd = useMemo(
|
||||
() => buildProjectNameByCwd(buildScheduleProjectTargets(projects)),
|
||||
[projects],
|
||||
);
|
||||
|
||||
// Resolve every schedule's derived state and target line once, then partition
|
||||
// by the host and status filters. Sorted newest-first for a stable order
|
||||
// across hosts.
|
||||
const resolvedRows = useMemo(() => {
|
||||
const now = Date.now();
|
||||
return schedules.map((schedule) => ({
|
||||
schedule,
|
||||
resolved: resolveSchedule({
|
||||
schedule,
|
||||
serverId: schedule.serverId,
|
||||
now,
|
||||
agentsByKey,
|
||||
projectNameByCwd,
|
||||
agentDataLoaded: agentDirReadyHosts.has(schedule.serverId),
|
||||
}),
|
||||
}));
|
||||
}, [schedules, agentsByKey, projectNameByCwd, agentDirReadyHosts]);
|
||||
|
||||
const visibleRows = useMemo<ScheduleRowView[]>(() => {
|
||||
const singleHost = hosts.length <= 1;
|
||||
return resolvedRows
|
||||
.filter(
|
||||
({ schedule, resolved }) =>
|
||||
(selectedHost === ALL_HOSTS_OPTION_ID || schedule.serverId === selectedHost) &&
|
||||
resolved.bucket === statusFilter,
|
||||
)
|
||||
.sort((a, b) => Date.parse(b.schedule.createdAt) - Date.parse(a.schedule.createdAt))
|
||||
.map(({ schedule, resolved }) => ({
|
||||
schedule,
|
||||
targetLabel: resolved.target.label,
|
||||
provider: resolved.target.provider,
|
||||
state: resolved.state,
|
||||
serverName: schedule.serverName,
|
||||
singleHost,
|
||||
}));
|
||||
}, [resolvedRows, selectedHost, statusFilter, hosts.length]);
|
||||
|
||||
const showLoadError = isError && schedules.length === 0;
|
||||
const showHostFilter = hosts.length > 1;
|
||||
|
||||
return (
|
||||
<View style={styles.container}>
|
||||
<MenuHeader title="Schedules" rightContent={headerAction} />
|
||||
<SchedulesBody
|
||||
sections={sections}
|
||||
isLoading={isLoading}
|
||||
isError={isError}
|
||||
error={error}
|
||||
<MenuHeader title="Schedules" />
|
||||
<SchedulesScreenBody
|
||||
rows={visibleRows}
|
||||
hostErrors={hostErrors}
|
||||
hasSchedules={schedules.length > 0}
|
||||
isInitialLoad={isInitialLoad}
|
||||
showLoadError={showLoadError}
|
||||
statusFilter={statusFilter}
|
||||
onStatusFilterChange={setStatusFilter}
|
||||
showHostFilter={showHostFilter}
|
||||
hosts={hosts}
|
||||
selectedHost={selectedHost}
|
||||
onSelectHost={setSelectedHost}
|
||||
onRetry={refetch}
|
||||
onCreate={openCreate}
|
||||
onEdit={openEdit}
|
||||
@@ -74,24 +187,38 @@ function SchedulesScreenContent(): ReactElement {
|
||||
);
|
||||
}
|
||||
|
||||
function SchedulesBody({
|
||||
sections,
|
||||
isLoading,
|
||||
isError,
|
||||
error,
|
||||
function SchedulesScreenBody({
|
||||
rows,
|
||||
hostErrors,
|
||||
hasSchedules,
|
||||
isInitialLoad,
|
||||
showLoadError,
|
||||
statusFilter,
|
||||
onStatusFilterChange,
|
||||
showHostFilter,
|
||||
hosts,
|
||||
selectedHost,
|
||||
onSelectHost,
|
||||
onRetry,
|
||||
onCreate,
|
||||
onEdit,
|
||||
}: {
|
||||
sections: ScheduleHostSection[];
|
||||
isLoading: boolean;
|
||||
isError: boolean;
|
||||
error: Error | null;
|
||||
rows: ScheduleRowView[];
|
||||
hostErrors: ScheduleHostError[];
|
||||
hasSchedules: boolean;
|
||||
isInitialLoad: boolean;
|
||||
showLoadError: boolean;
|
||||
statusFilter: ScheduleBucket;
|
||||
onStatusFilterChange: (value: ScheduleBucket) => void;
|
||||
showHostFilter: boolean;
|
||||
hosts: ReturnType<typeof useHosts>;
|
||||
selectedHost: string;
|
||||
onSelectHost: (serverId: string) => void;
|
||||
onRetry: () => void;
|
||||
onCreate: () => void;
|
||||
onEdit: (serverId: string, schedule: ScheduleSummary) => void;
|
||||
onEdit: (schedule: AggregatedSchedule) => void;
|
||||
}): ReactElement {
|
||||
if (isLoading) {
|
||||
if (isInitialLoad) {
|
||||
return (
|
||||
<View style={styles.centered}>
|
||||
<LoadingSpinner size="large" color={styles.spinner.color} />
|
||||
@@ -99,76 +226,85 @@ function SchedulesBody({
|
||||
);
|
||||
}
|
||||
|
||||
if (isError) {
|
||||
if (showLoadError) {
|
||||
return (
|
||||
<View style={styles.centered}>
|
||||
<Text style={styles.message}>{error?.message ?? "Could not load schedules"}</Text>
|
||||
<Text style={styles.message}>Unable to load schedules</Text>
|
||||
<Button variant="ghost" onPress={onRetry} testID="schedules-retry">
|
||||
Retry
|
||||
Try again
|
||||
</Button>
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
if (sections.length === 0) {
|
||||
if (!hasSchedules) {
|
||||
return (
|
||||
<View style={styles.centered}>
|
||||
<View style={styles.centered} testID="schedules-empty">
|
||||
{hostErrors.length > 0 ? <ScheduleHostErrorsBanner errors={hostErrors} /> : null}
|
||||
<Text style={styles.message}>No schedules yet</Text>
|
||||
<Button leftIcon={Plus} onPress={onCreate} testID="schedules-empty-new">
|
||||
<Button variant="ghost" leftIcon={Plus} onPress={onCreate} testID="schedules-empty-new">
|
||||
Create a schedule
|
||||
</Button>
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
const emptyFilterText = statusFilter === "ended" ? "No ended schedules" : "No active schedules";
|
||||
|
||||
return (
|
||||
<View style={styles.body}>
|
||||
<View style={styles.filterRow}>
|
||||
<View style={styles.filterRowControls}>
|
||||
{showHostFilter ? (
|
||||
<HostFilter
|
||||
hosts={hosts}
|
||||
selectedHost={selectedHost}
|
||||
onSelectHost={onSelectHost}
|
||||
triggerTestID="schedules-host-filter-trigger"
|
||||
/>
|
||||
) : null}
|
||||
<SegmentedControl
|
||||
size="sm"
|
||||
value={statusFilter}
|
||||
onValueChange={onStatusFilterChange}
|
||||
options={STATUS_FILTER_OPTIONS}
|
||||
testID="schedules-status-filter"
|
||||
/>
|
||||
</View>
|
||||
<Button leftIcon={Plus} onPress={onCreate} size="sm" testID="schedules-new">
|
||||
New schedule
|
||||
</Button>
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<ScrollView
|
||||
style={styles.scroll}
|
||||
contentContainerStyle={styles.scrollContent}
|
||||
showsVerticalScrollIndicator={false}
|
||||
keyboardShouldPersistTaps="handled"
|
||||
testID="schedules-sections"
|
||||
>
|
||||
{sections.map((section) => (
|
||||
<ScheduleHostSectionView key={section.serverId} section={section} onEdit={onEdit} />
|
||||
))}
|
||||
</ScrollView>
|
||||
<ScrollView
|
||||
style={styles.scroll}
|
||||
contentContainerStyle={styles.scrollContent}
|
||||
showsVerticalScrollIndicator={false}
|
||||
keyboardShouldPersistTaps="handled"
|
||||
testID="schedules-list"
|
||||
>
|
||||
{hostErrors.length > 0 ? <ScheduleHostErrorsBanner errors={hostErrors} /> : null}
|
||||
{rows.length > 0 ? (
|
||||
<SchedulesTable rows={rows} onEditSchedule={onEdit} />
|
||||
) : (
|
||||
<View style={styles.filterEmpty}>
|
||||
<Text style={styles.filterEmptyText}>{emptyFilterText}</Text>
|
||||
</View>
|
||||
)}
|
||||
</ScrollView>
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
function ScheduleHostSectionView({
|
||||
section,
|
||||
onEdit,
|
||||
}: {
|
||||
section: ScheduleHostSection;
|
||||
onEdit: (serverId: string, schedule: ScheduleSummary) => void;
|
||||
}): ReactElement {
|
||||
const handleEdit = useCallback(
|
||||
(schedule: ScheduleSummary) => {
|
||||
onEdit(section.serverId, schedule);
|
||||
},
|
||||
[onEdit, section.serverId],
|
||||
);
|
||||
const emptyMessage = section.isOnline ? "No schedules" : "Host offline";
|
||||
|
||||
function ScheduleHostErrorsBanner({ errors }: { errors: ScheduleHostError[] }): ReactElement {
|
||||
return (
|
||||
<View style={styles.section} testID={`schedules-section-${section.serverId}`}>
|
||||
<Text style={styles.sectionTitle} testID={`schedules-section-title-${section.serverId}`}>
|
||||
{section.serverName}
|
||||
</Text>
|
||||
{section.error ? <Text style={styles.sectionError}>{section.error}</Text> : null}
|
||||
{section.schedules.length > 0 ? (
|
||||
<SchedulesTable
|
||||
serverId={section.serverId}
|
||||
schedules={section.schedules}
|
||||
onEditSchedule={handleEdit}
|
||||
/>
|
||||
) : null}
|
||||
{section.schedules.length === 0 && !section.error ? (
|
||||
<View style={styles.sectionEmpty}>
|
||||
<Text style={styles.sectionEmptyText}>{emptyMessage}</Text>
|
||||
</View>
|
||||
) : null}
|
||||
<View style={styles.errorsBannerWrap}>
|
||||
<View style={styles.errorsBanner} testID="schedules-host-errors">
|
||||
{errors.map((error) => (
|
||||
<Text key={error.serverId} style={styles.errorsBannerText}>
|
||||
{`${error.serverName}: Could not load schedules`}
|
||||
</Text>
|
||||
))}
|
||||
</View>
|
||||
</View>
|
||||
);
|
||||
}
|
||||
@@ -178,6 +314,10 @@ const styles = StyleSheet.create((theme) => ({
|
||||
flex: 1,
|
||||
backgroundColor: theme.colors.surface0,
|
||||
},
|
||||
body: {
|
||||
flex: 1,
|
||||
minHeight: 0,
|
||||
},
|
||||
centered: {
|
||||
flex: 1,
|
||||
justifyContent: "center",
|
||||
@@ -185,43 +325,50 @@ const styles = StyleSheet.create((theme) => ({
|
||||
gap: theme.spacing[6],
|
||||
padding: theme.spacing[6],
|
||||
},
|
||||
filterRow: {
|
||||
flexDirection: "row",
|
||||
alignItems: "center",
|
||||
justifyContent: "space-between",
|
||||
gap: theme.spacing[3],
|
||||
paddingHorizontal: { xs: theme.spacing[3], md: theme.spacing[6] },
|
||||
paddingTop: theme.spacing[4],
|
||||
},
|
||||
filterRowControls: {
|
||||
flexDirection: "row",
|
||||
alignItems: "center",
|
||||
gap: theme.spacing[3],
|
||||
flexShrink: 1,
|
||||
flexWrap: "wrap",
|
||||
},
|
||||
scroll: {
|
||||
flex: 1,
|
||||
minHeight: 0,
|
||||
},
|
||||
scrollContent: {
|
||||
gap: theme.spacing[4],
|
||||
gap: theme.spacing[3],
|
||||
paddingTop: theme.spacing[4],
|
||||
paddingBottom: theme.spacing[6],
|
||||
},
|
||||
section: {
|
||||
gap: theme.spacing[2],
|
||||
errorsBannerWrap: {
|
||||
paddingHorizontal: { xs: theme.spacing[3], md: theme.spacing[6] },
|
||||
},
|
||||
sectionTitle: {
|
||||
width: "100%",
|
||||
maxWidth: 720,
|
||||
alignSelf: "center",
|
||||
paddingHorizontal: theme.spacing[4],
|
||||
color: theme.colors.foregroundMuted,
|
||||
fontSize: theme.fontSize.sm,
|
||||
fontWeight: theme.fontWeight.medium,
|
||||
errorsBanner: {
|
||||
borderWidth: 1,
|
||||
borderColor: theme.colors.border,
|
||||
borderRadius: theme.borderRadius.lg,
|
||||
padding: theme.spacing[3],
|
||||
gap: theme.spacing[1],
|
||||
},
|
||||
sectionError: {
|
||||
width: "100%",
|
||||
maxWidth: 720,
|
||||
alignSelf: "center",
|
||||
paddingHorizontal: theme.spacing[4],
|
||||
color: theme.colors.destructive,
|
||||
fontSize: theme.fontSize.sm,
|
||||
errorsBannerText: {
|
||||
color: theme.colors.palette.red[300],
|
||||
fontSize: theme.fontSize.xs,
|
||||
},
|
||||
sectionEmpty: {
|
||||
width: "100%",
|
||||
maxWidth: 720,
|
||||
alignSelf: "center",
|
||||
paddingHorizontal: theme.spacing[4],
|
||||
paddingVertical: theme.spacing[3],
|
||||
filterEmpty: {
|
||||
paddingHorizontal: { xs: theme.spacing[3], md: theme.spacing[6] },
|
||||
paddingVertical: theme.spacing[6],
|
||||
alignItems: "center",
|
||||
},
|
||||
sectionEmptyText: {
|
||||
filterEmptyText: {
|
||||
color: theme.colors.foregroundMuted,
|
||||
fontSize: theme.fontSize.sm,
|
||||
},
|
||||
|
||||
@@ -1,23 +1,18 @@
|
||||
import { useMemo, useState, useCallback, useEffect, useRef } from "react";
|
||||
import { Pressable, type PressableStateCallbackType, View, Text } from "react-native";
|
||||
import { useMemo, useState, useCallback, useEffect } from "react";
|
||||
import { View, Text } from "react-native";
|
||||
import { useIsFocused } from "@react-navigation/native";
|
||||
import { router } from "expo-router";
|
||||
import { StyleSheet, useUnistyles } from "react-native-unistyles";
|
||||
import { ChevronDown, ChevronLeft, Server } from "lucide-react-native";
|
||||
import { ChevronLeft } from "lucide-react-native";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { MenuHeader } from "@/components/headers/menu-header";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { LoadingSpinner } from "@/components/ui/loading-spinner";
|
||||
import { AgentList } from "@/components/agent-list";
|
||||
import { HostStatusDotSlot } from "@/components/hosts/host-picker";
|
||||
import {
|
||||
ALL_HOSTS_OPTION_ID,
|
||||
getHostPickerLabel,
|
||||
HostPicker,
|
||||
} from "@/components/hosts/host-picker";
|
||||
import { HostFilter } from "@/components/hosts/host-filter";
|
||||
import { ALL_HOSTS_OPTION_ID } from "@/components/hosts/host-picker";
|
||||
import { useAgentHistory } from "@/hooks/use-agent-history";
|
||||
import { useHosts } from "@/runtime/host-runtime";
|
||||
import { type HostProfile } from "@/types/host-connection";
|
||||
import { buildOpenProjectRoute } from "@/utils/host-routes";
|
||||
|
||||
export function SessionsScreen() {
|
||||
@@ -30,71 +25,6 @@ export function SessionsScreen() {
|
||||
return <SessionsScreenContent />;
|
||||
}
|
||||
|
||||
function SessionsHostFilter({
|
||||
hosts,
|
||||
selectedHost,
|
||||
onSelectHost,
|
||||
}: {
|
||||
hosts: HostProfile[];
|
||||
selectedHost: string;
|
||||
onSelectHost: (serverId: string) => void;
|
||||
}) {
|
||||
const { theme } = useUnistyles();
|
||||
const [isFilterOpen, setIsFilterOpen] = useState(false);
|
||||
const filterAnchorRef = useRef<View>(null);
|
||||
|
||||
const selectedHostLabel = useMemo(
|
||||
() => getHostPickerLabel(hosts, selectedHost, { includeAllHost: true }),
|
||||
[hosts, selectedHost],
|
||||
);
|
||||
|
||||
const handleFilterOpen = useCallback(() => setIsFilterOpen(true), []);
|
||||
|
||||
const filterTriggerStyle = useCallback(
|
||||
({ pressed, hovered = false }: PressableStateCallbackType & { hovered?: boolean }) => [
|
||||
styles.filterTrigger,
|
||||
Boolean(hovered) && styles.filterTriggerHovered,
|
||||
pressed && styles.filterTriggerPressed,
|
||||
],
|
||||
[],
|
||||
);
|
||||
|
||||
return (
|
||||
<HostPicker
|
||||
hosts={hosts}
|
||||
value={selectedHost}
|
||||
onSelect={onSelectHost}
|
||||
open={isFilterOpen}
|
||||
onOpenChange={setIsFilterOpen}
|
||||
anchorRef={filterAnchorRef}
|
||||
includeAllHost
|
||||
searchable={false}
|
||||
title="Filter by host"
|
||||
desktopPlacement="bottom-start"
|
||||
>
|
||||
<View ref={filterAnchorRef} collapsable={false} style={styles.filterTriggerWrap}>
|
||||
<Pressable
|
||||
onPress={handleFilterOpen}
|
||||
style={filterTriggerStyle}
|
||||
testID="sessions-host-filter-trigger"
|
||||
accessibilityRole="button"
|
||||
accessibilityLabel={`Filter: ${selectedHostLabel}`}
|
||||
>
|
||||
{selectedHost === ALL_HOSTS_OPTION_ID ? (
|
||||
<Server size={14} color={theme.colors.foregroundMuted} />
|
||||
) : (
|
||||
<HostStatusDotSlot serverId={selectedHost} />
|
||||
)}
|
||||
<Text style={styles.filterTriggerText} numberOfLines={1}>
|
||||
{selectedHostLabel}
|
||||
</Text>
|
||||
<ChevronDown size={14} color={theme.colors.foregroundMuted} />
|
||||
</Pressable>
|
||||
</View>
|
||||
</HostPicker>
|
||||
);
|
||||
}
|
||||
|
||||
function SessionsScreenContent() {
|
||||
const { theme } = useUnistyles();
|
||||
const { t } = useTranslation();
|
||||
@@ -152,10 +82,11 @@ function SessionsScreenContent() {
|
||||
<MenuHeader title={t("sessions.title")} />
|
||||
{showHostFilter ? (
|
||||
<View style={styles.filterContainer}>
|
||||
<SessionsHostFilter
|
||||
<HostFilter
|
||||
hosts={hosts}
|
||||
selectedHost={selectedHost}
|
||||
onSelectHost={setSelectedHost}
|
||||
triggerTestID="sessions-host-filter-trigger"
|
||||
/>
|
||||
</View>
|
||||
) : null}
|
||||
@@ -207,32 +138,6 @@ const styles = StyleSheet.create((theme) => ({
|
||||
},
|
||||
paddingTop: theme.spacing[4],
|
||||
},
|
||||
filterTriggerWrap: {
|
||||
alignSelf: "flex-start",
|
||||
},
|
||||
filterTrigger: {
|
||||
flexDirection: "row",
|
||||
alignItems: "center",
|
||||
gap: theme.spacing[1.5],
|
||||
alignSelf: "flex-start",
|
||||
paddingVertical: theme.spacing[1.5],
|
||||
paddingHorizontal: theme.spacing[3],
|
||||
borderRadius: theme.borderRadius.md,
|
||||
backgroundColor: theme.colors.surface1,
|
||||
borderWidth: theme.borderWidth[1],
|
||||
borderColor: theme.colors.border,
|
||||
},
|
||||
filterTriggerHovered: {
|
||||
backgroundColor: theme.colors.surface2,
|
||||
},
|
||||
filterTriggerPressed: {
|
||||
backgroundColor: theme.colors.surface3,
|
||||
},
|
||||
filterTriggerText: {
|
||||
color: theme.colors.foreground,
|
||||
fontSize: theme.fontSize.sm,
|
||||
fontWeight: theme.fontWeight.medium,
|
||||
},
|
||||
emptyContainer: {
|
||||
flex: 1,
|
||||
justifyContent: "center",
|
||||
|
||||
@@ -66,7 +66,7 @@ describe("buildSidebarProjectRowModel", () => {
|
||||
});
|
||||
});
|
||||
|
||||
it("renders a single-workspace git project as an expandable section with the new worktree action", () => {
|
||||
it("renders a single-workspace git project as an expandable section with the new workspace action", () => {
|
||||
const result = buildSidebarProjectRowModel({
|
||||
project: project({
|
||||
projectKind: "git",
|
||||
@@ -79,13 +79,49 @@ describe("buildSidebarProjectRowModel", () => {
|
||||
kind: "project_section",
|
||||
chevron: "expand",
|
||||
trailingAction: {
|
||||
kind: "new_worktree",
|
||||
kind: "new_workspace",
|
||||
target: { serverId: "srv", iconWorkingDir: "/repo" },
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
it("targets the project host, not route state, for new worktree actions", () => {
|
||||
it("shows the new workspace action for a non-git project when the host supports workspace multiplicity", () => {
|
||||
const result = buildSidebarProjectRowModel({
|
||||
project: project({ projectKind: "directory", workspaces: [] }),
|
||||
collapsed: false,
|
||||
supportsMultiplicityByServerId: new Map([["srv", true]]),
|
||||
});
|
||||
|
||||
expect(result.trailingAction).toEqual({
|
||||
kind: "new_workspace",
|
||||
target: { serverId: "srv", iconWorkingDir: "/repo" },
|
||||
});
|
||||
});
|
||||
|
||||
it("hides the new workspace action for a non-git project when the host lacks workspace multiplicity", () => {
|
||||
const result = buildSidebarProjectRowModel({
|
||||
project: project({ projectKind: "directory", workspaces: [] }),
|
||||
collapsed: false,
|
||||
supportsMultiplicityByServerId: new Map([["srv", false]]),
|
||||
});
|
||||
|
||||
expect(result.trailingAction).toEqual({ kind: "none" });
|
||||
});
|
||||
|
||||
it("still shows the new workspace action for a git project regardless of multiplicity", () => {
|
||||
const result = buildSidebarProjectRowModel({
|
||||
project: project({ projectKind: "git" }),
|
||||
collapsed: false,
|
||||
supportsMultiplicityByServerId: new Map([["srv", false]]),
|
||||
});
|
||||
|
||||
expect(result.trailingAction).toEqual({
|
||||
kind: "new_workspace",
|
||||
target: { serverId: "srv", iconWorkingDir: "/repo" },
|
||||
});
|
||||
});
|
||||
|
||||
it("targets the project host, not route state, for new workspace actions", () => {
|
||||
const result = buildSidebarProjectRowModel({
|
||||
project: project({
|
||||
hosts: [
|
||||
@@ -98,13 +134,34 @@ describe("buildSidebarProjectRowModel", () => {
|
||||
|
||||
expect(result).toMatchObject({
|
||||
trailingAction: {
|
||||
kind: "new_worktree",
|
||||
kind: "new_workspace",
|
||||
target: { serverId: "host-b", iconWorkingDir: "/repo/b" },
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
it("renders a multi-workspace git project as an expandable section with a new worktree action", () => {
|
||||
it("targets the first multiplicity-capable host for a non-git project", () => {
|
||||
const result = buildSidebarProjectRowModel({
|
||||
project: project({
|
||||
projectKind: "directory",
|
||||
hosts: [
|
||||
{ serverId: "host-a", iconWorkingDir: "/repo/a", canCreateWorktree: false },
|
||||
{ serverId: "host-b", iconWorkingDir: "/repo/b", canCreateWorktree: false },
|
||||
],
|
||||
}),
|
||||
collapsed: false,
|
||||
supportsMultiplicityByServerId: new Map([["host-b", true]]),
|
||||
});
|
||||
|
||||
expect(result).toMatchObject({
|
||||
trailingAction: {
|
||||
kind: "new_workspace",
|
||||
target: { serverId: "host-b", iconWorkingDir: "/repo/b" },
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
it("renders a multi-workspace git project as an expandable section with a new workspace action", () => {
|
||||
const result = buildSidebarProjectRowModel({
|
||||
project: project({
|
||||
projectKind: "git",
|
||||
@@ -120,7 +177,7 @@ describe("buildSidebarProjectRowModel", () => {
|
||||
kind: "project_section",
|
||||
chevron: "expand",
|
||||
trailingAction: {
|
||||
kind: "new_worktree",
|
||||
kind: "new_workspace",
|
||||
target: { serverId: "srv", iconWorkingDir: "/repo" },
|
||||
},
|
||||
});
|
||||
@@ -149,7 +206,7 @@ describe("buildSidebarProjectRowModel", () => {
|
||||
kind: "project_section",
|
||||
chevron: "collapse",
|
||||
trailingAction: {
|
||||
kind: "new_worktree",
|
||||
kind: "new_workspace",
|
||||
target: { serverId: "srv", iconWorkingDir: "/repo" },
|
||||
},
|
||||
});
|
||||
|
||||
@@ -6,7 +6,7 @@ export interface SidebarProjectHostTarget {
|
||||
}
|
||||
|
||||
export type SidebarProjectTrailingAction =
|
||||
| { kind: "new_worktree"; target: SidebarProjectHostTarget }
|
||||
| { kind: "new_workspace"; target: SidebarProjectHostTarget }
|
||||
| { kind: "none" };
|
||||
|
||||
export interface SidebarProjectSectionRowModel {
|
||||
@@ -17,6 +17,8 @@ export interface SidebarProjectSectionRowModel {
|
||||
|
||||
export type SidebarProjectRowModel = SidebarProjectSectionRowModel;
|
||||
|
||||
const EMPTY_MULTIPLICITY_MAP: ReadonlyMap<string, boolean> = new Map();
|
||||
|
||||
function hostTarget(input: {
|
||||
serverId: string;
|
||||
iconWorkingDir: string;
|
||||
@@ -40,9 +42,18 @@ export function resolveSidebarProjectIconTarget(
|
||||
return null;
|
||||
}
|
||||
|
||||
function resolveNewWorktreeTarget(project: SidebarProjectEntry): SidebarProjectHostTarget | null {
|
||||
// A project can host a brand-new workspace on a host when that host can create a
|
||||
// git worktree (git projects) OR the host supports running multiple independent
|
||||
// workspaces per directory (`workspaceMultiplicity`), which is what lets non-git
|
||||
// directories add a second workspace. Mirrors the gate used by the global "New
|
||||
// workspace" affordances (use-global-new-workspace-action.ts and left-sidebar's
|
||||
// SidebarNewWorkspaceHeaderRow): `canCreateWorktree || supportsMultiplicity`.
|
||||
function resolveNewWorkspaceTarget(
|
||||
project: SidebarProjectEntry,
|
||||
supportsMultiplicityByServerId: ReadonlyMap<string, boolean>,
|
||||
): SidebarProjectHostTarget | null {
|
||||
for (const host of project.hosts) {
|
||||
if (!host.canCreateWorktree) {
|
||||
if (!host.canCreateWorktree && !supportsMultiplicityByServerId.get(host.serverId)) {
|
||||
continue;
|
||||
}
|
||||
const target = hostTarget(host);
|
||||
@@ -53,18 +64,25 @@ function resolveNewWorktreeTarget(project: SidebarProjectEntry): SidebarProjectH
|
||||
return null;
|
||||
}
|
||||
|
||||
function projectTrailingAction(project: SidebarProjectEntry): SidebarProjectTrailingAction {
|
||||
const target = resolveNewWorktreeTarget(project);
|
||||
return target ? { kind: "new_worktree", target } : { kind: "none" };
|
||||
function projectTrailingAction(
|
||||
project: SidebarProjectEntry,
|
||||
supportsMultiplicityByServerId: ReadonlyMap<string, boolean>,
|
||||
): SidebarProjectTrailingAction {
|
||||
const target = resolveNewWorkspaceTarget(project, supportsMultiplicityByServerId);
|
||||
return target ? { kind: "new_workspace", target } : { kind: "none" };
|
||||
}
|
||||
|
||||
export function buildSidebarProjectRowModel(input: {
|
||||
project: SidebarProjectEntry;
|
||||
collapsed: boolean;
|
||||
supportsMultiplicityByServerId?: ReadonlyMap<string, boolean>;
|
||||
}): SidebarProjectRowModel {
|
||||
return {
|
||||
kind: "project_section",
|
||||
chevron: input.collapsed ? "expand" : "collapse",
|
||||
trailingAction: projectTrailingAction(input.project),
|
||||
trailingAction: projectTrailingAction(
|
||||
input.project,
|
||||
input.supportsMultiplicityByServerId ?? EMPTY_MULTIPLICITY_MAP,
|
||||
),
|
||||
};
|
||||
}
|
||||
|
||||
@@ -33,9 +33,10 @@ export async function runPairCommand(options: PairOptions): Promise<void> {
|
||||
if (host) {
|
||||
const client = await tryConnectToDaemon({ host, timeout: 1500 });
|
||||
if (client) {
|
||||
try {
|
||||
const serverInfo = await client.waitForServerInfo(PAIRING_DAEMON_RPC_TIMEOUT_MS);
|
||||
if (serverInfo.features?.daemonStatusRpc === true) {
|
||||
const supportsDaemonStatusRpc =
|
||||
client.getLastServerInfoMessage()?.features?.daemonStatusRpc === true;
|
||||
if (supportsDaemonStatusRpc) {
|
||||
try {
|
||||
const offer = await client.getDaemonPairingOffer({
|
||||
timeout: PAIRING_DAEMON_RPC_TIMEOUT_MS,
|
||||
});
|
||||
@@ -45,11 +46,11 @@ export async function runPairCommand(options: PairOptions): Promise<void> {
|
||||
options,
|
||||
);
|
||||
return;
|
||||
} catch {
|
||||
// COMPAT(daemon-rpc-rollout): fall back to CLI-side pairing generation while
|
||||
// old daemons lack daemonStatusRpc. Remove once the daemon floor is past
|
||||
// v0.1.76; pairing should come from daemon.get_pairing_offer.
|
||||
}
|
||||
} catch {
|
||||
// COMPAT(daemon-rpc-rollout): fall back to CLI-side pairing generation while
|
||||
// old daemons lack daemonStatusRpc. Remove once the daemon floor is past
|
||||
// v0.1.76; pairing should come from daemon.get_pairing_offer.
|
||||
}
|
||||
await client.close().catch(() => {});
|
||||
}
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
import type { Command } from "commander";
|
||||
import { createRequire } from "node:module";
|
||||
import { getOrCreateServerId, findExecutable, execCommand } from "@getpaseo/server";
|
||||
import { AGENT_PROVIDER_DEFINITIONS } from "@getpaseo/protocol/provider-manifest";
|
||||
import { connectToDaemon } from "../../utils/client.js";
|
||||
import type { CommandOptions, ListResult, OutputSchema } from "../../output/index.js";
|
||||
import { resolveLocalDaemonState, resolveTcpHostFromListen } from "./local-daemon.js";
|
||||
@@ -157,9 +156,11 @@ function toStatusRows(status: DaemonStatus): StatusRow[] {
|
||||
return rows;
|
||||
}
|
||||
|
||||
const PROVIDER_BINARIES: { label: string; binary: string }[] = AGENT_PROVIDER_DEFINITIONS.filter(
|
||||
(provider) => provider.voice?.enabled === true && provider.defaultModeId !== null,
|
||||
).map((provider) => ({ label: provider.label, binary: provider.id }));
|
||||
const PROVIDER_BINARIES: { label: string; binary: string }[] = [
|
||||
{ label: "Claude", binary: "claude" },
|
||||
{ label: "Codex", binary: "codex" },
|
||||
{ label: "OpenCode", binary: "opencode" },
|
||||
];
|
||||
|
||||
async function checkProviderBinary(
|
||||
binary: string,
|
||||
|
||||
@@ -50,7 +50,10 @@ export function addLoopRunOptions(command: Command): Command {
|
||||
.argument("<prompt>", "Prompt for each fresh worker iteration")
|
||||
.option("--provider <provider>", "Default provider for worker and verifier agents")
|
||||
.option("--model <model>", "Default model for worker and verifier agents")
|
||||
.option("--mode <mode>", "Provider-specific mode for the worker agent")
|
||||
.option(
|
||||
"--mode <mode>",
|
||||
"Provider-specific mode for the worker agent (e.g. claude bypassPermissions, opencode build)",
|
||||
)
|
||||
.option("--verify-provider <provider>", "Provider for the verifier agent")
|
||||
.option("--verify-model <model>", "Model for the verifier agent")
|
||||
.option("--verify-mode <mode>", "Provider-specific mode for the verifier agent")
|
||||
|
||||
@@ -1,710 +0,0 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
|
||||
import { render } from "../../output/index.js";
|
||||
import { runAddCommand } from "./add.js";
|
||||
|
||||
interface CatalogEntry {
|
||||
id: string;
|
||||
label: string;
|
||||
api: string;
|
||||
baseUrl: string;
|
||||
auth: Record<string, unknown>;
|
||||
models: Array<{ id: string; label?: string; reasoning?: boolean }>;
|
||||
}
|
||||
|
||||
interface RecordingClientInput {
|
||||
catalog: CatalogEntry[];
|
||||
features?: Record<string, unknown>;
|
||||
setProvider?: (input: {
|
||||
name: string;
|
||||
providerType: string;
|
||||
options: { apiKey?: string; models?: Array<{ id: string }> };
|
||||
}) => Promise<unknown>;
|
||||
startOAuth?: (
|
||||
name: string,
|
||||
options?: { mode?: string; requestId?: string } | string,
|
||||
) => Promise<unknown>;
|
||||
completeOAuth?: (name: string) => Promise<unknown>;
|
||||
storeCredential?: (input: { name: string; credential: unknown }) => Promise<unknown>;
|
||||
}
|
||||
|
||||
function createClient(input: RecordingClientInput) {
|
||||
return {
|
||||
waitForServerInfo: async () => ({
|
||||
status: "server_info",
|
||||
serverId: "test-daemon",
|
||||
features: input.features ?? { paseoAgentCatalog: true },
|
||||
}),
|
||||
getPaseoAgentCatalog: async () => ({
|
||||
requestId: "catalog-1",
|
||||
catalog: input.catalog,
|
||||
error: null,
|
||||
}),
|
||||
setPaseoAgentProvider: async (providerInput: {
|
||||
name: string;
|
||||
providerType: string;
|
||||
options: { apiKey?: string; models?: Array<{ id: string }> };
|
||||
}) => {
|
||||
if (input.setProvider) {
|
||||
return input.setProvider(providerInput);
|
||||
}
|
||||
return {
|
||||
requestId: "set-1",
|
||||
success: true,
|
||||
provider: {
|
||||
name: providerInput.name,
|
||||
providerType: providerInput.providerType,
|
||||
models: providerInput.options.models ?? [],
|
||||
auth: { kind: "api_key", configured: true, source: "literal" },
|
||||
available: true,
|
||||
error: null,
|
||||
},
|
||||
error: null,
|
||||
};
|
||||
},
|
||||
startPaseoAgentOAuth: async (
|
||||
name: string,
|
||||
options?: { mode?: string; requestId?: string } | string,
|
||||
) =>
|
||||
input.startOAuth?.(name, options) ?? {
|
||||
requestId: "oauth-start-1",
|
||||
success: true,
|
||||
name,
|
||||
authorization: {
|
||||
kind: "device_code",
|
||||
userCode: "ABCD-EFGH",
|
||||
verificationUri: "https://auth.example.test/device",
|
||||
intervalSeconds: 5,
|
||||
expiresInSeconds: 900,
|
||||
},
|
||||
error: null,
|
||||
},
|
||||
completePaseoAgentOAuth: async (name: string) =>
|
||||
input.completeOAuth?.(name) ?? {
|
||||
requestId: "oauth-complete-1",
|
||||
success: true,
|
||||
name,
|
||||
auth: { kind: "oauth", configured: true, source: "stored" },
|
||||
error: null,
|
||||
},
|
||||
storePaseoAgentOAuthCredential: async (credentialInput: {
|
||||
name: string;
|
||||
credential: unknown;
|
||||
}) =>
|
||||
input.storeCredential?.(credentialInput) ?? {
|
||||
requestId: "oauth-store-1",
|
||||
success: true,
|
||||
name: credentialInput.name,
|
||||
auth: { kind: "oauth", configured: true, source: "stored" },
|
||||
error: null,
|
||||
},
|
||||
close: async () => {},
|
||||
};
|
||||
}
|
||||
|
||||
function apiKeyEntry(overrides: Partial<CatalogEntry> = {}): CatalogEntry {
|
||||
return {
|
||||
id: "alpha-key",
|
||||
label: "Alpha Key",
|
||||
api: "test-api",
|
||||
baseUrl: "https://alpha.example.test",
|
||||
auth: {
|
||||
kind: "api_key",
|
||||
envVar: "ALPHA_API_KEY",
|
||||
hint: "Create an Alpha key before continuing.",
|
||||
keyUrl: "https://alpha.example.test/keys",
|
||||
placeholder: "Alpha API key",
|
||||
},
|
||||
models: [{ id: "alpha-model", label: "Alpha Model", reasoning: true }],
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
function oauthEntry(overrides: Partial<CatalogEntry> = {}): CatalogEntry {
|
||||
return {
|
||||
id: "beta-oauth",
|
||||
label: "Beta OAuth",
|
||||
api: "test-oauth-api",
|
||||
baseUrl: "https://beta.example.test",
|
||||
auth: { kind: "oauth", flow: "beta-flow" },
|
||||
models: [{ id: "beta-model" }],
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
describe("provider add", () => {
|
||||
it("configures an API-key provider from a hidden prompt without echoing the key", async () => {
|
||||
const setCalls: unknown[] = [];
|
||||
const prompts: string[] = [];
|
||||
const output: string[] = [];
|
||||
const result = await runAddCommand(
|
||||
"alpha-key",
|
||||
{ host: "localhost:7777", name: "alpha-main" },
|
||||
{} as never,
|
||||
{
|
||||
write: (message) => output.push(message),
|
||||
promptSecret: async (message) => {
|
||||
prompts.push(message);
|
||||
return "redaction-sentinel";
|
||||
},
|
||||
promptText: async () => {
|
||||
throw new Error("text prompt should not be used");
|
||||
},
|
||||
readStdin: async () => {
|
||||
throw new Error("stdin should not be read");
|
||||
},
|
||||
connectDaemon: async (options) => {
|
||||
expect(options.host).toBe("localhost:7777");
|
||||
return createClient({
|
||||
catalog: [apiKeyEntry()],
|
||||
setProvider: async (input) => {
|
||||
setCalls.push(input);
|
||||
return {
|
||||
requestId: "set-1",
|
||||
success: true,
|
||||
provider: {
|
||||
name: input.name,
|
||||
providerType: input.providerType,
|
||||
models: input.options.models ?? [],
|
||||
auth: { kind: "api_key", configured: true, source: "literal" },
|
||||
available: true,
|
||||
error: null,
|
||||
},
|
||||
error: null,
|
||||
};
|
||||
},
|
||||
});
|
||||
},
|
||||
},
|
||||
);
|
||||
|
||||
expect(setCalls).toEqual([
|
||||
{
|
||||
name: "alpha-main",
|
||||
providerType: "alpha-key",
|
||||
options: {
|
||||
apiKey: "redaction-sentinel",
|
||||
models: [{ id: "alpha-model", label: "Alpha Model", reasoning: true }],
|
||||
},
|
||||
},
|
||||
]);
|
||||
expect(prompts).toEqual(["Enter Alpha API key (leave empty to use $ALPHA_API_KEY):"]);
|
||||
expect(output.join("\n")).toContain("Create an Alpha key");
|
||||
expect(output.join("\n")).toContain("https://alpha.example.test/keys");
|
||||
expect(render(result, { format: "json" })).not.toContain("redaction-sentinel");
|
||||
expect(render(result, { format: "table", noColor: true })).toContain("alpha-main");
|
||||
});
|
||||
|
||||
it("stores an environment reference when the API-key prompt is empty", async () => {
|
||||
const setCalls: unknown[] = [];
|
||||
const result = await runAddCommand("alpha-key", {}, {} as never, {
|
||||
promptSecret: async () => "",
|
||||
promptText: async () => {
|
||||
throw new Error("text prompt should not be used");
|
||||
},
|
||||
readStdin: async () => {
|
||||
throw new Error("stdin should not be read");
|
||||
},
|
||||
write: () => {},
|
||||
connectDaemon: async () =>
|
||||
createClient({
|
||||
catalog: [apiKeyEntry()],
|
||||
setProvider: async (input) => {
|
||||
setCalls.push(input);
|
||||
return {
|
||||
requestId: "set-1",
|
||||
success: true,
|
||||
provider: {
|
||||
name: input.name,
|
||||
providerType: input.providerType,
|
||||
models: input.options.models ?? [],
|
||||
auth: { kind: "api_key", configured: false, source: "env" },
|
||||
available: false,
|
||||
error: null,
|
||||
},
|
||||
error: null,
|
||||
};
|
||||
},
|
||||
}),
|
||||
});
|
||||
|
||||
expect(setCalls).toEqual([
|
||||
{
|
||||
name: "alpha-key",
|
||||
providerType: "alpha-key",
|
||||
options: {
|
||||
apiKey: "$ALPHA_API_KEY",
|
||||
models: [{ id: "alpha-model", label: "Alpha Model", reasoning: true }],
|
||||
},
|
||||
},
|
||||
]);
|
||||
expect(result.data.auth).toBe("Needs attention");
|
||||
});
|
||||
|
||||
it("reads an API key from stdin for scripts", async () => {
|
||||
const setCalls: unknown[] = [];
|
||||
|
||||
await runAddCommand("alpha-key", { apiKeyStdin: true, model: ["one,two"] }, {} as never, {
|
||||
readStdin: async () => "stdin-secret\n",
|
||||
promptSecret: async () => {
|
||||
throw new Error("prompt should not be used with --api-key-stdin");
|
||||
},
|
||||
promptText: async () => {
|
||||
throw new Error("text prompt should not be used");
|
||||
},
|
||||
write: () => {},
|
||||
connectDaemon: async () =>
|
||||
createClient({
|
||||
catalog: [apiKeyEntry({ models: [] })],
|
||||
setProvider: async (input) => {
|
||||
setCalls.push(input);
|
||||
return {
|
||||
requestId: "set-1",
|
||||
success: true,
|
||||
provider: {
|
||||
name: input.name,
|
||||
providerType: input.providerType,
|
||||
models: input.options.models ?? [],
|
||||
auth: { kind: "api_key", configured: true, source: "literal" },
|
||||
available: true,
|
||||
error: null,
|
||||
},
|
||||
error: null,
|
||||
};
|
||||
},
|
||||
}),
|
||||
});
|
||||
|
||||
expect(setCalls).toEqual([
|
||||
{
|
||||
name: "alpha-key",
|
||||
providerType: "alpha-key",
|
||||
options: {
|
||||
apiKey: "stdin-secret",
|
||||
models: [{ id: "one" }, { id: "two" }],
|
||||
},
|
||||
},
|
||||
]);
|
||||
});
|
||||
|
||||
it("configures an API-key provider without model defaults", async () => {
|
||||
const setCalls: unknown[] = [];
|
||||
|
||||
const result = await runAddCommand("alpha-key", { apiKeyStdin: true }, {} as never, {
|
||||
readStdin: async () => "stdin-secret\n",
|
||||
promptSecret: async () => {
|
||||
throw new Error("prompt should not be used with --api-key-stdin");
|
||||
},
|
||||
promptText: async () => {
|
||||
throw new Error("text prompt should not be used");
|
||||
},
|
||||
write: () => {},
|
||||
connectDaemon: async () =>
|
||||
createClient({
|
||||
catalog: [apiKeyEntry({ models: [] })],
|
||||
setProvider: async (input) => {
|
||||
setCalls.push(input);
|
||||
return {
|
||||
requestId: "set-1",
|
||||
success: true,
|
||||
provider: {
|
||||
name: input.name,
|
||||
providerType: input.providerType,
|
||||
models: [],
|
||||
auth: { kind: "api_key", configured: true, source: "literal" },
|
||||
available: true,
|
||||
error: null,
|
||||
},
|
||||
error: null,
|
||||
};
|
||||
},
|
||||
}),
|
||||
});
|
||||
|
||||
expect(setCalls).toEqual([
|
||||
{
|
||||
name: "alpha-key",
|
||||
providerType: "alpha-key",
|
||||
options: {
|
||||
apiKey: "stdin-secret",
|
||||
},
|
||||
},
|
||||
]);
|
||||
expect(result.data).toMatchObject({
|
||||
name: "alpha-key",
|
||||
auth: "Connected",
|
||||
available: "yes",
|
||||
models: "-",
|
||||
});
|
||||
});
|
||||
|
||||
it("runs browser OAuth locally and pushes the credential to the selected daemon", async () => {
|
||||
const order: string[] = [];
|
||||
const stored: unknown[] = [];
|
||||
const openedUrls: string[] = [];
|
||||
|
||||
const result = await runAddCommand(
|
||||
"beta-oauth",
|
||||
{ host: "tcp://remote:7777?ssl=true&password=secret", name: "beta-main" },
|
||||
{} as never,
|
||||
{
|
||||
write: (message) => order.push(`write:${message}`),
|
||||
openBrowser: (url) => {
|
||||
openedUrls.push(url);
|
||||
return true;
|
||||
},
|
||||
loginBrowserCredential: async (options) => {
|
||||
expect(options.flow).toBe("beta-flow");
|
||||
order.push("browser-login");
|
||||
options.onAuthUrl("https://auth.example.test/browser", "Authorize in the browser.");
|
||||
return { type: "oauth", access: "access-token", refresh: "refresh-token", expires: 123 };
|
||||
},
|
||||
promptText: async () => {
|
||||
throw new Error("manual prompt should not be used");
|
||||
},
|
||||
promptSecret: async () => {
|
||||
throw new Error("secret prompt should not be used");
|
||||
},
|
||||
readStdin: async () => {
|
||||
throw new Error("stdin should not be read");
|
||||
},
|
||||
connectDaemon: async () =>
|
||||
createClient({
|
||||
catalog: [oauthEntry()],
|
||||
setProvider: async (input) => {
|
||||
order.push("set-provider");
|
||||
return {
|
||||
requestId: "set-1",
|
||||
success: true,
|
||||
provider: {
|
||||
name: input.name,
|
||||
providerType: input.providerType,
|
||||
models: input.options.models ?? [],
|
||||
auth: { kind: "oauth", configured: false },
|
||||
available: false,
|
||||
error: null,
|
||||
},
|
||||
error: null,
|
||||
};
|
||||
},
|
||||
storeCredential: async (input) => {
|
||||
stored.push(input);
|
||||
return {
|
||||
requestId: "store-1",
|
||||
success: true,
|
||||
name: input.name,
|
||||
auth: { kind: "oauth", configured: true, source: "stored" },
|
||||
error: null,
|
||||
};
|
||||
},
|
||||
}),
|
||||
},
|
||||
);
|
||||
|
||||
expect(order).toEqual([
|
||||
"set-provider",
|
||||
"browser-login",
|
||||
"write:Authorize in the browser.",
|
||||
"write: https://auth.example.test/browser",
|
||||
"write:Waiting for you to approve in the browser...",
|
||||
"write:Credential accepted by selected daemon (tcp://remote:7777?ssl=true).",
|
||||
]);
|
||||
expect(stored).toEqual([
|
||||
{
|
||||
name: "beta-main",
|
||||
credential: {
|
||||
type: "oauth",
|
||||
access: "access-token",
|
||||
refresh: "refresh-token",
|
||||
expires: 123,
|
||||
},
|
||||
},
|
||||
]);
|
||||
expect(openedUrls).toEqual(["https://auth.example.test/browser"]);
|
||||
expect(result.data.auth).toBe("Connected");
|
||||
expect(order.join("\n")).not.toContain("secret");
|
||||
});
|
||||
|
||||
it("uses daemon-run OAuth when --device-code is passed", async () => {
|
||||
const output: string[] = [];
|
||||
const oauthCalls: string[] = [];
|
||||
|
||||
const result = await runAddCommand("beta-oauth", { deviceCode: true }, {} as never, {
|
||||
write: (message) => output.push(message),
|
||||
openBrowser: () => {
|
||||
throw new Error("browser should not be opened for --device-code");
|
||||
},
|
||||
loginBrowserCredential: async () => {
|
||||
throw new Error("browser login should not run for --device-code");
|
||||
},
|
||||
promptText: async () => {
|
||||
throw new Error("prompt should not be used");
|
||||
},
|
||||
promptSecret: async () => {
|
||||
throw new Error("secret prompt should not be used");
|
||||
},
|
||||
readStdin: async () => {
|
||||
throw new Error("stdin should not be read");
|
||||
},
|
||||
connectDaemon: async () =>
|
||||
createClient({
|
||||
catalog: [oauthEntry()],
|
||||
startOAuth: async (name, options) => {
|
||||
oauthCalls.push(
|
||||
`start:${name}:${typeof options === "string" ? options : options?.mode}`,
|
||||
);
|
||||
return {
|
||||
requestId: "start-1",
|
||||
success: true,
|
||||
name,
|
||||
authorization: {
|
||||
kind: "device_code",
|
||||
userCode: "ABCD-EFGH",
|
||||
verificationUri: "https://auth.example.test/device",
|
||||
expiresInSeconds: 900,
|
||||
},
|
||||
error: null,
|
||||
};
|
||||
},
|
||||
completeOAuth: async (name) => {
|
||||
oauthCalls.push(`complete:${name}`);
|
||||
return {
|
||||
requestId: "complete-1",
|
||||
success: true,
|
||||
name,
|
||||
auth: { kind: "oauth", configured: true, source: "stored" },
|
||||
error: null,
|
||||
};
|
||||
},
|
||||
}),
|
||||
});
|
||||
|
||||
expect(oauthCalls).toEqual(["start:beta-oauth:device_code", "complete:beta-oauth"]);
|
||||
expect(output.join("\n")).toContain("ABCD-EFGH");
|
||||
expect(output.join("\n")).toContain("https://auth.example.test/device");
|
||||
expect(result.data.auth).toBe("Connected");
|
||||
});
|
||||
|
||||
it("falls back to daemon-run OAuth when the browser cannot open", async () => {
|
||||
const output: string[] = [];
|
||||
const oauthCalls: string[] = [];
|
||||
|
||||
await runAddCommand("beta-oauth", {}, {} as never, {
|
||||
write: (message) => output.push(message),
|
||||
openBrowser: () => false,
|
||||
loginBrowserCredential: async (options) => {
|
||||
options.onAuthUrl("https://auth.example.test/browser");
|
||||
throw new Error("onAuthUrl should have switched to device-code");
|
||||
},
|
||||
promptText: async () => {
|
||||
throw new Error("prompt should not be used");
|
||||
},
|
||||
promptSecret: async () => {
|
||||
throw new Error("secret prompt should not be used");
|
||||
},
|
||||
readStdin: async () => {
|
||||
throw new Error("stdin should not be read");
|
||||
},
|
||||
connectDaemon: async () =>
|
||||
createClient({
|
||||
catalog: [oauthEntry()],
|
||||
startOAuth: async (name, options) => {
|
||||
oauthCalls.push(
|
||||
`start:${name}:${typeof options === "string" ? options : options?.mode}`,
|
||||
);
|
||||
return {
|
||||
requestId: "start-1",
|
||||
success: true,
|
||||
name,
|
||||
authorization: {
|
||||
kind: "device_code",
|
||||
userCode: "WXYZ-1234",
|
||||
verificationUri: "https://auth.example.test/device",
|
||||
},
|
||||
error: null,
|
||||
};
|
||||
},
|
||||
completeOAuth: async (name) => {
|
||||
oauthCalls.push(`complete:${name}`);
|
||||
return {
|
||||
requestId: "complete-1",
|
||||
success: true,
|
||||
name,
|
||||
auth: { kind: "oauth", configured: true, source: "stored" },
|
||||
error: null,
|
||||
};
|
||||
},
|
||||
}),
|
||||
});
|
||||
|
||||
expect(oauthCalls).toEqual(["start:beta-oauth:device_code", "complete:beta-oauth"]);
|
||||
expect(output.join("\n")).toContain("Browser could not be opened");
|
||||
expect(output.join("\n")).toContain("WXYZ-1234");
|
||||
});
|
||||
|
||||
it("updates the same instance on repeated add calls", async () => {
|
||||
const setCalls: unknown[] = [];
|
||||
const client = createClient({
|
||||
catalog: [apiKeyEntry()],
|
||||
setProvider: async (input) => {
|
||||
setCalls.push(input);
|
||||
return {
|
||||
requestId: "set-1",
|
||||
success: true,
|
||||
provider: {
|
||||
name: input.name,
|
||||
providerType: input.providerType,
|
||||
models: input.options.models ?? [],
|
||||
auth: { kind: "api_key", configured: true, source: "literal" },
|
||||
available: true,
|
||||
error: null,
|
||||
},
|
||||
error: null,
|
||||
};
|
||||
},
|
||||
});
|
||||
const dependencies = {
|
||||
promptSecret: async () => "secret",
|
||||
promptText: async () => {
|
||||
throw new Error("text prompt should not be used");
|
||||
},
|
||||
readStdin: async () => {
|
||||
throw new Error("stdin should not be read");
|
||||
},
|
||||
write: () => {},
|
||||
connectDaemon: async () => client,
|
||||
};
|
||||
|
||||
await runAddCommand("alpha-key", { name: "same-name" }, {} as never, dependencies);
|
||||
await runAddCommand("alpha-key", { name: "same-name" }, {} as never, dependencies);
|
||||
|
||||
expect(setCalls).toHaveLength(2);
|
||||
expect(setCalls).toEqual([
|
||||
expect.objectContaining({ name: "same-name", providerType: "alpha-key" }),
|
||||
expect.objectContaining({ name: "same-name", providerType: "alpha-key" }),
|
||||
]);
|
||||
});
|
||||
|
||||
it("mentions known provider ids for an unknown catalog id", async () => {
|
||||
await expect(
|
||||
runAddCommand("missing-key", {}, {} as never, {
|
||||
promptSecret: async () => {
|
||||
throw new Error("prompt should not run");
|
||||
},
|
||||
promptText: async () => {
|
||||
throw new Error("text prompt should not run");
|
||||
},
|
||||
readStdin: async () => {
|
||||
throw new Error("stdin should not run");
|
||||
},
|
||||
write: () => {},
|
||||
connectDaemon: async () =>
|
||||
createClient({
|
||||
catalog: [apiKeyEntry({ id: "alpha-key" }), apiKeyEntry({ id: "gamma-key" })],
|
||||
setProvider: async () => {
|
||||
throw new Error("set should not run");
|
||||
},
|
||||
}),
|
||||
}),
|
||||
).rejects.toMatchObject({
|
||||
code: "UNKNOWN_PROVIDER",
|
||||
message:
|
||||
'Unknown model provider type "missing-key". Known provider ids: alpha-key, gamma-key.',
|
||||
});
|
||||
});
|
||||
|
||||
it("requires the catalog feature flag before reading the catalog", async () => {
|
||||
const calls: string[] = [];
|
||||
|
||||
await expect(
|
||||
runAddCommand("alpha-key", {}, {} as never, {
|
||||
promptSecret: async () => {
|
||||
throw new Error("prompt should not run");
|
||||
},
|
||||
promptText: async () => {
|
||||
throw new Error("text prompt should not run");
|
||||
},
|
||||
readStdin: async () => {
|
||||
throw new Error("stdin should not run");
|
||||
},
|
||||
write: () => {},
|
||||
connectDaemon: async () => ({
|
||||
waitForServerInfo: async () => ({
|
||||
status: "server_info",
|
||||
serverId: "test-daemon",
|
||||
features: {},
|
||||
}),
|
||||
getPaseoAgentCatalog: async () => {
|
||||
calls.push("catalog");
|
||||
throw new Error("catalog should not be read");
|
||||
},
|
||||
setPaseoAgentProvider: async () => {
|
||||
calls.push("set");
|
||||
throw new Error("set should not run");
|
||||
},
|
||||
startPaseoAgentOAuth: async () => {
|
||||
throw new Error("oauth should not run");
|
||||
},
|
||||
completePaseoAgentOAuth: async () => {
|
||||
throw new Error("oauth should not run");
|
||||
},
|
||||
storePaseoAgentOAuthCredential: async () => {
|
||||
throw new Error("oauth should not run");
|
||||
},
|
||||
close: async () => {},
|
||||
}),
|
||||
}),
|
||||
).rejects.toMatchObject({
|
||||
code: "HOST_UPDATE_REQUIRED",
|
||||
message: "Update the Paseo daemon to use this command.",
|
||||
});
|
||||
|
||||
expect(calls).toEqual([]);
|
||||
});
|
||||
|
||||
it("lets the user choose a provider when no id is passed", async () => {
|
||||
const setCalls: unknown[] = [];
|
||||
const output: string[] = [];
|
||||
|
||||
await runAddCommand(undefined, {}, {} as never, {
|
||||
write: (message) => output.push(message),
|
||||
promptText: async (message) => {
|
||||
expect(message).toBe("Select provider:");
|
||||
return "2";
|
||||
},
|
||||
promptSecret: async () => "chosen-secret",
|
||||
readStdin: async () => {
|
||||
throw new Error("stdin should not be read");
|
||||
},
|
||||
connectDaemon: async () =>
|
||||
createClient({
|
||||
catalog: [
|
||||
apiKeyEntry({ id: "first-key", label: "First Key" }),
|
||||
apiKeyEntry({ id: "second-key", label: "Second Key" }),
|
||||
],
|
||||
setProvider: async (input) => {
|
||||
setCalls.push(input);
|
||||
return {
|
||||
requestId: "set-1",
|
||||
success: true,
|
||||
provider: {
|
||||
name: input.name,
|
||||
providerType: input.providerType,
|
||||
models: input.options.models ?? [],
|
||||
auth: { kind: "api_key", configured: true, source: "literal" },
|
||||
available: true,
|
||||
error: null,
|
||||
},
|
||||
error: null,
|
||||
};
|
||||
},
|
||||
}),
|
||||
});
|
||||
|
||||
expect(output.join("\n")).toContain("First Key (first-key)");
|
||||
expect(output.join("\n")).toContain("Second Key (second-key)");
|
||||
expect(setCalls).toEqual([
|
||||
expect.objectContaining({ name: "second-key", providerType: "second-key" }),
|
||||
]);
|
||||
});
|
||||
});
|
||||
@@ -1,510 +0,0 @@
|
||||
import { createInterface } from "node:readline/promises";
|
||||
import { Writable } from "node:stream";
|
||||
import type { Command } from "commander";
|
||||
import type { DaemonClient } from "@getpaseo/client/internal/daemon-client";
|
||||
import type {
|
||||
PaseoAgentCatalogEntry,
|
||||
PaseoAgentOAuthCredential,
|
||||
PaseoAgentProviderAuthState,
|
||||
RedactedPaseoAgentProviderConfig,
|
||||
} from "@getpaseo/protocol/messages";
|
||||
import { loginOAuthBrowser } from "@getpaseo/server";
|
||||
|
||||
import { connectToDaemon } from "../../utils/client.js";
|
||||
import { collectMultiple } from "../../utils/command-options.js";
|
||||
import { openBrowserUrl } from "../../utils/open-browser.js";
|
||||
import { requirePaseoAgentCatalogFeature } from "./feature.js";
|
||||
import type {
|
||||
CommandError,
|
||||
CommandOptions,
|
||||
OutputSchema,
|
||||
SingleResult,
|
||||
} from "../../output/index.js";
|
||||
|
||||
interface ProviderAddOptions extends CommandOptions {
|
||||
name?: string;
|
||||
apiKeyStdin?: boolean;
|
||||
deviceCode?: boolean;
|
||||
model?: string[];
|
||||
}
|
||||
|
||||
interface ProviderModelInput {
|
||||
id: string;
|
||||
label?: string;
|
||||
api?: string;
|
||||
reasoning?: boolean;
|
||||
contextWindow?: number;
|
||||
maxTokens?: number;
|
||||
}
|
||||
|
||||
interface ProviderConfiguredItem {
|
||||
name: string;
|
||||
providerType: string;
|
||||
label: string;
|
||||
auth: string;
|
||||
available: string;
|
||||
models: string;
|
||||
}
|
||||
|
||||
interface ProviderAddClient extends Pick<
|
||||
DaemonClient,
|
||||
| "waitForServerInfo"
|
||||
| "getPaseoAgentCatalog"
|
||||
| "setPaseoAgentProvider"
|
||||
| "startPaseoAgentOAuth"
|
||||
| "completePaseoAgentOAuth"
|
||||
| "storePaseoAgentOAuthCredential"
|
||||
| "close"
|
||||
> {}
|
||||
|
||||
export interface ProviderAddDependencies {
|
||||
connectDaemon: (options: { host?: string }) => Promise<ProviderAddClient>;
|
||||
readStdin: () => Promise<string>;
|
||||
promptText: (message: string) => Promise<string>;
|
||||
promptSecret: (message: string) => Promise<string>;
|
||||
loginBrowserCredential: typeof loginOAuthBrowser;
|
||||
openBrowser: (url: string) => boolean;
|
||||
write: (message: string) => void;
|
||||
}
|
||||
|
||||
const defaultDependencies: ProviderAddDependencies = {
|
||||
connectDaemon: connectToDaemon,
|
||||
readStdin,
|
||||
promptText,
|
||||
promptSecret,
|
||||
loginBrowserCredential: loginOAuthBrowser,
|
||||
openBrowser: openBrowserUrl,
|
||||
write: (message) => console.error(message),
|
||||
};
|
||||
|
||||
export const providerConfiguredSchema: OutputSchema<ProviderConfiguredItem> = {
|
||||
idField: "name",
|
||||
columns: [
|
||||
{ header: "NAME", field: "name", width: 20 },
|
||||
{ header: "TYPE", field: "providerType", width: 16 },
|
||||
{ header: "LABEL", field: "label", width: 22 },
|
||||
{ header: "AUTH", field: "auth", width: 16 },
|
||||
{ header: "AVAILABLE", field: "available", width: 10 },
|
||||
{ header: "MODELS", field: "models", width: 50 },
|
||||
],
|
||||
};
|
||||
|
||||
async function readStdin(): Promise<string> {
|
||||
process.stdin.setEncoding("utf8");
|
||||
let value = "";
|
||||
for await (const chunk of process.stdin) {
|
||||
value += chunk;
|
||||
}
|
||||
return value;
|
||||
}
|
||||
|
||||
async function promptText(message: string): Promise<string> {
|
||||
const rl = createInterface({ input: process.stdin, output: process.stdout });
|
||||
try {
|
||||
return (await rl.question(`${message} `)).trim();
|
||||
} finally {
|
||||
rl.close();
|
||||
}
|
||||
}
|
||||
|
||||
async function promptSecret(message: string): Promise<string> {
|
||||
const output = new Writable({
|
||||
write(chunk, _encoding, callback) {
|
||||
const text = String(chunk);
|
||||
if (text.includes(message)) {
|
||||
process.stdout.write(text);
|
||||
}
|
||||
callback();
|
||||
},
|
||||
}) as Writable & { isTTY?: boolean };
|
||||
output.isTTY = true;
|
||||
|
||||
const rl = createInterface({ input: process.stdin, output, terminal: true });
|
||||
try {
|
||||
return (await rl.question(`${message} `)).trim();
|
||||
} finally {
|
||||
process.stdout.write("\n");
|
||||
rl.close();
|
||||
}
|
||||
}
|
||||
|
||||
function authField(entry: PaseoAgentCatalogEntry, field: string): string | undefined {
|
||||
const auth = entry.auth;
|
||||
const value = auth[field];
|
||||
return typeof value === "string" && value.trim().length > 0 ? value.trim() : undefined;
|
||||
}
|
||||
|
||||
function requireAuthField(
|
||||
entry: PaseoAgentCatalogEntry,
|
||||
field: string,
|
||||
description: string,
|
||||
): string {
|
||||
const value = authField(entry, field);
|
||||
if (value) return value;
|
||||
|
||||
throw {
|
||||
code: "UNSUPPORTED_PROVIDER_AUTH",
|
||||
message: `Provider ${entry.id} is missing ${description}. Update the Paseo daemon to use this command.`,
|
||||
} satisfies CommandError;
|
||||
}
|
||||
|
||||
function apiKeyEnvVar(entry: PaseoAgentCatalogEntry): string {
|
||||
return requireAuthField(entry, "envVar", "an API key environment variable");
|
||||
}
|
||||
|
||||
function oauthFlow(entry: PaseoAgentCatalogEntry): string {
|
||||
return requireAuthField(entry, "flow", "an OAuth flow");
|
||||
}
|
||||
|
||||
function normalizeModels(rawModels: string[] | undefined): string[] {
|
||||
return (rawModels ?? [])
|
||||
.flatMap((value) => value.split(","))
|
||||
.map((value) => value.trim())
|
||||
.filter(Boolean);
|
||||
}
|
||||
|
||||
function catalogModels(entry: PaseoAgentCatalogEntry): ProviderModelInput[] {
|
||||
return entry.models.map((model) => ({
|
||||
id: model.id,
|
||||
...(model.label ? { label: model.label } : {}),
|
||||
...(model.api ? { api: model.api } : {}),
|
||||
...(model.reasoning !== undefined ? { reasoning: model.reasoning } : {}),
|
||||
...(model.contextWindow !== undefined ? { contextWindow: model.contextWindow } : {}),
|
||||
...(model.maxTokens !== undefined ? { maxTokens: model.maxTokens } : {}),
|
||||
}));
|
||||
}
|
||||
|
||||
function resolveModels(
|
||||
entry: PaseoAgentCatalogEntry,
|
||||
options: ProviderAddOptions,
|
||||
): ProviderModelInput[] | undefined {
|
||||
const modelIds = normalizeModels(options.model);
|
||||
if (modelIds.length > 0) {
|
||||
return modelIds.map((id) => ({ id }));
|
||||
}
|
||||
|
||||
const models = catalogModels(entry);
|
||||
if (models.length > 0) {
|
||||
return models;
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
|
||||
async function selectCatalogEntry(
|
||||
catalog: PaseoAgentCatalogEntry[],
|
||||
dependencies: ProviderAddDependencies,
|
||||
): Promise<PaseoAgentCatalogEntry> {
|
||||
if (catalog.length === 0) {
|
||||
throw {
|
||||
code: "EMPTY_PROVIDER_CATALOG",
|
||||
message: "The Paseo daemon returned an empty provider catalog.",
|
||||
} satisfies CommandError;
|
||||
}
|
||||
|
||||
dependencies.write("Available model providers:");
|
||||
catalog.forEach((entry, index) => {
|
||||
dependencies.write(` ${index + 1}. ${entry.label} (${entry.id})`);
|
||||
});
|
||||
const answer = await dependencies.promptText("Select provider:");
|
||||
const selectedIndex = Number(answer);
|
||||
const byIndex = Number.isInteger(selectedIndex) ? catalog[selectedIndex - 1] : undefined;
|
||||
const byId = catalog.find((entry) => entry.id === answer);
|
||||
const selected = byIndex ?? byId;
|
||||
if (selected) {
|
||||
return selected;
|
||||
}
|
||||
throw {
|
||||
code: "INVALID_PROVIDER_SELECTION",
|
||||
message: `Invalid provider selection: ${answer}`,
|
||||
} satisfies CommandError;
|
||||
}
|
||||
|
||||
async function resolveEntry(
|
||||
id: string | undefined,
|
||||
catalog: PaseoAgentCatalogEntry[],
|
||||
dependencies: ProviderAddDependencies,
|
||||
): Promise<PaseoAgentCatalogEntry> {
|
||||
if (!id) {
|
||||
return selectCatalogEntry(catalog, dependencies);
|
||||
}
|
||||
|
||||
const entry = catalog.find((candidate) => candidate.id === id);
|
||||
if (entry) {
|
||||
return entry;
|
||||
}
|
||||
|
||||
const knownIds = catalog.map((candidate) => candidate.id).join(", ");
|
||||
throw {
|
||||
code: "UNKNOWN_PROVIDER",
|
||||
message: `Unknown model provider type "${id}". Known provider ids: ${knownIds}.`,
|
||||
} satisfies CommandError;
|
||||
}
|
||||
|
||||
function formatAuthState(provider: RedactedPaseoAgentProviderConfig): string {
|
||||
if (!provider.auth) {
|
||||
return "not configured";
|
||||
}
|
||||
return provider.auth.configured ? "Connected" : "Needs attention";
|
||||
}
|
||||
|
||||
function toConfiguredItem(
|
||||
provider: RedactedPaseoAgentProviderConfig,
|
||||
entry: PaseoAgentCatalogEntry,
|
||||
): ProviderConfiguredItem {
|
||||
return {
|
||||
name: provider.name,
|
||||
providerType: provider.providerType,
|
||||
label: entry.label,
|
||||
auth: formatAuthState(provider),
|
||||
available: provider.available ? "yes" : "no",
|
||||
models: provider.models.map((model) => model.id).join(", ") || "-",
|
||||
};
|
||||
}
|
||||
|
||||
function formatDaemonTarget(host: string | undefined): string {
|
||||
if (!host) {
|
||||
return "local daemon";
|
||||
}
|
||||
try {
|
||||
if (host.startsWith("tcp://")) {
|
||||
const url = new URL(host);
|
||||
url.searchParams.delete("password");
|
||||
return `selected daemon (${url.toString()})`;
|
||||
}
|
||||
} catch {
|
||||
return `selected daemon (${host})`;
|
||||
}
|
||||
return `selected daemon (${host})`;
|
||||
}
|
||||
|
||||
async function resolveApiKey(
|
||||
entry: PaseoAgentCatalogEntry,
|
||||
options: ProviderAddOptions,
|
||||
dependencies: ProviderAddDependencies,
|
||||
): Promise<string> {
|
||||
const envVar = apiKeyEnvVar(entry);
|
||||
if (options.apiKeyStdin) {
|
||||
const value = (await dependencies.readStdin()).trim();
|
||||
if (value) {
|
||||
return value;
|
||||
}
|
||||
throw {
|
||||
code: "MISSING_API_KEY",
|
||||
message: "No API key was read from stdin.",
|
||||
} satisfies CommandError;
|
||||
}
|
||||
|
||||
const hint = authField(entry, "hint");
|
||||
const keyUrl = authField(entry, "keyUrl");
|
||||
if (hint) {
|
||||
dependencies.write(hint);
|
||||
}
|
||||
if (keyUrl) {
|
||||
dependencies.write(`API key URL: ${keyUrl}`);
|
||||
}
|
||||
const placeholder = authField(entry, "placeholder") ?? "API key";
|
||||
const value = await dependencies.promptSecret(
|
||||
`Enter ${placeholder} (leave empty to use $${envVar}):`,
|
||||
);
|
||||
return value || `$${envVar}`;
|
||||
}
|
||||
|
||||
function isBrowserOpenError(error: unknown): boolean {
|
||||
return (
|
||||
typeof error === "object" &&
|
||||
error !== null &&
|
||||
"code" in error &&
|
||||
(error as CommandError).code === "BROWSER_OPEN_FAILED"
|
||||
);
|
||||
}
|
||||
|
||||
function printOAuthAuthorization(
|
||||
authorization: Awaited<ReturnType<ProviderAddClient["startPaseoAgentOAuth"]>>["authorization"],
|
||||
dependencies: ProviderAddDependencies,
|
||||
): void {
|
||||
if (!authorization) {
|
||||
dependencies.write("Authorization completed; waiting for the daemon to store credentials...");
|
||||
return;
|
||||
}
|
||||
if (authorization.instructions) {
|
||||
dependencies.write(authorization.instructions);
|
||||
}
|
||||
if (authorization.verificationUri) {
|
||||
dependencies.write(`Open: ${authorization.verificationUri}`);
|
||||
}
|
||||
if (authorization.userCode) {
|
||||
dependencies.write(`Code: ${authorization.userCode}`);
|
||||
}
|
||||
if (authorization.url) {
|
||||
dependencies.write(`Open: ${authorization.url}`);
|
||||
}
|
||||
if (authorization.expiresInSeconds) {
|
||||
dependencies.write(
|
||||
`Expires in about ${Math.round(authorization.expiresInSeconds / 60)} minutes.`,
|
||||
);
|
||||
}
|
||||
dependencies.write("Waiting for authorization...");
|
||||
}
|
||||
|
||||
async function runDaemonOAuth(
|
||||
client: ProviderAddClient,
|
||||
name: string,
|
||||
dependencies: ProviderAddDependencies,
|
||||
): Promise<PaseoAgentProviderAuthState | undefined> {
|
||||
const started = await client.startPaseoAgentOAuth(name, { mode: "device_code" });
|
||||
if (!started.success) {
|
||||
throw {
|
||||
code: "OAUTH_START_FAILED",
|
||||
message: started.error ?? "Daemon rejected the OAuth start request.",
|
||||
} satisfies CommandError;
|
||||
}
|
||||
printOAuthAuthorization(started.authorization, dependencies);
|
||||
const completed = await client.completePaseoAgentOAuth(name);
|
||||
if (!completed.success) {
|
||||
throw {
|
||||
code: "OAUTH_COMPLETE_FAILED",
|
||||
message: completed.error ?? "Daemon did not complete OAuth.",
|
||||
} satisfies CommandError;
|
||||
}
|
||||
return completed.auth;
|
||||
}
|
||||
|
||||
async function runBrowserOAuth(
|
||||
client: ProviderAddClient,
|
||||
entry: PaseoAgentCatalogEntry,
|
||||
name: string,
|
||||
options: ProviderAddOptions,
|
||||
dependencies: ProviderAddDependencies,
|
||||
): Promise<PaseoAgentProviderAuthState | undefined> {
|
||||
const target = formatDaemonTarget(options.host);
|
||||
const credential: PaseoAgentOAuthCredential = await dependencies.loginBrowserCredential({
|
||||
flow: oauthFlow(entry),
|
||||
onAuthUrl: (url, instructions) => {
|
||||
const opened = dependencies.openBrowser(url);
|
||||
if (!opened) {
|
||||
throw {
|
||||
code: "BROWSER_OPEN_FAILED",
|
||||
message: "Browser could not be opened.",
|
||||
} satisfies CommandError;
|
||||
}
|
||||
dependencies.write(instructions ?? "Opening your browser to authorize Paseo.");
|
||||
dependencies.write(` ${url}`);
|
||||
dependencies.write("Waiting for you to approve in the browser...");
|
||||
},
|
||||
onProgress: (message) => dependencies.write(message),
|
||||
promptForCode: dependencies.promptText,
|
||||
});
|
||||
const result = await client.storePaseoAgentOAuthCredential({ name, credential });
|
||||
if (!result.success) {
|
||||
throw {
|
||||
code: "OAUTH_STORE_FAILED",
|
||||
message: result.error ?? "Daemon rejected the OAuth credential.",
|
||||
} satisfies CommandError;
|
||||
}
|
||||
dependencies.write(`Credential accepted by ${target}.`);
|
||||
return result.auth;
|
||||
}
|
||||
|
||||
async function configureProvider(
|
||||
client: ProviderAddClient,
|
||||
entry: PaseoAgentCatalogEntry,
|
||||
name: string,
|
||||
options: ProviderAddOptions,
|
||||
dependencies: ProviderAddDependencies,
|
||||
): Promise<RedactedPaseoAgentProviderConfig> {
|
||||
const models = resolveModels(entry, options);
|
||||
const apiKey =
|
||||
entry.auth.kind === "api_key" ? await resolveApiKey(entry, options, dependencies) : undefined;
|
||||
const result = await client.setPaseoAgentProvider({
|
||||
name,
|
||||
providerType: entry.id,
|
||||
options: {
|
||||
...(apiKey ? { apiKey } : {}),
|
||||
...(models ? { models } : {}),
|
||||
},
|
||||
});
|
||||
if (!result.success || !result.provider) {
|
||||
throw {
|
||||
code: "PROVIDER_CONFIG_FAILED",
|
||||
message: result.error ?? "Daemon rejected the provider config.",
|
||||
} satisfies CommandError;
|
||||
}
|
||||
return result.provider;
|
||||
}
|
||||
|
||||
async function authenticateOAuthProvider(
|
||||
client: ProviderAddClient,
|
||||
entry: PaseoAgentCatalogEntry,
|
||||
name: string,
|
||||
options: ProviderAddOptions,
|
||||
dependencies: ProviderAddDependencies,
|
||||
): Promise<PaseoAgentProviderAuthState | undefined> {
|
||||
if (options.deviceCode) {
|
||||
return runDaemonOAuth(client, name, dependencies);
|
||||
}
|
||||
try {
|
||||
return await runBrowserOAuth(client, entry, name, options, dependencies);
|
||||
} catch (error) {
|
||||
if (!isBrowserOpenError(error)) {
|
||||
throw error;
|
||||
}
|
||||
dependencies.write("Browser could not be opened; using device-code authorization.");
|
||||
return runDaemonOAuth(client, name, dependencies);
|
||||
}
|
||||
}
|
||||
|
||||
export async function runAddCommand(
|
||||
id: string | undefined,
|
||||
options: ProviderAddOptions,
|
||||
_command: Command,
|
||||
dependencies: Partial<ProviderAddDependencies> = {},
|
||||
): Promise<SingleResult<ProviderConfiguredItem>> {
|
||||
const deps = { ...defaultDependencies, ...dependencies };
|
||||
const client = await deps.connectDaemon({ host: options.host });
|
||||
try {
|
||||
await requirePaseoAgentCatalogFeature(client);
|
||||
const catalogResult = await client.getPaseoAgentCatalog();
|
||||
if (catalogResult.error) {
|
||||
throw {
|
||||
code: "PROVIDER_CATALOG_FAILED",
|
||||
message: catalogResult.error,
|
||||
} satisfies CommandError;
|
||||
}
|
||||
const entry = await resolveEntry(id, catalogResult.catalog, deps);
|
||||
const name = options.name?.trim() || entry.id;
|
||||
const provider = await configureProvider(client, entry, name, options, deps);
|
||||
if (entry.auth.kind !== "api_key" && entry.auth.kind !== "oauth") {
|
||||
throw {
|
||||
code: "UNSUPPORTED_PROVIDER_AUTH",
|
||||
message: `Provider ${entry.label} uses an auth type this CLI does not understand. Update the Paseo daemon to use this command.`,
|
||||
} satisfies CommandError;
|
||||
}
|
||||
const auth =
|
||||
entry.auth.kind === "oauth"
|
||||
? await authenticateOAuthProvider(client, entry, name, options, deps)
|
||||
: provider.auth;
|
||||
|
||||
return {
|
||||
type: "single",
|
||||
data: toConfiguredItem(auth ? { ...provider, auth } : provider, entry),
|
||||
schema: providerConfiguredSchema,
|
||||
};
|
||||
} finally {
|
||||
await client.close().catch(() => {});
|
||||
}
|
||||
}
|
||||
|
||||
export function addProviderAddOptions(command: Command): Command {
|
||||
return command
|
||||
.description("Configure a Paseo Agent model provider")
|
||||
.argument("[id]", "Catalog provider id; omit to choose interactively")
|
||||
.option("--name <instanceName>", "Provider instance name (default: provider id)")
|
||||
.option(
|
||||
"--model <id>",
|
||||
"Model ID to expose (repeatable, comma-separated; defaults to catalog models)",
|
||||
collectMultiple,
|
||||
[],
|
||||
)
|
||||
.option("--api-key-stdin", "Read API key from stdin")
|
||||
.option("--device-code", "Use daemon-run device-code OAuth instead of browser OAuth");
|
||||
}
|
||||
@@ -1,53 +0,0 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import type { ServerInfoStatusPayload } from "@getpaseo/protocol/messages";
|
||||
|
||||
import { requirePaseoAgentCatalogFeature } from "./feature.js";
|
||||
|
||||
function createServerInfo(features: ServerInfoStatusPayload["features"]): ServerInfoStatusPayload {
|
||||
return {
|
||||
status: "server_info",
|
||||
serverId: "test-daemon",
|
||||
features,
|
||||
};
|
||||
}
|
||||
|
||||
describe("provider feature gate", () => {
|
||||
it("waits for delayed server_info before allowing catalog commands", async () => {
|
||||
let resolveServerInfo: ((serverInfo: ServerInfoStatusPayload) => void) | null = null;
|
||||
|
||||
const allowed = requirePaseoAgentCatalogFeature({
|
||||
waitForServerInfo: async () =>
|
||||
new Promise<ServerInfoStatusPayload>((resolve) => {
|
||||
resolveServerInfo = resolve;
|
||||
}),
|
||||
});
|
||||
|
||||
expect(resolveServerInfo).not.toBeNull();
|
||||
resolveServerInfo?.(createServerInfo({ paseoAgentCatalog: true }));
|
||||
|
||||
await expect(allowed).resolves.toBeUndefined();
|
||||
});
|
||||
|
||||
it("surfaces the server_info wait timeout", async () => {
|
||||
const timeoutError = new Error("Timed out waiting for server_info status message (5ms)");
|
||||
|
||||
await expect(
|
||||
requirePaseoAgentCatalogFeature({
|
||||
waitForServerInfo: async () => {
|
||||
throw timeoutError;
|
||||
},
|
||||
}),
|
||||
).rejects.toThrow("Timed out waiting for server_info status message (5ms)");
|
||||
});
|
||||
|
||||
it("requires the catalog feature when server_info arrives without it", async () => {
|
||||
await expect(
|
||||
requirePaseoAgentCatalogFeature({
|
||||
waitForServerInfo: async () => createServerInfo({ paseoAgentCatalog: false }),
|
||||
}),
|
||||
).rejects.toMatchObject({
|
||||
code: "HOST_UPDATE_REQUIRED",
|
||||
message: "Update the Paseo daemon to use this command.",
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -1,17 +0,0 @@
|
||||
import type { DaemonClient } from "@getpaseo/client/internal/daemon-client";
|
||||
import type { CommandError } from "../../output/index.js";
|
||||
|
||||
export interface PaseoAgentCatalogFeatureClient extends Pick<DaemonClient, "waitForServerInfo"> {}
|
||||
|
||||
export async function requirePaseoAgentCatalogFeature(
|
||||
client: PaseoAgentCatalogFeatureClient,
|
||||
): Promise<void> {
|
||||
const serverInfo = await client.waitForServerInfo();
|
||||
if (serverInfo.features?.paseoAgentCatalog === true) {
|
||||
return;
|
||||
}
|
||||
throw {
|
||||
code: "HOST_UPDATE_REQUIRED",
|
||||
message: "Update the Paseo daemon to use this command.",
|
||||
} satisfies CommandError;
|
||||
}
|
||||
@@ -1,53 +1,23 @@
|
||||
import { Command } from "commander";
|
||||
import { runLsCommand } from "./ls.js";
|
||||
import { runModelsCommand } from "./models.js";
|
||||
import { addProviderAddOptions, runAddCommand, type ProviderAddDependencies } from "./add.js";
|
||||
import { runRmCommand, type ProviderRmDependencies } from "./rm.js";
|
||||
import type { ProviderListItem, ProviderLsDependencies } from "./ls.js";
|
||||
import { withOutput } from "../../output/index.js";
|
||||
import { addJsonAndDaemonHostOptions } from "../../utils/command-options.js";
|
||||
|
||||
export function createProviderCommand(
|
||||
dependencies: Partial<
|
||||
ProviderAddDependencies & ProviderLsDependencies & ProviderRmDependencies
|
||||
> = {},
|
||||
): Command {
|
||||
const provider = new Command("provider").description(
|
||||
"Manage Paseo Agent model providers and agent provider models",
|
||||
);
|
||||
export function createProviderCommand(): Command {
|
||||
const provider = new Command("provider").description("Manage agent providers");
|
||||
|
||||
addJsonAndDaemonHostOptions(
|
||||
provider.command("ls").description("List configured Paseo Agent model providers"),
|
||||
).action(
|
||||
withOutput<ProviderListItem, []>((options, command) =>
|
||||
runLsCommand(options, command, dependencies),
|
||||
),
|
||||
);
|
||||
provider.command("ls").description("List available providers and status"),
|
||||
).action(withOutput(runLsCommand));
|
||||
|
||||
addJsonAndDaemonHostOptions(
|
||||
provider
|
||||
.command("models")
|
||||
.description("List models for a provider")
|
||||
.argument("<provider>", "Provider name")
|
||||
.argument("<provider>", "Provider name (claude, codex, opencode)")
|
||||
.option("--thinking", "Include thinking option IDs for each model"),
|
||||
).action(withOutput(runModelsCommand));
|
||||
|
||||
addJsonAndDaemonHostOptions(addProviderAddOptions(provider.command("add"))).action(
|
||||
withOutput<Awaited<ReturnType<typeof runAddCommand>>["data"], [string | undefined]>(
|
||||
(id, options, command) => runAddCommand(id, options, command, dependencies),
|
||||
),
|
||||
);
|
||||
|
||||
addJsonAndDaemonHostOptions(
|
||||
provider
|
||||
.command("rm")
|
||||
.description("Remove a Paseo Agent model provider")
|
||||
.argument("<name>", "Provider instance name"),
|
||||
).action(
|
||||
withOutput<Awaited<ReturnType<typeof runRmCommand>>["data"], [string]>(
|
||||
(name, options, command) => runRmCommand(name, options, command, dependencies),
|
||||
),
|
||||
);
|
||||
|
||||
return provider;
|
||||
}
|
||||
|
||||
@@ -1,163 +0,0 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
|
||||
import { render } from "../../output/index.js";
|
||||
import { runLsCommand } from "./ls.js";
|
||||
import { runRmCommand } from "./rm.js";
|
||||
|
||||
function createServerInfo() {
|
||||
return {
|
||||
status: "server_info",
|
||||
serverId: "test-daemon",
|
||||
features: { paseoAgentCatalog: true },
|
||||
};
|
||||
}
|
||||
|
||||
describe("provider ls", () => {
|
||||
it("renders an empty configured-provider table with headers", async () => {
|
||||
const result = await runLsCommand({ host: "localhost:7777" }, {} as never, {
|
||||
connectDaemon: async () => ({
|
||||
waitForServerInfo: async () => createServerInfo(),
|
||||
getPaseoAgentCatalog: async () => ({
|
||||
requestId: "catalog-1",
|
||||
catalog: [],
|
||||
error: null,
|
||||
}),
|
||||
getPaseoAgentProviders: async () => ({
|
||||
requestId: "providers-1",
|
||||
defaultModel: null,
|
||||
providers: [],
|
||||
error: null,
|
||||
}),
|
||||
close: async () => {},
|
||||
}),
|
||||
});
|
||||
|
||||
expect(result.data).toEqual([]);
|
||||
expect(render(result, { format: "table", noColor: true })).toContain("NAME");
|
||||
expect(render(result, { format: "json" })).toBe("[]");
|
||||
});
|
||||
|
||||
it("lists configured model providers with catalog labels and auth states", async () => {
|
||||
const result = await runLsCommand({ host: "localhost:7777" }, {} as never, {
|
||||
connectDaemon: async (options) => {
|
||||
expect(options.host).toBe("localhost:7777");
|
||||
return {
|
||||
waitForServerInfo: async () => createServerInfo(),
|
||||
getPaseoAgentCatalog: async () => ({
|
||||
requestId: "catalog-1",
|
||||
catalog: [
|
||||
{
|
||||
id: "alpha-key",
|
||||
label: "Alpha Key",
|
||||
api: "test-api",
|
||||
baseUrl: "https://alpha.example.test",
|
||||
auth: { kind: "api_key", envVar: "ALPHA_API_KEY" },
|
||||
models: [{ id: "alpha-model" }],
|
||||
},
|
||||
{
|
||||
id: "beta-oauth",
|
||||
label: "Beta OAuth",
|
||||
api: "test-oauth-api",
|
||||
baseUrl: "https://beta.example.test",
|
||||
auth: { kind: "oauth", flow: "beta-flow" },
|
||||
models: [{ id: "beta-model" }],
|
||||
},
|
||||
],
|
||||
error: null,
|
||||
}),
|
||||
getPaseoAgentProviders: async () => ({
|
||||
requestId: "providers-1",
|
||||
defaultModel: null,
|
||||
providers: [
|
||||
{
|
||||
name: "alpha-main",
|
||||
providerType: "alpha-key",
|
||||
models: [{ id: "alpha-model" }],
|
||||
auth: { kind: "api_key", configured: true, source: "literal" },
|
||||
available: true,
|
||||
error: null,
|
||||
},
|
||||
{
|
||||
name: "beta-main",
|
||||
providerType: "beta-oauth",
|
||||
models: [{ id: "beta-model" }],
|
||||
auth: { kind: "oauth", configured: false, hint: "sign in again" },
|
||||
available: false,
|
||||
error: "auth missing",
|
||||
},
|
||||
{
|
||||
name: "manual-main",
|
||||
providerType: "manual-type",
|
||||
models: [{ id: "manual-model" }],
|
||||
available: true,
|
||||
error: null,
|
||||
},
|
||||
],
|
||||
error: null,
|
||||
}),
|
||||
close: async () => {},
|
||||
};
|
||||
},
|
||||
});
|
||||
|
||||
expect(result.data).toEqual([
|
||||
{
|
||||
name: "alpha-main",
|
||||
providerType: "alpha-key",
|
||||
label: "Alpha Key",
|
||||
auth: "Connected",
|
||||
available: "yes",
|
||||
models: "alpha-model",
|
||||
},
|
||||
{
|
||||
name: "beta-main",
|
||||
providerType: "beta-oauth",
|
||||
label: "Beta OAuth",
|
||||
auth: "Needs attention",
|
||||
available: "no",
|
||||
models: "beta-model",
|
||||
},
|
||||
{
|
||||
name: "manual-main",
|
||||
providerType: "manual-type",
|
||||
label: "manual-type",
|
||||
auth: "not configured",
|
||||
available: "yes",
|
||||
models: "manual-model",
|
||||
},
|
||||
]);
|
||||
|
||||
const table = render(result, { format: "table", noColor: true });
|
||||
expect(table).toContain("Connected");
|
||||
expect(table).toContain("Needs attention");
|
||||
expect(table).toContain("not configured");
|
||||
});
|
||||
});
|
||||
|
||||
describe("provider rm", () => {
|
||||
it("removes a configured model provider", async () => {
|
||||
const removedNames: string[] = [];
|
||||
|
||||
const result = await runRmCommand("alpha-main", { host: "localhost:7777" }, {} as never, {
|
||||
connectDaemon: async (options) => {
|
||||
expect(options.host).toBe("localhost:7777");
|
||||
return {
|
||||
waitForServerInfo: async () => createServerInfo(),
|
||||
removePaseoAgentProvider: async (name: string) => {
|
||||
removedNames.push(name);
|
||||
return {
|
||||
requestId: "remove-1",
|
||||
success: true,
|
||||
removed: true,
|
||||
error: null,
|
||||
};
|
||||
},
|
||||
close: async () => {},
|
||||
};
|
||||
},
|
||||
});
|
||||
|
||||
expect(removedNames).toEqual(["alpha-main"]);
|
||||
expect(result.data).toEqual({ name: "alpha-main", removed: "yes" });
|
||||
});
|
||||
});
|
||||
@@ -1,57 +1,52 @@
|
||||
import type { Command } from "commander";
|
||||
import {
|
||||
renderTable,
|
||||
renderTableHeader,
|
||||
type CommandOptions,
|
||||
type ListResult,
|
||||
type OutputSchema,
|
||||
} from "../../output/index.js";
|
||||
import type { DaemonClient } from "@getpaseo/client/internal/daemon-client";
|
||||
import type {
|
||||
PaseoAgentCatalogEntry,
|
||||
RedactedPaseoAgentProviderConfig,
|
||||
} from "@getpaseo/protocol/messages";
|
||||
import { connectToDaemon } from "../../utils/client.js";
|
||||
import { requirePaseoAgentCatalogFeature } from "./feature.js";
|
||||
import type { CommandOptions, ListResult, OutputSchema } from "../../output/index.js";
|
||||
import type { ProviderSnapshotEntry } from "@getpaseo/protocol/agent-types";
|
||||
import { AGENT_PROVIDER_DEFINITIONS } from "@getpaseo/protocol/provider-manifest";
|
||||
import { tryConnectToDaemon } from "../../utils/client.js";
|
||||
|
||||
export interface ProviderListItem {
|
||||
name: string;
|
||||
providerType: string;
|
||||
provider: ProviderSnapshotEntry["provider"];
|
||||
label: string;
|
||||
auth: string;
|
||||
available: string;
|
||||
models: string;
|
||||
status: string;
|
||||
enabled: "Enabled" | "Disabled";
|
||||
defaultMode: string;
|
||||
modes: string;
|
||||
}
|
||||
|
||||
interface ProviderLsClient extends Pick<
|
||||
DaemonClient,
|
||||
"waitForServerInfo" | "getPaseoAgentCatalog" | "getPaseoAgentProviders" | "close"
|
||||
> {}
|
||||
/** Derive provider list from the manifest — single source of truth */
|
||||
const PROVIDERS: ProviderListItem[] = AGENT_PROVIDER_DEFINITIONS.map((def) => ({
|
||||
provider: def.id,
|
||||
label: def.label,
|
||||
status: "available",
|
||||
enabled: def.enabledByDefault === false ? "Disabled" : "Enabled",
|
||||
defaultMode: def.defaultModeId ?? "-",
|
||||
modes: def.modes.length > 0 ? def.modes.map((m) => m.label).join(", ") : "-",
|
||||
}));
|
||||
|
||||
export interface ProviderLsDependencies {
|
||||
connectDaemon: (options: { host?: string }) => Promise<ProviderLsClient>;
|
||||
function getStaticProviders(): ProviderListItem[] {
|
||||
return PROVIDERS;
|
||||
}
|
||||
|
||||
const defaultDependencies: ProviderLsDependencies = {
|
||||
connectDaemon: connectToDaemon,
|
||||
};
|
||||
|
||||
/** Schema for provider ls output */
|
||||
export const providerLsSchema: OutputSchema<ProviderListItem> = {
|
||||
idField: "name",
|
||||
idField: "provider",
|
||||
columns: [
|
||||
{ header: "NAME", field: "name", width: 20 },
|
||||
{ header: "TYPE", field: "providerType", width: 16 },
|
||||
{ header: "LABEL", field: "label", width: 22 },
|
||||
{ header: "AUTH", field: "auth", width: 16 },
|
||||
{ header: "AVAILABLE", field: "available", width: 10 },
|
||||
{ header: "MODELS", field: "models", width: 30 },
|
||||
{ header: "PROVIDER", field: "provider", width: 12 },
|
||||
{ header: "LABEL", field: "label", width: 16 },
|
||||
{
|
||||
header: "STATUS",
|
||||
field: "status",
|
||||
width: 12,
|
||||
color: (value) => {
|
||||
if (value === "available") return "green";
|
||||
if (value === "unavailable") return "red";
|
||||
return undefined;
|
||||
},
|
||||
},
|
||||
{ header: "ENABLED", field: "enabled", width: 10 },
|
||||
{ header: "DEFAULT MODE", field: "defaultMode", width: 14 },
|
||||
{ header: "MODES", field: "modes", width: 30 },
|
||||
],
|
||||
renderHuman: (result, options) => {
|
||||
if (result.type === "list" && result.data.length === 0) {
|
||||
return options.noHeaders ? "" : renderTableHeader(providerLsSchema, options);
|
||||
}
|
||||
return renderTable(result, options);
|
||||
},
|
||||
};
|
||||
|
||||
export type ProviderLsResult = ListResult<ProviderListItem>;
|
||||
@@ -60,54 +55,40 @@ export interface ProviderLsOptions extends CommandOptions {
|
||||
host?: string;
|
||||
}
|
||||
|
||||
function authState(provider: RedactedPaseoAgentProviderConfig): string {
|
||||
if (!provider.auth) {
|
||||
return "not configured";
|
||||
}
|
||||
return provider.auth.configured ? "Connected" : "Needs attention";
|
||||
}
|
||||
|
||||
function catalogLabel(catalog: PaseoAgentCatalogEntry[], providerType: string): string {
|
||||
return catalog.find((entry) => entry.id === providerType)?.label ?? providerType;
|
||||
}
|
||||
|
||||
export async function runLsCommand(
|
||||
options: ProviderLsOptions,
|
||||
_command: Command,
|
||||
dependencies: Partial<ProviderLsDependencies> = {},
|
||||
): Promise<ProviderLsResult> {
|
||||
const deps = { ...defaultDependencies, ...dependencies };
|
||||
const client = await deps.connectDaemon({ host: options.host });
|
||||
|
||||
try {
|
||||
await requirePaseoAgentCatalogFeature(client);
|
||||
const catalogResult = await client.getPaseoAgentCatalog();
|
||||
if (catalogResult.error) {
|
||||
throw {
|
||||
code: "PROVIDER_CATALOG_FAILED",
|
||||
message: catalogResult.error,
|
||||
};
|
||||
}
|
||||
const providersResult = await client.getPaseoAgentProviders();
|
||||
if (providersResult.error) {
|
||||
throw {
|
||||
code: "PROVIDER_LIST_FAILED",
|
||||
message: providersResult.error,
|
||||
};
|
||||
}
|
||||
const client = await tryConnectToDaemon({ host: options.host });
|
||||
|
||||
if (!client) {
|
||||
return {
|
||||
type: "list",
|
||||
data: providersResult.providers.map((provider) => ({
|
||||
name: provider.name,
|
||||
providerType: provider.providerType,
|
||||
label: catalogLabel(catalogResult.catalog, provider.providerType),
|
||||
auth: authState(provider),
|
||||
available: provider.available ? "yes" : "no",
|
||||
models: provider.models.map((model) => model.id).join(", ") || "-",
|
||||
data: getStaticProviders(),
|
||||
schema: providerLsSchema,
|
||||
};
|
||||
}
|
||||
|
||||
try {
|
||||
const snapshot = await client.getProvidersSnapshot();
|
||||
return {
|
||||
type: "list",
|
||||
data: snapshot.entries.map((entry) => ({
|
||||
provider: entry.provider,
|
||||
label: entry.label ?? entry.provider,
|
||||
status: entry.status === "ready" ? "available" : entry.status,
|
||||
enabled: !entry.enabled ? "Disabled" : "Enabled",
|
||||
defaultMode: entry.defaultModeId ?? "default",
|
||||
modes: (entry.modes ?? []).map((mode) => mode.label).join(", "),
|
||||
})),
|
||||
schema: providerLsSchema,
|
||||
};
|
||||
} catch {
|
||||
return {
|
||||
type: "list",
|
||||
data: getStaticProviders(),
|
||||
schema: providerLsSchema,
|
||||
};
|
||||
} finally {
|
||||
await client.close().catch(() => {});
|
||||
}
|
||||
|
||||
@@ -1,71 +0,0 @@
|
||||
import type { Command } from "commander";
|
||||
import type { DaemonClient } from "@getpaseo/client/internal/daemon-client";
|
||||
|
||||
import { connectToDaemon } from "../../utils/client.js";
|
||||
import type {
|
||||
CommandError,
|
||||
CommandOptions,
|
||||
OutputSchema,
|
||||
SingleResult,
|
||||
} from "../../output/index.js";
|
||||
import { requirePaseoAgentCatalogFeature } from "./feature.js";
|
||||
|
||||
interface ProviderRmOptions extends CommandOptions {
|
||||
host?: string;
|
||||
}
|
||||
|
||||
interface ProviderRemoveItem {
|
||||
name: string;
|
||||
removed: string;
|
||||
}
|
||||
|
||||
interface ProviderRmClient extends Pick<
|
||||
DaemonClient,
|
||||
"waitForServerInfo" | "removePaseoAgentProvider" | "close"
|
||||
> {}
|
||||
|
||||
export interface ProviderRmDependencies {
|
||||
connectDaemon: (options: { host?: string }) => Promise<ProviderRmClient>;
|
||||
}
|
||||
|
||||
const defaultDependencies: ProviderRmDependencies = {
|
||||
connectDaemon: connectToDaemon,
|
||||
};
|
||||
|
||||
export const providerRemoveSchema: OutputSchema<ProviderRemoveItem> = {
|
||||
idField: "name",
|
||||
columns: [
|
||||
{ header: "NAME", field: "name", width: 20 },
|
||||
{ header: "REMOVED", field: "removed", width: 10 },
|
||||
],
|
||||
};
|
||||
|
||||
export async function runRmCommand(
|
||||
name: string,
|
||||
options: ProviderRmOptions,
|
||||
_command: Command,
|
||||
dependencies: Partial<ProviderRmDependencies> = {},
|
||||
): Promise<SingleResult<ProviderRemoveItem>> {
|
||||
const deps = { ...defaultDependencies, ...dependencies };
|
||||
const client = await deps.connectDaemon({ host: options.host });
|
||||
try {
|
||||
await requirePaseoAgentCatalogFeature(client);
|
||||
const result = await client.removePaseoAgentProvider(name);
|
||||
if (!result.success) {
|
||||
throw {
|
||||
code: "PROVIDER_REMOVE_FAILED",
|
||||
message: result.error ?? "Daemon rejected the provider removal request.",
|
||||
} satisfies CommandError;
|
||||
}
|
||||
return {
|
||||
type: "single",
|
||||
data: {
|
||||
name,
|
||||
removed: result.removed ? "yes" : "no",
|
||||
},
|
||||
schema: providerRemoveSchema,
|
||||
};
|
||||
} finally {
|
||||
await client.close().catch(() => {});
|
||||
}
|
||||
}
|
||||
@@ -28,7 +28,10 @@ export function createScheduleCommand(): Command {
|
||||
"--provider <provider>",
|
||||
"Agent provider, or provider/model (e.g. codex or codex/gpt-5.4)",
|
||||
)
|
||||
.option("--mode <mode>", "Provider-specific mode")
|
||||
.option(
|
||||
"--mode <mode>",
|
||||
"Provider-specific mode (e.g. claude bypassPermissions, opencode build)",
|
||||
)
|
||||
.option("--cwd <path>", "Working directory (default: current; required with --host)")
|
||||
.option("--run-now", "Fire one immediate run on creation (only with --cron)")
|
||||
.option("--no-run-now", "Wait the full interval before the first run (only with --every)")
|
||||
|
||||
@@ -1,81 +0,0 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
|
||||
import { browserOpenCommand, openBrowserUrl } from "./open-browser.js";
|
||||
|
||||
describe("browserOpenCommand", () => {
|
||||
it("opens Windows URLs without cmd.exe shell parsing", () => {
|
||||
const url =
|
||||
"https://auth.openai.com/oauth/authorize?client_id=paseo&state=abc%20123&redirect_uri=http%3A%2F%2F127.0.0.1%3A49152%2Fcallback";
|
||||
|
||||
expect(browserOpenCommand(url, "win32")).toEqual({
|
||||
command: "rundll32.exe",
|
||||
args: ["url.dll,FileProtocolHandler", url],
|
||||
});
|
||||
});
|
||||
|
||||
it("keeps macOS opener behavior unchanged", () => {
|
||||
const url = "https://auth.openai.com/oauth/authorize?client_id=paseo&state=abc";
|
||||
|
||||
expect(browserOpenCommand(url, "darwin")).toEqual({
|
||||
command: "open",
|
||||
args: [url],
|
||||
});
|
||||
});
|
||||
|
||||
it("keeps Linux opener behavior unchanged", () => {
|
||||
const url = "https://auth.openai.com/oauth/authorize?client_id=paseo&state=abc";
|
||||
|
||||
expect(browserOpenCommand(url, "linux")).toEqual({
|
||||
command: "xdg-open",
|
||||
args: [url],
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("openBrowserUrl", () => {
|
||||
it("spawns the resolved opener without launching a real browser", () => {
|
||||
const spawned: Array<{
|
||||
command: string;
|
||||
args: string[];
|
||||
options: { stdio: "ignore"; detached: true };
|
||||
}> = [];
|
||||
const child = {
|
||||
on: () => child,
|
||||
unref: () => {},
|
||||
};
|
||||
|
||||
const opened = openBrowserUrl(
|
||||
"https://auth.openai.com/oauth/authorize?client_id=paseo&state=abc",
|
||||
{
|
||||
platform: "win32",
|
||||
spawn: (command, args, options) => {
|
||||
spawned.push({ command, args, options });
|
||||
return child;
|
||||
},
|
||||
},
|
||||
);
|
||||
|
||||
expect(opened).toBe(true);
|
||||
expect(spawned).toEqual([
|
||||
{
|
||||
command: "rundll32.exe",
|
||||
args: [
|
||||
"url.dll,FileProtocolHandler",
|
||||
"https://auth.openai.com/oauth/authorize?client_id=paseo&state=abc",
|
||||
],
|
||||
options: { stdio: "ignore", detached: true },
|
||||
},
|
||||
]);
|
||||
});
|
||||
|
||||
it("returns false when spawning the opener throws", () => {
|
||||
const opened = openBrowserUrl("https://auth.openai.com/oauth/authorize", {
|
||||
platform: "linux",
|
||||
spawn: () => {
|
||||
throw new Error("spawn failed");
|
||||
},
|
||||
});
|
||||
|
||||
expect(opened).toBe(false);
|
||||
});
|
||||
});
|
||||
@@ -1,50 +0,0 @@
|
||||
import { spawn, type ChildProcessWithoutNullStreams } from "node:child_process";
|
||||
|
||||
interface BrowserOpenCommand {
|
||||
command: string;
|
||||
args: string[];
|
||||
}
|
||||
|
||||
type BrowserOpenSpawn = (
|
||||
command: string,
|
||||
args: string[],
|
||||
options: { stdio: "ignore"; detached: true },
|
||||
) => Pick<ChildProcessWithoutNullStreams, "on" | "unref">;
|
||||
|
||||
interface BrowserOpenDependencies {
|
||||
platform?: NodeJS.Platform;
|
||||
spawn?: BrowserOpenSpawn;
|
||||
}
|
||||
|
||||
/**
|
||||
* Best-effort cross-platform browser opener for CLI OAuth flows. Returns true if the
|
||||
* opener process was spawned, false otherwise. Callers must always print the URL too,
|
||||
* so a failed/headless open still lets the user copy it.
|
||||
*/
|
||||
export function browserOpenCommand(
|
||||
url: string,
|
||||
platform: NodeJS.Platform = process.platform,
|
||||
): BrowserOpenCommand {
|
||||
switch (platform) {
|
||||
case "darwin":
|
||||
return { command: "open", args: [url] };
|
||||
case "win32":
|
||||
return { command: "rundll32.exe", args: ["url.dll,FileProtocolHandler", url] };
|
||||
default:
|
||||
return { command: "xdg-open", args: [url] };
|
||||
}
|
||||
}
|
||||
|
||||
export function openBrowserUrl(url: string, dependencies: BrowserOpenDependencies = {}): boolean {
|
||||
const spawnBrowser = dependencies.spawn ?? spawn;
|
||||
const { command, args } = browserOpenCommand(url, dependencies.platform);
|
||||
|
||||
try {
|
||||
const child = spawnBrowser(command, args, { stdio: "ignore", detached: true });
|
||||
child.on("error", () => {});
|
||||
child.unref();
|
||||
return true;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
@@ -3,18 +3,15 @@
|
||||
/**
|
||||
* Phase 15: Provider Command Tests
|
||||
*
|
||||
* Tests provider commands for configured Paseo Agent model providers and agent
|
||||
* provider model listing. This test uses an isolated daemon to avoid coupling to
|
||||
* a user's long-running daemon.
|
||||
* Tests provider commands for listing providers and models.
|
||||
* Provider ls data is static, while provider models are fetched via daemon integration.
|
||||
* This test uses an isolated daemon to avoid coupling to a user's long-running daemon.
|
||||
*
|
||||
* Tests:
|
||||
* - provider --help shows subcommands
|
||||
* - provider ls on a fresh daemon prints an empty configured-provider table
|
||||
* - provider add stores an API-key model provider without network validation
|
||||
* - repeated provider add updates the same provider instance
|
||||
* - provider add rejects unknown catalog ids with known ids
|
||||
* - provider rm removes a configured model provider
|
||||
* - provider add uses catalog default models when present
|
||||
* - provider ls lists all providers
|
||||
* - provider ls --json outputs valid JSON
|
||||
* - provider ls --quiet outputs provider names only
|
||||
* - provider models claude lists claude models
|
||||
* - provider models codex lists codex models
|
||||
* - provider models opencode lists opencode models
|
||||
@@ -23,7 +20,14 @@
|
||||
*/
|
||||
|
||||
import assert from "node:assert";
|
||||
import { createE2ETestContext } from "./helpers/test-daemon.ts";
|
||||
import { writeFile } from "node:fs/promises";
|
||||
import { join } from "node:path";
|
||||
import {
|
||||
createE2ETestContext,
|
||||
createTempDirs,
|
||||
runPaseoCli,
|
||||
startTestDaemon,
|
||||
} from "./helpers/test-daemon.ts";
|
||||
|
||||
console.log("=== Provider Commands ===\n");
|
||||
|
||||
@@ -34,26 +38,10 @@ interface ProviderModel {
|
||||
}
|
||||
|
||||
interface ProviderListRow {
|
||||
name: string;
|
||||
providerType: string;
|
||||
provider: string;
|
||||
label: string;
|
||||
auth: string;
|
||||
available: string;
|
||||
models: string;
|
||||
}
|
||||
|
||||
interface CliResult {
|
||||
exitCode: number;
|
||||
stdout: string;
|
||||
stderr: string;
|
||||
}
|
||||
|
||||
function assertExitCode(result: CliResult, expected: number, message: string): void {
|
||||
assert.strictEqual(result.exitCode, expected, `${message}\nstderr:\n${result.stderr}`);
|
||||
}
|
||||
|
||||
function assertNonZeroExitCode(result: CliResult, message: string): void {
|
||||
assert.notStrictEqual(result.exitCode, 0, `${message}\nstderr:\n${result.stderr}`);
|
||||
status: string;
|
||||
enabled: string;
|
||||
}
|
||||
|
||||
const EXPECTED_CLAUDE_MODELS = [
|
||||
@@ -143,24 +131,6 @@ async function runProviderModelsJson(provider: string): Promise<ProviderModel[]>
|
||||
return attemptRun(1);
|
||||
}
|
||||
|
||||
function parseProviderListJson(stdout: string): ProviderListRow[] {
|
||||
const data = JSON.parse(stdout.trim()) as ProviderListRow[];
|
||||
assert(Array.isArray(data), "provider ls --json output should be an array");
|
||||
return data;
|
||||
}
|
||||
|
||||
async function getProviderRows(): Promise<ProviderListRow[]> {
|
||||
const result = await ctx.paseo(["provider", "ls", "--json"]);
|
||||
assertExitCode(result, 0, "provider ls --json should exit 0");
|
||||
return parseProviderListJson(result.stdout);
|
||||
}
|
||||
|
||||
function assertProviderTableHeader(stdout: string): void {
|
||||
for (const header of ["NAME", "TYPE", "LABEL", "AUTH", "AVAILABLE", "MODELS"]) {
|
||||
assert(stdout.includes(header), `provider ls table should include ${header}`);
|
||||
}
|
||||
}
|
||||
|
||||
function assertClaudeModels(data: ProviderModel[]): void {
|
||||
assert.strictEqual(
|
||||
data.length,
|
||||
@@ -195,137 +165,144 @@ try {
|
||||
{
|
||||
console.log("Test 1: provider --help shows subcommands");
|
||||
const result = await ctx.paseo(["provider", "--help"]);
|
||||
assertExitCode(result, 0, "provider --help should exit 0");
|
||||
assert.strictEqual(result.exitCode, 0, "provider --help should exit 0");
|
||||
assert(result.stdout.includes("ls"), "help should mention ls");
|
||||
assert(result.stdout.includes("add"), "help should mention add");
|
||||
assert(result.stdout.includes("rm"), "help should mention rm");
|
||||
assert(result.stdout.includes("models"), "help should mention models");
|
||||
console.log("✓ provider --help shows subcommands\n");
|
||||
}
|
||||
|
||||
// Test 2: provider ls on a fresh daemon shows no configured providers
|
||||
// Test 2: provider ls lists all providers
|
||||
{
|
||||
console.log("Test 2: provider ls on a fresh daemon shows no configured providers");
|
||||
console.log("Test 2: provider ls lists all providers");
|
||||
const result = await ctx.paseo(["provider", "ls"]);
|
||||
assertExitCode(result, 0, "provider ls should exit 0");
|
||||
assertProviderTableHeader(result.stdout);
|
||||
assert(!result.stdout.includes("OpenRouter"), "fresh output should have no provider rows");
|
||||
|
||||
const jsonResult = await ctx.paseo(["provider", "ls", "--json"]);
|
||||
assertExitCode(jsonResult, 0, "provider ls --json should exit 0");
|
||||
assert.deepStrictEqual(parseProviderListJson(jsonResult.stdout), []);
|
||||
console.log("✓ provider ls on a fresh daemon shows no configured providers\n");
|
||||
}
|
||||
|
||||
// Test 3: provider add openrouter stores a dummy key without network validation
|
||||
{
|
||||
console.log("Test 3: provider add openrouter stores a dummy key without network validation");
|
||||
const result = await ctx.paseo(["provider", "add", "openrouter", "--api-key-stdin"], {
|
||||
stdin: "dummy-openrouter-key\n",
|
||||
});
|
||||
assertExitCode(result, 0, "provider add should exit 0");
|
||||
assert(result.stdout.includes("openrouter"), "add output should include the instance name");
|
||||
assert(result.stdout.includes("OpenRouter"), "add output should include the catalog label");
|
||||
assert(result.stdout.includes("Connected"), "add output should show connected auth state");
|
||||
assert(result.stdout.includes("yes"), "add output should show the provider as available");
|
||||
|
||||
const rows = await getProviderRows();
|
||||
assert.strictEqual(rows.length, 1, "provider ls should show exactly one configured instance");
|
||||
assert.deepStrictEqual(rows[0], {
|
||||
name: "openrouter",
|
||||
providerType: "openrouter",
|
||||
label: "OpenRouter",
|
||||
auth: "Connected",
|
||||
available: "yes",
|
||||
models: "-",
|
||||
});
|
||||
console.log("✓ provider add openrouter stores a dummy key without network validation\n");
|
||||
}
|
||||
|
||||
// Test 4: provider add is idempotent for the same instance name
|
||||
{
|
||||
console.log("Test 4: provider add is idempotent for the same instance name");
|
||||
const result = await ctx.paseo(["provider", "add", "openrouter", "--api-key-stdin"], {
|
||||
stdin: "dummy-openrouter-key-2\n",
|
||||
});
|
||||
assertExitCode(result, 0, "provider add should exit 0");
|
||||
|
||||
const rows = await getProviderRows();
|
||||
assert.strictEqual(rows.length, 1, "re-running add should not create another instance");
|
||||
assert.strictEqual(rows[0]?.name, "openrouter");
|
||||
assert.strictEqual(rows[0]?.label, "OpenRouter");
|
||||
console.log("✓ provider add is idempotent for the same instance name\n");
|
||||
}
|
||||
|
||||
// Test 5: provider add rejects unknown catalog ids with known ids
|
||||
{
|
||||
console.log("Test 5: provider add rejects unknown catalog ids with known ids");
|
||||
const result = await ctx.paseo(["provider", "add", "nonsense-id", "--api-key-stdin"], {
|
||||
stdin: "dummy-key\n",
|
||||
});
|
||||
assertNonZeroExitCode(result, "provider add should fail for unknown ids");
|
||||
const output = result.stdout + result.stderr;
|
||||
assert(output.includes("nonsense-id"), "error should mention the requested id");
|
||||
assert(output.includes("Known provider ids"), "error should mention known provider ids");
|
||||
assert(output.includes("openrouter"), "known ids should include openrouter");
|
||||
assert(output.includes("kimi"), "known ids should include kimi");
|
||||
console.log("✓ provider add rejects unknown catalog ids with known ids\n");
|
||||
}
|
||||
|
||||
// Test 6: provider rm removes a configured provider
|
||||
{
|
||||
console.log("Test 6: provider rm removes a configured provider");
|
||||
const result = await ctx.paseo(["provider", "rm", "openrouter"]);
|
||||
assertExitCode(result, 0, "provider rm should exit 0");
|
||||
assert(result.stdout.includes("openrouter"), "rm output should include the instance name");
|
||||
assert(result.stdout.includes("yes"), "rm output should report removal");
|
||||
|
||||
const rows = await getProviderRows();
|
||||
assert.deepStrictEqual(rows, [], "provider ls should be empty after removing openrouter");
|
||||
const table = await ctx.paseo(["provider", "ls"]);
|
||||
assertExitCode(table, 0, "provider ls should stay successful after removal");
|
||||
assertProviderTableHeader(table.stdout);
|
||||
console.log("✓ provider rm removes a configured provider\n");
|
||||
}
|
||||
|
||||
// Test 7: provider add uses catalog default models when present
|
||||
{
|
||||
console.log("Test 7: provider add uses catalog default models when present");
|
||||
const result = await ctx.paseo(["provider", "add", "kimi", "--api-key-stdin"], {
|
||||
stdin: "dummy-kimi-key\n",
|
||||
});
|
||||
assertExitCode(result, 0, "provider add kimi should exit 0");
|
||||
assert(result.stdout.includes("kimi"), "add output should include the instance name");
|
||||
assert(result.stdout.includes("Kimi Coding Plan"), "add output should include the label");
|
||||
|
||||
const rows = await getProviderRows();
|
||||
const kimi = rows.find((row) => row.name === "kimi");
|
||||
assert(kimi, "provider ls should include the kimi instance");
|
||||
assert.strictEqual(kimi.label, "Kimi Coding Plan");
|
||||
assert.strictEqual(kimi.auth, "Connected");
|
||||
assert.strictEqual(kimi.available, "yes");
|
||||
assert.notStrictEqual(kimi.models, "-", "kimi should expose catalog-derived default models");
|
||||
assert.strictEqual(result.exitCode, 0, "provider ls should exit 0");
|
||||
assert(result.stdout.includes("claude"), "output should include claude");
|
||||
assert(result.stdout.includes("codex"), "output should include codex");
|
||||
assert(result.stdout.includes("opencode"), "output should include opencode");
|
||||
assert(result.stdout.includes("ENABLED"), "output should include ENABLED column");
|
||||
assert(result.stdout.includes("Enabled"), "output should show enabled providers");
|
||||
assert(
|
||||
kimi.models
|
||||
.split(",")
|
||||
.map((model) => model.trim())
|
||||
.filter(Boolean).length > 0,
|
||||
"kimi should list at least one catalog-derived model id",
|
||||
result.stdout.includes("available") ||
|
||||
result.stdout.includes("loading") ||
|
||||
result.stdout.includes("unavailable"),
|
||||
"output should show a provider status",
|
||||
);
|
||||
console.log("✓ provider add uses catalog default models when present\n");
|
||||
console.log("✓ provider ls lists all providers\n");
|
||||
}
|
||||
|
||||
// Test 8: provider models claude lists canonical model aliases
|
||||
// Test 3: provider ls --json outputs valid JSON
|
||||
{
|
||||
console.log("Test 8: provider models claude lists canonical model aliases");
|
||||
console.log("Test 3: provider ls --json outputs valid JSON");
|
||||
const result = await ctx.paseo(["provider", "ls", "--json"]);
|
||||
assert.strictEqual(result.exitCode, 0, "should exit 0");
|
||||
const data = JSON.parse(result.stdout.trim());
|
||||
assert(Array.isArray(data), "output should be an array");
|
||||
assert(data.length >= 3, `should have at least 3 providers, got ${data.length}`);
|
||||
assert(
|
||||
data.some((p: { provider: string }) => p.provider === "claude"),
|
||||
"should include claude",
|
||||
);
|
||||
assert(
|
||||
data.some((p: { provider: string }) => p.provider === "codex"),
|
||||
"should include codex",
|
||||
);
|
||||
assert(
|
||||
data.some((p: { provider: string }) => p.provider === "opencode"),
|
||||
"should include opencode",
|
||||
);
|
||||
const rows = data as ProviderListRow[];
|
||||
for (const provider of ["claude", "codex", "opencode"] as const) {
|
||||
const row = rows.find((p) => p.provider === provider);
|
||||
assert(row, `should include ${provider}`);
|
||||
assert.strictEqual(row.enabled, "Enabled", `${provider} should report Enabled`);
|
||||
}
|
||||
|
||||
const omp = rows.find((p) => p.provider === "omp");
|
||||
assert(omp, "should include omp");
|
||||
assert.strictEqual(omp.enabled, "Disabled", "omp should report Disabled by default");
|
||||
console.log("✓ provider ls --json outputs valid JSON\n");
|
||||
}
|
||||
|
||||
// Test 4: provider ls includes disabled providers
|
||||
{
|
||||
console.log("Test 4: provider ls includes disabled providers");
|
||||
const { paseoHome, workDir } = await createTempDirs();
|
||||
await writeFile(
|
||||
join(paseoHome, "config.json"),
|
||||
JSON.stringify(
|
||||
{
|
||||
version: 1,
|
||||
agents: {
|
||||
providers: {
|
||||
claude: {
|
||||
enabled: false,
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
null,
|
||||
2,
|
||||
) + "\n",
|
||||
);
|
||||
|
||||
const disabledCtx = await startTestDaemon({ paseoHome, workDir, timeout: 120000 });
|
||||
try {
|
||||
const result = await runPaseoCli(disabledCtx, ["provider", "ls", "--json"]);
|
||||
assert.strictEqual(result.exitCode, 0, "provider ls should exit 0");
|
||||
const data = JSON.parse(result.stdout.trim()) as ProviderListRow[];
|
||||
const claude = data.find((p) => p.provider === "claude");
|
||||
assert(claude, "disabled claude provider should stay in provider ls");
|
||||
assert.strictEqual(claude.enabled, "Disabled", "disabled provider should report Disabled");
|
||||
|
||||
const opencode = data.find((p) => p.provider === "opencode");
|
||||
assert(opencode, "enabled opencode provider should stay in provider ls");
|
||||
assert.strictEqual(opencode.enabled, "Enabled", "enabled provider should report Enabled");
|
||||
|
||||
const modelsResult = await runPaseoCli(disabledCtx, ["provider", "models", "claude"]);
|
||||
assert.notStrictEqual(
|
||||
modelsResult.exitCode,
|
||||
0,
|
||||
"provider models should fail for disabled providers",
|
||||
);
|
||||
const output = modelsResult.stdout + modelsResult.stderr;
|
||||
assert(
|
||||
output.includes("Provider claude is disabled"),
|
||||
"provider models should surface the daemon disabled error",
|
||||
);
|
||||
assert(
|
||||
!output.includes("claude-sonnet"),
|
||||
"provider models should not print fallback models for disabled providers",
|
||||
);
|
||||
} finally {
|
||||
await disabledCtx.stop();
|
||||
}
|
||||
console.log("✓ provider ls includes disabled providers\n");
|
||||
}
|
||||
|
||||
// Test 5: provider ls --quiet outputs provider names only
|
||||
{
|
||||
console.log("Test 5: provider ls --quiet outputs provider names only");
|
||||
const result = await ctx.paseo(["provider", "ls", "--quiet"]);
|
||||
assert.strictEqual(result.exitCode, 0, "should exit 0");
|
||||
const lines = result.stdout.trim().split("\n");
|
||||
assert(lines.length >= 3, `should have at least 3 lines, got ${lines.length}`);
|
||||
assert(lines.includes("claude"), "should include claude");
|
||||
assert(lines.includes("codex"), "should include codex");
|
||||
assert(lines.includes("opencode"), "should include opencode");
|
||||
console.log("✓ provider ls --quiet outputs provider names only\n");
|
||||
}
|
||||
|
||||
// Test 6: provider models claude lists canonical model aliases
|
||||
{
|
||||
console.log("Test 6: provider models claude lists canonical model aliases");
|
||||
const data = await runProviderModelsJson("claude");
|
||||
assertClaudeModels(data);
|
||||
console.log("✓ provider models claude lists canonical model aliases\n");
|
||||
}
|
||||
|
||||
// Test 9: provider models codex includes concrete codex model IDs
|
||||
// Test 7: provider models codex includes concrete codex model IDs
|
||||
{
|
||||
console.log("Test 9: provider models codex includes concrete codex model IDs");
|
||||
console.log("Test 7: provider models codex includes concrete codex model IDs");
|
||||
const data = await runProviderModelsJson("codex");
|
||||
assert(data.length >= 1, "codex model list should not be empty");
|
||||
const ids = data.map((m) => m.id);
|
||||
@@ -345,9 +322,9 @@ try {
|
||||
console.log("✓ provider models codex includes concrete codex model IDs\n");
|
||||
}
|
||||
|
||||
// Test 10: provider models opencode returns namespaced model IDs
|
||||
// Test 8: provider models opencode returns namespaced model IDs
|
||||
{
|
||||
console.log("Test 10: provider models opencode returns namespaced model IDs");
|
||||
console.log("Test 8: provider models opencode returns namespaced model IDs");
|
||||
const data = await runProviderModelsJson("opencode");
|
||||
assert(data.length >= 1, "opencode model list should not be empty");
|
||||
const ids = data.map((m) => m.id);
|
||||
@@ -366,11 +343,11 @@ try {
|
||||
console.log("✓ provider models opencode returns namespaced model IDs\n");
|
||||
}
|
||||
|
||||
// Test 11: provider models unknown fails with error
|
||||
// Test 9: provider models unknown fails with error
|
||||
{
|
||||
console.log("Test 11: provider models unknown fails with error");
|
||||
console.log("Test 9: provider models unknown fails with error");
|
||||
const result = await ctx.paseo(["provider", "models", "unknown"]);
|
||||
assertNonZeroExitCode(result, "should fail for unknown provider");
|
||||
assert.notStrictEqual(result.exitCode, 0, "should fail for unknown provider");
|
||||
const output = result.stdout + result.stderr;
|
||||
assert(
|
||||
output.toLowerCase().includes("unknown") || output.toLowerCase().includes("provider"),
|
||||
@@ -379,9 +356,9 @@ try {
|
||||
console.log("✓ provider models unknown fails with error\n");
|
||||
}
|
||||
|
||||
// Test 12: provider models --json outputs valid JSON
|
||||
// Test 10: provider models --json outputs valid JSON
|
||||
{
|
||||
console.log("Test 12: provider models --json outputs valid JSON");
|
||||
console.log("Test 10: provider models --json outputs valid JSON");
|
||||
const data = await runProviderModelsJson("claude");
|
||||
assert(Array.isArray(data), "output should be an array");
|
||||
assert(
|
||||
@@ -394,15 +371,15 @@ try {
|
||||
console.log("✓ provider models --json outputs valid JSON\n");
|
||||
}
|
||||
|
||||
// Test 13: provider models --quiet outputs model IDs only
|
||||
// Test 11: provider models --quiet outputs model IDs only
|
||||
{
|
||||
console.log("Test 13: provider models --quiet outputs model IDs only");
|
||||
console.log("Test 11: provider models --quiet outputs model IDs only");
|
||||
assert(
|
||||
claudeModelIdsFromJson.length > 0,
|
||||
"claude model IDs should be captured from --json output",
|
||||
);
|
||||
const result = await ctx.paseo(["provider", "models", "claude", "--quiet"]);
|
||||
assertExitCode(result, 0, "provider models claude --quiet should exit 0");
|
||||
assert.strictEqual(result.exitCode, 0, "should exit 0");
|
||||
const lines = result.stdout.trim().split("\n").filter(Boolean);
|
||||
assert.strictEqual(
|
||||
lines.length,
|
||||
|
||||
@@ -43,13 +43,6 @@ const TEST_DAEMON_ENV_DEFAULTS: Record<string, string> = {
|
||||
PASEO_VOICE_MODE_ENABLED: process.env.PASEO_VOICE_MODE_ENABLED ?? "0",
|
||||
};
|
||||
const TEST_DAEMON_HOST = "127.0.0.1";
|
||||
// Keep in sync with catalog.ts api_key auth envVar hints. These are scrubbed so
|
||||
// local developer credentials cannot hide CI-missing-auth failures.
|
||||
const PASEO_AGENT_PROVIDER_AUTH_ENV_KEYS = [
|
||||
"OPENROUTER_API_KEY",
|
||||
"KIMI_API_KEY",
|
||||
"OPENCODE_API_KEY",
|
||||
] as const;
|
||||
|
||||
const DEFAULT_OUTPUT_CAPTURE_LIMIT = 256 * 1024;
|
||||
const TEST_OUTPUT_CAPTURE_LIMIT = Number.parseInt(
|
||||
@@ -66,14 +59,6 @@ function createOutputCapture(): OutputCapture {
|
||||
return { value: "", truncated: false };
|
||||
}
|
||||
|
||||
function scrubPaseoAgentProviderAuthEnv(env: NodeJS.ProcessEnv): NodeJS.ProcessEnv {
|
||||
const scrubbed = { ...env };
|
||||
for (const key of PASEO_AGENT_PROVIDER_AUTH_ENV_KEYS) {
|
||||
delete scrubbed[key];
|
||||
}
|
||||
return scrubbed;
|
||||
}
|
||||
|
||||
function appendOutputCapture(target: OutputCapture, chunk: Buffer): void {
|
||||
const next = target.value + chunk.toString();
|
||||
if (next.length <= TEST_OUTPUT_CAPTURE_LIMIT) {
|
||||
@@ -249,7 +234,7 @@ export async function startTestDaemon(options?: {
|
||||
|
||||
// Start daemon process using tsx to run TypeScript directly
|
||||
const daemonProcess = spawn("npx", ["tsx", cliSrcPath, "daemon", "start", "--foreground"], {
|
||||
env: scrubPaseoAgentProviderAuthEnv({
|
||||
env: {
|
||||
...process.env,
|
||||
...TEST_DAEMON_ENV_DEFAULTS,
|
||||
PASEO_HOME: paseoHome,
|
||||
@@ -257,7 +242,7 @@ export async function startTestDaemon(options?: {
|
||||
// Force no TTY to prevent QR code output
|
||||
CI: "true",
|
||||
...options?.env,
|
||||
}),
|
||||
},
|
||||
stdio: ["ignore", "pipe", "pipe"],
|
||||
detached: process.platform !== "win32",
|
||||
});
|
||||
@@ -351,7 +336,6 @@ export async function runPaseoCli(
|
||||
timeout?: number;
|
||||
cwd?: string;
|
||||
env?: NodeJS.ProcessEnv;
|
||||
stdin?: string;
|
||||
},
|
||||
): Promise<{ exitCode: number; stdout: string; stderr: string }> {
|
||||
const timeout = options?.timeout ?? 60000;
|
||||
@@ -362,15 +346,15 @@ export async function runPaseoCli(
|
||||
|
||||
return new Promise((resolve, reject) => {
|
||||
const proc = spawn("npx", ["tsx", cliSrcPath, ...args], {
|
||||
env: scrubPaseoAgentProviderAuthEnv({
|
||||
env: {
|
||||
...process.env,
|
||||
...TEST_DAEMON_ENV_DEFAULTS,
|
||||
PASEO_HOST: `${TEST_DAEMON_HOST}:${ctx.port}`,
|
||||
PASEO_HOME: ctx.paseoHome,
|
||||
...options?.env,
|
||||
}),
|
||||
},
|
||||
cwd,
|
||||
stdio: [options?.stdin === undefined ? "ignore" : "pipe", "pipe", "pipe"],
|
||||
stdio: ["ignore", "pipe", "pipe"],
|
||||
detached: process.platform !== "win32",
|
||||
});
|
||||
|
||||
@@ -385,10 +369,6 @@ export async function runPaseoCli(
|
||||
appendOutputCapture(stderr, data);
|
||||
});
|
||||
|
||||
if (options?.stdin !== undefined) {
|
||||
proc.stdin?.end(options.stdin);
|
||||
}
|
||||
|
||||
const timeoutId = setTimeout(() => {
|
||||
if (proc.pid) {
|
||||
signalProcessTree(proc.pid, "SIGKILL");
|
||||
@@ -426,7 +406,7 @@ export async function createE2ETestContext(options?: {
|
||||
/** Run a paseo CLI command against this daemon */
|
||||
paseo: (
|
||||
args: string[],
|
||||
opts?: { timeout?: number; cwd?: string; env?: NodeJS.ProcessEnv; stdin?: string },
|
||||
opts?: { timeout?: number; cwd?: string; env?: NodeJS.ProcessEnv },
|
||||
) => Promise<{
|
||||
exitCode: number;
|
||||
stdout: string;
|
||||
@@ -438,7 +418,7 @@ export async function createE2ETestContext(options?: {
|
||||
|
||||
const paseo = (
|
||||
args: string[],
|
||||
opts?: { timeout?: number; cwd?: string; env?: NodeJS.ProcessEnv; stdin?: string },
|
||||
opts?: { timeout?: number; cwd?: string; env?: NodeJS.ProcessEnv },
|
||||
) => runPaseoCli(ctx, args, opts);
|
||||
|
||||
return {
|
||||
|
||||
@@ -19,44 +19,6 @@ const TEST_ENV_DEFAULTS = {
|
||||
PASEO_DICTATION_ENABLED: process.env.PASEO_DICTATION_ENABLED ?? "0",
|
||||
PASEO_VOICE_MODE_ENABLED: process.env.PASEO_VOICE_MODE_ENABLED ?? "0",
|
||||
};
|
||||
// Keep in sync with catalog.ts api_key auth envVar hints. These are scrubbed so
|
||||
// local developer credentials cannot hide CI-missing-auth failures.
|
||||
const PASEO_AGENT_PROVIDER_AUTH_ENV_KEYS = [
|
||||
"OPENROUTER_API_KEY",
|
||||
"KIMI_API_KEY",
|
||||
"OPENCODE_API_KEY",
|
||||
] as const;
|
||||
|
||||
function scrubPaseoAgentProviderAuthEnv(env: NodeJS.ProcessEnv): NodeJS.ProcessEnv {
|
||||
const scrubbed = { ...env };
|
||||
for (const key of PASEO_AGENT_PROVIDER_AUTH_ENV_KEYS) {
|
||||
delete scrubbed[key];
|
||||
}
|
||||
return scrubbed;
|
||||
}
|
||||
|
||||
function testCliEnv(port: number): NodeJS.ProcessEnv {
|
||||
return scrubPaseoAgentProviderAuthEnv({
|
||||
...process.env,
|
||||
PASEO_HOST: `localhost:${port}`,
|
||||
PASEO_LOCAL_SPEECH_AUTO_DOWNLOAD: TEST_ENV_DEFAULTS.PASEO_LOCAL_SPEECH_AUTO_DOWNLOAD,
|
||||
PASEO_DICTATION_ENABLED: TEST_ENV_DEFAULTS.PASEO_DICTATION_ENABLED,
|
||||
PASEO_VOICE_MODE_ENABLED: TEST_ENV_DEFAULTS.PASEO_VOICE_MODE_ENABLED,
|
||||
});
|
||||
}
|
||||
|
||||
function testDaemonEnv(port: number, paseoHome: string): NodeJS.ProcessEnv {
|
||||
return scrubPaseoAgentProviderAuthEnv({
|
||||
...process.env,
|
||||
PASEO_HOME: paseoHome,
|
||||
PASEO_LISTEN: `127.0.0.1:${port}`,
|
||||
PASEO_RELAY_ENABLED: "false",
|
||||
PASEO_LOCAL_SPEECH_AUTO_DOWNLOAD: TEST_ENV_DEFAULTS.PASEO_LOCAL_SPEECH_AUTO_DOWNLOAD,
|
||||
PASEO_DICTATION_ENABLED: TEST_ENV_DEFAULTS.PASEO_DICTATION_ENABLED,
|
||||
PASEO_VOICE_MODE_ENABLED: TEST_ENV_DEFAULTS.PASEO_VOICE_MODE_ENABLED,
|
||||
CI: "true",
|
||||
});
|
||||
}
|
||||
|
||||
function killPidTree(pid: number, signal: NodeJS.Signals): void {
|
||||
if (!Number.isInteger(pid) || pid <= 0) {
|
||||
@@ -123,7 +85,7 @@ export async function createTempDirs(): Promise<{ paseoHome: string; workDir: st
|
||||
*/
|
||||
async function probeDaemon(port: number): Promise<boolean> {
|
||||
try {
|
||||
const result = await $({ env: testCliEnv(port) })`paseo agent ls`.nothrow();
|
||||
const result = await $`PASEO_HOST=localhost:${port} paseo agent ls`.nothrow();
|
||||
return result.exitCode === 0;
|
||||
} catch {
|
||||
return false;
|
||||
@@ -148,9 +110,8 @@ export async function waitForDaemon(port: number, timeout = 30000): Promise<void
|
||||
*/
|
||||
export async function startDaemon(port: number, paseoHome: string): Promise<ProcessPromise> {
|
||||
$.verbose = false;
|
||||
const daemon = $({
|
||||
env: testDaemonEnv(port, paseoHome),
|
||||
})`paseo daemon start --foreground`.nothrow();
|
||||
const daemon =
|
||||
$`PASEO_HOME=${paseoHome} PASEO_LISTEN=127.0.0.1:${port} PASEO_RELAY_ENABLED=false PASEO_LOCAL_SPEECH_AUTO_DOWNLOAD=${TEST_ENV_DEFAULTS.PASEO_LOCAL_SPEECH_AUTO_DOWNLOAD} PASEO_DICTATION_ENABLED=${TEST_ENV_DEFAULTS.PASEO_DICTATION_ENABLED} PASEO_VOICE_MODE_ENABLED=${TEST_ENV_DEFAULTS.PASEO_VOICE_MODE_ENABLED} CI=true paseo daemon start --foreground`.nothrow();
|
||||
return daemon;
|
||||
}
|
||||
|
||||
@@ -164,7 +125,7 @@ export async function createTestContext(): Promise<TestContext> {
|
||||
// Helper to run CLI commands against test daemon
|
||||
const paseo = (args: string[]): ProcessPromise => {
|
||||
$.verbose = false;
|
||||
return $({ env: testCliEnv(port) })`paseo ${args}`.nothrow();
|
||||
return $`PASEO_HOST=localhost:${port} PASEO_LOCAL_SPEECH_AUTO_DOWNLOAD=${TEST_ENV_DEFAULTS.PASEO_LOCAL_SPEECH_AUTO_DOWNLOAD} PASEO_DICTATION_ENABLED=${TEST_ENV_DEFAULTS.PASEO_DICTATION_ENABLED} PASEO_VOICE_MODE_ENABLED=${TEST_ENV_DEFAULTS.PASEO_VOICE_MODE_ENABLED} paseo ${args}`.nothrow();
|
||||
};
|
||||
|
||||
// Cleanup function
|
||||
|
||||
@@ -75,30 +75,6 @@ function createMockTransport() {
|
||||
return {
|
||||
transport,
|
||||
sent,
|
||||
sendServerInfo: (input: { serverId?: string; features?: Record<string, boolean> } = {}) => {
|
||||
onMessage(
|
||||
JSON.stringify({
|
||||
type: "session",
|
||||
message: {
|
||||
type: "status",
|
||||
payload: {
|
||||
status: "server_info",
|
||||
serverId: input.serverId ?? `srv_test_${serverInfoOrdinal++}`,
|
||||
hostname: null,
|
||||
version: null,
|
||||
...(input.features ? { features: input.features } : {}),
|
||||
},
|
||||
},
|
||||
}),
|
||||
);
|
||||
},
|
||||
triggerOpenWithoutServerInfo: (options?: { preserveSent?: boolean }) => {
|
||||
onOpen();
|
||||
if (!options?.preserveSent) {
|
||||
// Ignore HELLO handshake payloads in assertions.
|
||||
sent.length = 0;
|
||||
}
|
||||
},
|
||||
triggerOpen: (options?: { preserveSent?: boolean }) => {
|
||||
onOpen();
|
||||
if (!options?.preserveSent) {
|
||||
@@ -215,57 +191,6 @@ test("advertises consumer-provided browser automation capabilities", async () =>
|
||||
expect(hello.capabilities[CLIENT_CAPS.desktopBrowserAutomation]).toBe(true);
|
||||
});
|
||||
|
||||
test("waitForServerInfo resolves when server_info arrives after the socket opens", async () => {
|
||||
vi.useFakeTimers();
|
||||
const mock = createMockTransport();
|
||||
const client = new DaemonClient({
|
||||
url: "ws://test",
|
||||
clientId: "server_info_wait_unit_test",
|
||||
transportFactory: () => mock.transport,
|
||||
reconnect: { enabled: false },
|
||||
});
|
||||
clients.push(client);
|
||||
|
||||
const serverInfoPromise = client.waitForServerInfo(100);
|
||||
const connectPromise = client.connect();
|
||||
mock.triggerOpenWithoutServerInfo();
|
||||
setTimeout(() => {
|
||||
mock.sendServerInfo({
|
||||
serverId: "srv_delayed_server_info",
|
||||
features: { paseoAgentCatalog: true },
|
||||
});
|
||||
}, 25);
|
||||
|
||||
await vi.advanceTimersByTimeAsync(25);
|
||||
|
||||
await expect(serverInfoPromise).resolves.toMatchObject({
|
||||
status: "server_info",
|
||||
serverId: "srv_delayed_server_info",
|
||||
features: { paseoAgentCatalog: true },
|
||||
});
|
||||
await connectPromise;
|
||||
});
|
||||
|
||||
test("waitForServerInfo rejects clearly when server_info never arrives", async () => {
|
||||
vi.useFakeTimers();
|
||||
const mock = createMockTransport();
|
||||
const client = new DaemonClient({
|
||||
url: "ws://test",
|
||||
clientId: "server_info_timeout_unit_test",
|
||||
transportFactory: () => mock.transport,
|
||||
reconnect: { enabled: false },
|
||||
});
|
||||
clients.push(client);
|
||||
|
||||
const serverInfoPromise = client.waitForServerInfo(50);
|
||||
const rejection = expect(serverInfoPromise).rejects.toThrow(
|
||||
"Timed out waiting for server_info status message (50ms)",
|
||||
);
|
||||
|
||||
await vi.advanceTimersByTimeAsync(50);
|
||||
await rejection;
|
||||
});
|
||||
|
||||
const noopLogger: Logger = {
|
||||
debug: () => {},
|
||||
info: () => {},
|
||||
|
||||
@@ -94,21 +94,7 @@ import type {
|
||||
AgentProvider,
|
||||
AgentSessionConfig,
|
||||
} from "@getpaseo/protocol/agent-types";
|
||||
import type {
|
||||
MutableDaemonConfig,
|
||||
MutableDaemonConfigPatch,
|
||||
PaseoAgentGetCatalogResponse,
|
||||
PaseoAgentGetProvidersResponse,
|
||||
PaseoAgentOAuthCompleteResponse,
|
||||
PaseoAgentOAuthCredential,
|
||||
PaseoAgentOAuthStartResponse,
|
||||
PaseoAgentOAuthStoreCredentialResponse,
|
||||
PaseoAgentRenameProviderRequest,
|
||||
PaseoAgentRenameProviderResponse,
|
||||
PaseoAgentRemoveProviderResponse,
|
||||
PaseoAgentSetProviderRequest,
|
||||
PaseoAgentSetProviderResponse,
|
||||
} from "@getpaseo/protocol/messages";
|
||||
import type { MutableDaemonConfig, MutableDaemonConfigPatch } from "@getpaseo/protocol/messages";
|
||||
import { isRelayClientWebSocketUrl } from "@getpaseo/protocol/daemon-endpoints";
|
||||
import { terminalSubscriptionKey } from "@getpaseo/protocol/terminal-subscription-key";
|
||||
import {
|
||||
@@ -842,7 +828,6 @@ const DEFAULT_RECONNECT_BASE_DELAY_MS = 1500;
|
||||
const DEFAULT_RECONNECT_MAX_DELAY_MS = 30000;
|
||||
const DEFAULT_SESSION_RPC_TIMEOUT_MS = 60_000;
|
||||
const DEFAULT_CONNECT_TIMEOUT_MS = 15_000;
|
||||
const DEFAULT_SERVER_INFO_TIMEOUT_MS = 10_000;
|
||||
const DEFAULT_LIVENESS_TIMEOUT_MS = 5000;
|
||||
const LIVENESS_HEARTBEAT_INTERVAL_MS = 10_000;
|
||||
const LIVENESS_HEARTBEAT_TIMEOUT_MS = 15_000;
|
||||
@@ -3876,119 +3861,6 @@ export class DaemonClient {
|
||||
this.sendSessionMessageStrict(response);
|
||||
}
|
||||
|
||||
async getPaseoAgentProviders(
|
||||
requestId?: string,
|
||||
): Promise<PaseoAgentGetProvidersResponse["payload"]> {
|
||||
return this.sendNamespacedCorrelatedSessionRequest({
|
||||
requestId,
|
||||
message: {
|
||||
type: "config.paseo_agent.get_providers.request",
|
||||
},
|
||||
timeout: 10000,
|
||||
});
|
||||
}
|
||||
|
||||
async getPaseoAgentCatalog(requestId?: string): Promise<PaseoAgentGetCatalogResponse["payload"]> {
|
||||
return this.sendNamespacedCorrelatedSessionRequest({
|
||||
requestId,
|
||||
message: {
|
||||
type: "config.paseo_agent.get_catalog.request",
|
||||
},
|
||||
timeout: 10000,
|
||||
});
|
||||
}
|
||||
|
||||
async setPaseoAgentProvider(
|
||||
input: Omit<PaseoAgentSetProviderRequest, "type" | "requestId"> & { requestId?: string },
|
||||
): Promise<PaseoAgentSetProviderResponse["payload"]> {
|
||||
return this.sendNamespacedCorrelatedSessionRequest({
|
||||
requestId: input.requestId,
|
||||
message: {
|
||||
type: "config.paseo_agent.set_provider.request",
|
||||
name: input.name,
|
||||
providerType: input.providerType,
|
||||
...(input.displayName ? { displayName: input.displayName } : {}),
|
||||
options: input.options,
|
||||
},
|
||||
timeout: 30000,
|
||||
});
|
||||
}
|
||||
|
||||
async removePaseoAgentProvider(
|
||||
name: string,
|
||||
requestId?: string,
|
||||
): Promise<PaseoAgentRemoveProviderResponse["payload"]> {
|
||||
return this.sendNamespacedCorrelatedSessionRequest({
|
||||
requestId,
|
||||
message: {
|
||||
type: "config.paseo_agent.remove_provider.request",
|
||||
name,
|
||||
},
|
||||
timeout: 30000,
|
||||
});
|
||||
}
|
||||
|
||||
async renamePaseoAgentProvider(
|
||||
input: Omit<PaseoAgentRenameProviderRequest, "type" | "requestId"> & { requestId?: string },
|
||||
): Promise<PaseoAgentRenameProviderResponse["payload"]> {
|
||||
return this.sendNamespacedCorrelatedSessionRequest({
|
||||
requestId: input.requestId,
|
||||
message: {
|
||||
type: "config.paseo_agent.rename_provider.request",
|
||||
name: input.name,
|
||||
displayName: input.displayName,
|
||||
},
|
||||
timeout: 30000,
|
||||
});
|
||||
}
|
||||
|
||||
async startPaseoAgentOAuth(
|
||||
name: string,
|
||||
options?: string | { mode?: string; requestId?: string },
|
||||
): Promise<PaseoAgentOAuthStartResponse["payload"]> {
|
||||
const requestId = typeof options === "string" ? options : options?.requestId;
|
||||
const mode = typeof options === "string" ? undefined : options?.mode;
|
||||
return this.sendNamespacedCorrelatedSessionRequest({
|
||||
requestId,
|
||||
message: {
|
||||
type: "config.paseo_agent.oauth.start.request",
|
||||
name,
|
||||
...(mode ? { mode } : {}),
|
||||
},
|
||||
timeout: 30000,
|
||||
});
|
||||
}
|
||||
|
||||
async completePaseoAgentOAuth(
|
||||
name: string,
|
||||
requestId?: string,
|
||||
): Promise<PaseoAgentOAuthCompleteResponse["payload"]> {
|
||||
return this.sendNamespacedCorrelatedSessionRequest({
|
||||
requestId,
|
||||
message: {
|
||||
type: "config.paseo_agent.oauth.complete.request",
|
||||
name,
|
||||
},
|
||||
timeout: 30000,
|
||||
});
|
||||
}
|
||||
|
||||
async storePaseoAgentOAuthCredential(input: {
|
||||
name: string;
|
||||
credential: PaseoAgentOAuthCredential;
|
||||
requestId?: string;
|
||||
}): Promise<PaseoAgentOAuthStoreCredentialResponse["payload"]> {
|
||||
return this.sendNamespacedCorrelatedSessionRequest({
|
||||
requestId: input.requestId,
|
||||
message: {
|
||||
type: "config.paseo_agent.oauth.store_credential.request",
|
||||
name: input.name,
|
||||
credential: input.credential,
|
||||
},
|
||||
timeout: 30000,
|
||||
});
|
||||
}
|
||||
|
||||
async readProjectConfig(repoRoot: string, requestId?: string): Promise<ReadProjectConfigPayload> {
|
||||
return this.sendCorrelatedSessionRequest({
|
||||
requestId,
|
||||
@@ -4762,29 +4634,6 @@ export class DaemonClient {
|
||||
return this.lastServerInfoMessage;
|
||||
}
|
||||
|
||||
async waitForServerInfo(
|
||||
timeoutMs = DEFAULT_SERVER_INFO_TIMEOUT_MS,
|
||||
): Promise<ServerInfoStatusPayload> {
|
||||
if (this.lastServerInfoMessage) {
|
||||
return this.lastServerInfoMessage;
|
||||
}
|
||||
|
||||
const { promise } = this.waitForWithCancel<ServerInfoStatusPayload>(
|
||||
(msg) => {
|
||||
if (msg.type !== "status") {
|
||||
return null;
|
||||
}
|
||||
return parseServerInfoStatusPayload(msg.payload);
|
||||
},
|
||||
timeoutMs,
|
||||
{
|
||||
timeoutMessage: `Timed out waiting for server_info status message (${timeoutMs}ms)`,
|
||||
},
|
||||
);
|
||||
|
||||
return promise;
|
||||
}
|
||||
|
||||
private resolveTransportUrlForAttempt(): string {
|
||||
return this.config.url;
|
||||
}
|
||||
@@ -5302,12 +5151,10 @@ export class DaemonClient {
|
||||
private waitForWithCancel<T>(
|
||||
predicate: (msg: SessionOutboundMessage) => T | null,
|
||||
timeout = 30000,
|
||||
options?: { skipQueue?: boolean; timeoutMessage?: string },
|
||||
_options?: { skipQueue?: boolean },
|
||||
): WaitHandle<T> {
|
||||
// Capture stack trace at call site, not inside setTimeout
|
||||
const timeoutError = new Error(
|
||||
options?.timeoutMessage ?? `Timeout waiting for message (${timeout}ms)`,
|
||||
);
|
||||
const timeoutError = new Error(`Timeout waiting for message (${timeout}ms)`);
|
||||
|
||||
let waiter: Waiter<T> | null = null;
|
||||
let settled = false;
|
||||
|
||||
@@ -1,274 +0,0 @@
|
||||
import { describe, expect, test } from "vitest";
|
||||
|
||||
import { SessionInboundMessageSchema, SessionOutboundMessageSchema } from "./messages.js";
|
||||
|
||||
describe("Paseo Agent config RPC schemas", () => {
|
||||
test("parses provider config requests with providerType outside the message type field", () => {
|
||||
const parsed = SessionInboundMessageSchema.parse({
|
||||
type: "config.paseo_agent.set_provider.request",
|
||||
requestId: "req-set-openrouter",
|
||||
name: "openrouter-main",
|
||||
providerType: "openrouter",
|
||||
options: {
|
||||
apiKey: "sk-test",
|
||||
models: [{ id: "anthropic/claude-3.7-sonnet", reasoning: true }],
|
||||
},
|
||||
});
|
||||
|
||||
expect(parsed.type).toBe("config.paseo_agent.set_provider.request");
|
||||
expect(parsed.providerType).toBe("openrouter");
|
||||
});
|
||||
|
||||
test("parses provider config requests without model overrides", () => {
|
||||
const parsed = SessionInboundMessageSchema.parse({
|
||||
type: "config.paseo_agent.set_provider.request",
|
||||
requestId: "req-set-openrouter",
|
||||
name: "openrouter",
|
||||
providerType: "openrouter",
|
||||
options: {
|
||||
apiKey: "sk-test",
|
||||
},
|
||||
});
|
||||
|
||||
expect(parsed.options.models).toBeUndefined();
|
||||
});
|
||||
|
||||
test("parses a provider type this client has never heard of (new daemon, old client)", () => {
|
||||
const parsed = SessionOutboundMessageSchema.parse({
|
||||
type: "config.paseo_agent.get_providers.response",
|
||||
payload: {
|
||||
requestId: "req-get-future",
|
||||
defaultModel: null,
|
||||
providers: [
|
||||
{
|
||||
name: "kimi-main",
|
||||
providerType: "kimi-coding",
|
||||
models: [{ id: "kimi-k3" }],
|
||||
auth: { kind: "future_auth_kind", configured: true, source: "future_source" },
|
||||
available: true,
|
||||
error: null,
|
||||
},
|
||||
],
|
||||
error: null,
|
||||
},
|
||||
});
|
||||
|
||||
expect(parsed.payload.providers[0]?.providerType).toBe("kimi-coding");
|
||||
});
|
||||
|
||||
test("parses redacted provider responses without raw secret fields", () => {
|
||||
const parsed = SessionOutboundMessageSchema.parse({
|
||||
type: "config.paseo_agent.get_providers.response",
|
||||
payload: {
|
||||
requestId: "req-get",
|
||||
defaultModel: "openrouter-main/anthropic/claude-3.7-sonnet",
|
||||
providers: [
|
||||
{
|
||||
name: "openrouter-main",
|
||||
providerType: "openrouter",
|
||||
baseUrl: "https://openrouter.ai/api/v1",
|
||||
api: "openai-completions",
|
||||
models: [{ id: "anthropic/claude-3.7-sonnet" }],
|
||||
auth: { kind: "api_key", configured: true, source: "literal" },
|
||||
available: true,
|
||||
error: null,
|
||||
},
|
||||
],
|
||||
error: null,
|
||||
},
|
||||
});
|
||||
|
||||
expect(parsed.payload.providers[0]?.providerType).toBe("openrouter");
|
||||
expect(JSON.stringify(parsed)).not.toContain("apiKey");
|
||||
});
|
||||
|
||||
test("parses get_catalog request and response with forward-tolerant entries", () => {
|
||||
const request = SessionInboundMessageSchema.parse({
|
||||
type: "config.paseo_agent.get_catalog.request",
|
||||
requestId: "req-catalog",
|
||||
});
|
||||
const response = SessionOutboundMessageSchema.parse({
|
||||
type: "config.paseo_agent.get_catalog.response",
|
||||
payload: {
|
||||
requestId: "req-catalog",
|
||||
catalog: [
|
||||
{
|
||||
id: "future-provider",
|
||||
label: "Future Provider",
|
||||
iconName: "sparkles",
|
||||
docsUrl: "https://docs.example.test/provider",
|
||||
api: "future-api",
|
||||
baseUrl: "https://api.example.test",
|
||||
headers: { "User-Agent": "PaseoTest/1" },
|
||||
compat: { minHost: "0.1.104" },
|
||||
auth: { kind: "future_oauth", flow: "future-flow", extraAuthField: true },
|
||||
models: [
|
||||
{
|
||||
id: "future-model",
|
||||
label: "Future Model",
|
||||
futureModelField: "kept",
|
||||
},
|
||||
],
|
||||
futureEntryField: { keep: true },
|
||||
},
|
||||
],
|
||||
error: null,
|
||||
},
|
||||
});
|
||||
|
||||
expect(request.type).toBe("config.paseo_agent.get_catalog.request");
|
||||
expect(response.payload.catalog[0]?.auth.kind).toBe("future_oauth");
|
||||
expect(response.payload.catalog[0]?.futureEntryField).toEqual({ keep: true });
|
||||
expect(response.payload.catalog[0]?.models[0]?.futureModelField).toBe("kept");
|
||||
});
|
||||
|
||||
test("parses oauth.start request and device-code response", () => {
|
||||
const request = SessionInboundMessageSchema.parse({
|
||||
type: "config.paseo_agent.oauth.start.request",
|
||||
requestId: "req-oauth-start",
|
||||
name: "subscription",
|
||||
mode: "device_code",
|
||||
});
|
||||
const response = SessionOutboundMessageSchema.parse({
|
||||
type: "config.paseo_agent.oauth.start.response",
|
||||
payload: {
|
||||
requestId: "req-oauth-start",
|
||||
success: true,
|
||||
name: "subscription",
|
||||
authorization: {
|
||||
kind: "device_code",
|
||||
userCode: "ABCD-EFGH",
|
||||
verificationUri: "https://auth.example.test/device",
|
||||
intervalSeconds: 5,
|
||||
expiresInSeconds: 900,
|
||||
futureField: "kept",
|
||||
},
|
||||
error: null,
|
||||
},
|
||||
});
|
||||
|
||||
expect(request.name).toBe("subscription");
|
||||
expect(request.mode).toBe("device_code");
|
||||
expect(response.payload.authorization?.kind).toBe("device_code");
|
||||
expect(response.payload.authorization?.futureField).toBe("kept");
|
||||
});
|
||||
|
||||
test("parses oauth.start auth-url response", () => {
|
||||
const parsed = SessionOutboundMessageSchema.parse({
|
||||
type: "config.paseo_agent.oauth.start.response",
|
||||
payload: {
|
||||
requestId: "req-oauth-start-url",
|
||||
success: true,
|
||||
name: "subscription",
|
||||
authorization: {
|
||||
kind: "auth_url",
|
||||
url: "https://auth.example.test/oauth",
|
||||
instructions: "Open this URL to continue.",
|
||||
},
|
||||
error: null,
|
||||
},
|
||||
});
|
||||
|
||||
expect(parsed.payload.authorization?.url).toBe("https://auth.example.test/oauth");
|
||||
});
|
||||
|
||||
test("parses oauth.complete request and response", () => {
|
||||
const request = SessionInboundMessageSchema.parse({
|
||||
type: "config.paseo_agent.oauth.complete.request",
|
||||
requestId: "req-oauth-complete",
|
||||
name: "subscription",
|
||||
});
|
||||
const response = SessionOutboundMessageSchema.parse({
|
||||
type: "config.paseo_agent.oauth.complete.response",
|
||||
payload: {
|
||||
requestId: "req-oauth-complete",
|
||||
success: true,
|
||||
name: "subscription",
|
||||
auth: { kind: "oauth", configured: true, source: "stored" },
|
||||
error: null,
|
||||
},
|
||||
});
|
||||
|
||||
expect(request.name).toBe("subscription");
|
||||
expect(response.payload.auth?.configured).toBe(true);
|
||||
});
|
||||
|
||||
test("preserves future OAuth credential fields on inbound schema parse", () => {
|
||||
const parsed = SessionInboundMessageSchema.parse({
|
||||
type: "config.paseo_agent.oauth.store_credential.request",
|
||||
requestId: "req-oauth",
|
||||
name: "subscription",
|
||||
credential: {
|
||||
type: "oauth",
|
||||
access: "access-token",
|
||||
refresh: "refresh-token",
|
||||
expires: 123,
|
||||
accountId: "acct_123",
|
||||
futureField: { keep: true },
|
||||
},
|
||||
});
|
||||
|
||||
expect(parsed.credential.futureField).toEqual({ keep: true });
|
||||
});
|
||||
|
||||
test("parses oauth.store_credential response without credential material", () => {
|
||||
const parsed = SessionOutboundMessageSchema.parse({
|
||||
type: "config.paseo_agent.oauth.store_credential.response",
|
||||
payload: {
|
||||
requestId: "req-oauth",
|
||||
success: true,
|
||||
name: "subscription",
|
||||
auth: { kind: "oauth", configured: true, source: "stored" },
|
||||
error: null,
|
||||
},
|
||||
});
|
||||
|
||||
expect(parsed.payload.name).toBe("subscription");
|
||||
expect(JSON.stringify(parsed)).not.toContain("access-token");
|
||||
expect(JSON.stringify(parsed)).not.toContain("refresh-token");
|
||||
});
|
||||
|
||||
test("parses provider rename request and response with display name", () => {
|
||||
const request = SessionInboundMessageSchema.parse({
|
||||
type: "config.paseo_agent.rename_provider.request",
|
||||
requestId: "req-rename-provider",
|
||||
name: "subscription",
|
||||
displayName: "Work account",
|
||||
});
|
||||
const response = SessionOutboundMessageSchema.parse({
|
||||
type: "config.paseo_agent.rename_provider.response",
|
||||
payload: {
|
||||
requestId: "req-rename-provider",
|
||||
success: true,
|
||||
provider: {
|
||||
name: "subscription",
|
||||
displayName: "Work account",
|
||||
providerType: "chatgpt",
|
||||
models: [{ id: "gpt-5.4-mini" }],
|
||||
auth: { kind: "oauth", configured: true, source: "stored" },
|
||||
available: true,
|
||||
error: null,
|
||||
},
|
||||
error: null,
|
||||
},
|
||||
});
|
||||
|
||||
expect(request.displayName).toBe("Work account");
|
||||
expect(response.payload.provider?.displayName).toBe("Work account");
|
||||
});
|
||||
|
||||
test("parses ChatGPT provider config separately from credential storage", () => {
|
||||
const parsed = SessionInboundMessageSchema.parse({
|
||||
type: "config.paseo_agent.set_provider.request",
|
||||
requestId: "req-set-chatgpt",
|
||||
name: "chatgpt",
|
||||
providerType: "openai-codex",
|
||||
options: {
|
||||
models: [{ id: "gpt-5.4-mini", reasoning: true }],
|
||||
},
|
||||
});
|
||||
|
||||
expect(parsed.providerType).toBe("openai-codex");
|
||||
expect(JSON.stringify(parsed)).not.toContain("access-token");
|
||||
});
|
||||
});
|
||||
@@ -1922,151 +1922,6 @@ export const ListProviderFeaturesRequestMessageSchema = z.object({
|
||||
requestId: z.string(),
|
||||
});
|
||||
|
||||
// Open on the wire on purpose: the daemon's paseo-agent config schema owns the
|
||||
// closed set of known types. A new type added daemon-side must not break an
|
||||
// older client's envelope parse (protocol contract: never narrow, old clients
|
||||
// keep parsing new daemons).
|
||||
const PaseoAgentProviderTypeSchema = z.string().min(1);
|
||||
|
||||
const PaseoAgentProviderModelConfigSchema = z
|
||||
.object({
|
||||
id: z.string().min(1),
|
||||
label: z.string().min(1).optional(),
|
||||
api: z.string().min(1).optional(),
|
||||
reasoning: z.boolean().optional(),
|
||||
contextWindow: z.number().int().positive().optional(),
|
||||
maxTokens: z.number().int().positive().optional(),
|
||||
})
|
||||
.strict();
|
||||
|
||||
const PaseoAgentSetProviderOptionsSchema = z
|
||||
.object({
|
||||
apiKey: z.string().min(1).optional(),
|
||||
baseUrl: z.string().url().optional(),
|
||||
api: z.string().min(1).optional(),
|
||||
headers: z.record(z.string(), z.string()).optional(),
|
||||
authHeader: z.boolean().optional(),
|
||||
models: z.array(PaseoAgentProviderModelConfigSchema).min(1).optional(),
|
||||
})
|
||||
.strict();
|
||||
|
||||
const PaseoAgentOAuthCredentialSchema = z
|
||||
.object({
|
||||
type: z.literal("oauth"),
|
||||
access: z.string(),
|
||||
refresh: z.string(),
|
||||
expires: z.number(),
|
||||
accountId: z.string().min(1).optional(),
|
||||
})
|
||||
.passthrough();
|
||||
|
||||
const PaseoAgentCatalogModelSchema = z
|
||||
.object({
|
||||
id: z.string().min(1),
|
||||
label: z.string().min(1).optional(),
|
||||
api: z.string().min(1).optional(),
|
||||
reasoning: z.boolean().optional(),
|
||||
contextWindow: z.number().int().positive().optional(),
|
||||
maxTokens: z.number().int().positive().optional(),
|
||||
})
|
||||
.passthrough();
|
||||
|
||||
const PaseoAgentCatalogAuthSchema = z
|
||||
.object({
|
||||
kind: z.string().min(1),
|
||||
})
|
||||
.passthrough();
|
||||
|
||||
export const PaseoAgentCatalogEntrySchema = z
|
||||
.object({
|
||||
id: z.string().min(1),
|
||||
label: z.string().min(1),
|
||||
iconName: z.string().min(1).optional(),
|
||||
docsUrl: z.string().optional(),
|
||||
api: z.string().min(1),
|
||||
baseUrl: z.string().min(1),
|
||||
headers: z.record(z.string(), z.string()).optional(),
|
||||
compat: z.record(z.string(), z.unknown()).optional(),
|
||||
auth: PaseoAgentCatalogAuthSchema,
|
||||
models: z.array(PaseoAgentCatalogModelSchema),
|
||||
})
|
||||
.passthrough();
|
||||
|
||||
export const PaseoAgentProviderAuthStateSchema = z
|
||||
.object({
|
||||
kind: z.string().min(1),
|
||||
configured: z.boolean(),
|
||||
source: z.string().min(1).optional(),
|
||||
hint: z.string().optional(),
|
||||
})
|
||||
.passthrough();
|
||||
|
||||
export const RedactedPaseoAgentProviderConfigSchema = z
|
||||
.object({
|
||||
name: z.string().min(1),
|
||||
displayName: z.string().min(1).optional(),
|
||||
providerType: PaseoAgentProviderTypeSchema,
|
||||
baseUrl: z.string().optional(),
|
||||
api: z.string().optional(),
|
||||
models: z.array(PaseoAgentProviderModelConfigSchema),
|
||||
auth: PaseoAgentProviderAuthStateSchema.optional(),
|
||||
available: z.boolean(),
|
||||
error: z.string().nullable().optional(),
|
||||
})
|
||||
.passthrough();
|
||||
|
||||
export const PaseoAgentGetProvidersRequestSchema = z.object({
|
||||
type: z.literal("config.paseo_agent.get_providers.request"),
|
||||
requestId: z.string(),
|
||||
});
|
||||
|
||||
export const PaseoAgentGetCatalogRequestSchema = z.object({
|
||||
type: z.literal("config.paseo_agent.get_catalog.request"),
|
||||
requestId: z.string(),
|
||||
});
|
||||
|
||||
export const PaseoAgentSetProviderRequestSchema = z.object({
|
||||
type: z.literal("config.paseo_agent.set_provider.request"),
|
||||
requestId: z.string(),
|
||||
name: z.string().trim().min(1),
|
||||
displayName: z.string().trim().min(1).optional(),
|
||||
providerType: PaseoAgentProviderTypeSchema,
|
||||
options: PaseoAgentSetProviderOptionsSchema,
|
||||
});
|
||||
|
||||
export const PaseoAgentRemoveProviderRequestSchema = z.object({
|
||||
type: z.literal("config.paseo_agent.remove_provider.request"),
|
||||
requestId: z.string(),
|
||||
name: z.string().trim().min(1),
|
||||
});
|
||||
|
||||
export const PaseoAgentRenameProviderRequestSchema = z.object({
|
||||
type: z.literal("config.paseo_agent.rename_provider.request"),
|
||||
requestId: z.string(),
|
||||
name: z.string().trim().min(1),
|
||||
displayName: z.string().trim().min(1),
|
||||
});
|
||||
|
||||
export const PaseoAgentOAuthStartRequestSchema = z.object({
|
||||
type: z.literal("config.paseo_agent.oauth.start.request"),
|
||||
requestId: z.string(),
|
||||
name: z.string().trim().min(1),
|
||||
mode: z.string().min(1).optional(),
|
||||
});
|
||||
|
||||
export const PaseoAgentOAuthCompleteRequestSchema = z.object({
|
||||
type: z.literal("config.paseo_agent.oauth.complete.request"),
|
||||
requestId: z.string(),
|
||||
name: z.string().trim().min(1),
|
||||
});
|
||||
|
||||
export const PaseoAgentOAuthStoreCredentialRequestSchema = z.object({
|
||||
type: z.literal("config.paseo_agent.oauth.store_credential.request"),
|
||||
requestId: z.string(),
|
||||
name: z.string().trim().min(1),
|
||||
credential: PaseoAgentOAuthCredentialSchema,
|
||||
});
|
||||
|
||||
export const ListCommandsRequestSchema = z.object({
|
||||
type: z.literal("list_commands_request"),
|
||||
agentId: z.string(),
|
||||
@@ -2218,14 +2073,6 @@ export const SessionInboundMessageSchema = z.discriminatedUnion("type", [
|
||||
ListProviderModelsRequestMessageSchema,
|
||||
ListProviderModesRequestMessageSchema,
|
||||
ListProviderFeaturesRequestMessageSchema,
|
||||
PaseoAgentGetProvidersRequestSchema,
|
||||
PaseoAgentGetCatalogRequestSchema,
|
||||
PaseoAgentSetProviderRequestSchema,
|
||||
PaseoAgentRemoveProviderRequestSchema,
|
||||
PaseoAgentRenameProviderRequestSchema,
|
||||
PaseoAgentOAuthStartRequestSchema,
|
||||
PaseoAgentOAuthCompleteRequestSchema,
|
||||
PaseoAgentOAuthStoreCredentialRequestSchema,
|
||||
ListAvailableProvidersRequestMessageSchema,
|
||||
GetProvidersSnapshotRequestMessageSchema,
|
||||
RefreshProvidersSnapshotRequestMessageSchema,
|
||||
@@ -2517,10 +2364,6 @@ export const ServerInfoStatusPayloadSchema = z
|
||||
daemonSelfUpdate: z.boolean().optional(),
|
||||
// COMPAT(agentForkContext): added in v0.1.102, remove gate after 2026-12-28.
|
||||
agentForkContext: z.boolean().optional(),
|
||||
// COMPAT(paseoAgentConfig): added in v0.1.103, remove gate after 2027-01-02.
|
||||
paseoAgentConfig: z.boolean().optional(),
|
||||
// COMPAT(paseoAgentCatalog): added in v0.1.104, drop the gate when floor >= v0.1.104
|
||||
paseoAgentCatalog: z.boolean().optional(),
|
||||
})
|
||||
.optional(),
|
||||
})
|
||||
@@ -4027,100 +3870,6 @@ export const ListProviderFeaturesResponseMessageSchema = z.object({
|
||||
}),
|
||||
});
|
||||
|
||||
export const PaseoAgentGetProvidersResponseSchema = z.object({
|
||||
type: z.literal("config.paseo_agent.get_providers.response"),
|
||||
payload: z.object({
|
||||
requestId: z.string(),
|
||||
defaultModel: z.string().nullable(),
|
||||
providers: z.array(RedactedPaseoAgentProviderConfigSchema),
|
||||
error: z.string().nullable(),
|
||||
}),
|
||||
});
|
||||
|
||||
export const PaseoAgentGetCatalogResponseSchema = z.object({
|
||||
type: z.literal("config.paseo_agent.get_catalog.response"),
|
||||
payload: z.object({
|
||||
requestId: z.string(),
|
||||
catalog: z.array(PaseoAgentCatalogEntrySchema),
|
||||
error: z.string().nullable(),
|
||||
}),
|
||||
});
|
||||
|
||||
export const PaseoAgentSetProviderResponseSchema = z.object({
|
||||
type: z.literal("config.paseo_agent.set_provider.response"),
|
||||
payload: z.object({
|
||||
requestId: z.string(),
|
||||
success: z.boolean(),
|
||||
provider: RedactedPaseoAgentProviderConfigSchema.nullable(),
|
||||
error: z.string().nullable(),
|
||||
}),
|
||||
});
|
||||
|
||||
export const PaseoAgentRemoveProviderResponseSchema = z.object({
|
||||
type: z.literal("config.paseo_agent.remove_provider.response"),
|
||||
payload: z.object({
|
||||
requestId: z.string(),
|
||||
success: z.boolean(),
|
||||
removed: z.boolean(),
|
||||
error: z.string().nullable(),
|
||||
}),
|
||||
});
|
||||
|
||||
export const PaseoAgentRenameProviderResponseSchema = z.object({
|
||||
type: z.literal("config.paseo_agent.rename_provider.response"),
|
||||
payload: z.object({
|
||||
requestId: z.string(),
|
||||
success: z.boolean(),
|
||||
provider: RedactedPaseoAgentProviderConfigSchema.nullable(),
|
||||
error: z.string().nullable(),
|
||||
}),
|
||||
});
|
||||
|
||||
const PaseoAgentOAuthStartAuthorizationSchema = z
|
||||
.object({
|
||||
kind: z.string().min(1),
|
||||
url: z.string().optional(),
|
||||
instructions: z.string().optional(),
|
||||
userCode: z.string().optional(),
|
||||
verificationUri: z.string().optional(),
|
||||
intervalSeconds: z.number().optional(),
|
||||
expiresInSeconds: z.number().optional(),
|
||||
})
|
||||
.passthrough();
|
||||
|
||||
export const PaseoAgentOAuthStartResponseSchema = z.object({
|
||||
type: z.literal("config.paseo_agent.oauth.start.response"),
|
||||
payload: z.object({
|
||||
requestId: z.string(),
|
||||
success: z.boolean(),
|
||||
name: z.string(),
|
||||
authorization: PaseoAgentOAuthStartAuthorizationSchema.nullable(),
|
||||
error: z.string().nullable(),
|
||||
}),
|
||||
});
|
||||
|
||||
export const PaseoAgentOAuthCompleteResponseSchema = z.object({
|
||||
type: z.literal("config.paseo_agent.oauth.complete.response"),
|
||||
payload: z.object({
|
||||
requestId: z.string(),
|
||||
success: z.boolean(),
|
||||
name: z.string(),
|
||||
auth: PaseoAgentProviderAuthStateSchema.optional(),
|
||||
error: z.string().nullable(),
|
||||
}),
|
||||
});
|
||||
|
||||
export const PaseoAgentOAuthStoreCredentialResponseSchema = z.object({
|
||||
type: z.literal("config.paseo_agent.oauth.store_credential.response"),
|
||||
payload: z.object({
|
||||
requestId: z.string(),
|
||||
success: z.boolean(),
|
||||
name: z.string(),
|
||||
auth: PaseoAgentProviderAuthStateSchema.optional(),
|
||||
error: z.string().nullable(),
|
||||
}),
|
||||
});
|
||||
|
||||
const ProviderAvailabilitySchema = z.object({
|
||||
provider: AgentProviderSchema,
|
||||
available: z.boolean(),
|
||||
@@ -4516,14 +4265,6 @@ export const SessionOutboundMessageSchema = z.discriminatedUnion("type", [
|
||||
ListProviderModelsResponseMessageSchema,
|
||||
ListProviderModesResponseMessageSchema,
|
||||
ListProviderFeaturesResponseMessageSchema,
|
||||
PaseoAgentGetProvidersResponseSchema,
|
||||
PaseoAgentGetCatalogResponseSchema,
|
||||
PaseoAgentSetProviderResponseSchema,
|
||||
PaseoAgentRemoveProviderResponseSchema,
|
||||
PaseoAgentRenameProviderResponseSchema,
|
||||
PaseoAgentOAuthStartResponseSchema,
|
||||
PaseoAgentOAuthCompleteResponseSchema,
|
||||
PaseoAgentOAuthStoreCredentialResponseSchema,
|
||||
ListAvailableProvidersResponseSchema,
|
||||
GetProvidersSnapshotResponseMessageSchema,
|
||||
ProvidersSnapshotUpdateMessageSchema,
|
||||
@@ -4656,26 +4397,6 @@ export type ListProviderModesResponseMessage = z.infer<
|
||||
export type ListProviderFeaturesResponseMessage = z.infer<
|
||||
typeof ListProviderFeaturesResponseMessageSchema
|
||||
>;
|
||||
export type RedactedPaseoAgentProviderConfig = z.infer<
|
||||
typeof RedactedPaseoAgentProviderConfigSchema
|
||||
>;
|
||||
export type PaseoAgentProviderAuthState = z.infer<typeof PaseoAgentProviderAuthStateSchema>;
|
||||
export type PaseoAgentOAuthCredential = z.infer<typeof PaseoAgentOAuthCredentialSchema>;
|
||||
export type PaseoAgentCatalogEntry = z.infer<typeof PaseoAgentCatalogEntrySchema>;
|
||||
export type PaseoAgentGetProvidersResponse = z.infer<typeof PaseoAgentGetProvidersResponseSchema>;
|
||||
export type PaseoAgentGetCatalogResponse = z.infer<typeof PaseoAgentGetCatalogResponseSchema>;
|
||||
export type PaseoAgentSetProviderResponse = z.infer<typeof PaseoAgentSetProviderResponseSchema>;
|
||||
export type PaseoAgentRemoveProviderResponse = z.infer<
|
||||
typeof PaseoAgentRemoveProviderResponseSchema
|
||||
>;
|
||||
export type PaseoAgentRenameProviderResponse = z.infer<
|
||||
typeof PaseoAgentRenameProviderResponseSchema
|
||||
>;
|
||||
export type PaseoAgentOAuthStartResponse = z.infer<typeof PaseoAgentOAuthStartResponseSchema>;
|
||||
export type PaseoAgentOAuthCompleteResponse = z.infer<typeof PaseoAgentOAuthCompleteResponseSchema>;
|
||||
export type PaseoAgentOAuthStoreCredentialResponse = z.infer<
|
||||
typeof PaseoAgentOAuthStoreCredentialResponseSchema
|
||||
>;
|
||||
export type ListAvailableProvidersResponse = z.infer<typeof ListAvailableProvidersResponseSchema>;
|
||||
export type DaemonGetStatusResponse = z.infer<typeof DaemonGetStatusResponseSchema>;
|
||||
export type DaemonGetPairingOfferResponse = z.infer<typeof DaemonGetPairingOfferResponseSchema>;
|
||||
@@ -4752,16 +4473,6 @@ export type ListProviderModesRequestMessage = z.infer<typeof ListProviderModesRe
|
||||
export type ListProviderFeaturesRequestMessage = z.infer<
|
||||
typeof ListProviderFeaturesRequestMessageSchema
|
||||
>;
|
||||
export type PaseoAgentGetProvidersRequest = z.infer<typeof PaseoAgentGetProvidersRequestSchema>;
|
||||
export type PaseoAgentGetCatalogRequest = z.infer<typeof PaseoAgentGetCatalogRequestSchema>;
|
||||
export type PaseoAgentSetProviderRequest = z.infer<typeof PaseoAgentSetProviderRequestSchema>;
|
||||
export type PaseoAgentRemoveProviderRequest = z.infer<typeof PaseoAgentRemoveProviderRequestSchema>;
|
||||
export type PaseoAgentRenameProviderRequest = z.infer<typeof PaseoAgentRenameProviderRequestSchema>;
|
||||
export type PaseoAgentOAuthStartRequest = z.infer<typeof PaseoAgentOAuthStartRequestSchema>;
|
||||
export type PaseoAgentOAuthCompleteRequest = z.infer<typeof PaseoAgentOAuthCompleteRequestSchema>;
|
||||
export type PaseoAgentOAuthStoreCredentialRequest = z.infer<
|
||||
typeof PaseoAgentOAuthStoreCredentialRequestSchema
|
||||
>;
|
||||
export type ListAvailableProvidersRequestMessage = z.infer<
|
||||
typeof ListAvailableProvidersRequestMessageSchema
|
||||
>;
|
||||
|
||||
@@ -57,15 +57,7 @@ export const ProviderOverrideSchema = z.object({
|
||||
order: z.number().optional(),
|
||||
});
|
||||
|
||||
const BUILTIN_PROVIDER_IDS = [
|
||||
"claude",
|
||||
"codex",
|
||||
"copilot",
|
||||
"opencode",
|
||||
"pi",
|
||||
"omp",
|
||||
"paseo",
|
||||
] as const;
|
||||
const BUILTIN_PROVIDER_IDS = ["claude", "codex", "copilot", "opencode", "pi", "omp"] as const;
|
||||
const PROVIDER_ID_PATTERN = /^[a-z][a-z0-9-]*$/;
|
||||
|
||||
export const ProviderOverridesSchema = z
|
||||
|
||||
@@ -5,10 +5,7 @@ export const BUILTIN_PROVIDER_ICON_NAMES = [
|
||||
"kiro",
|
||||
"minimax",
|
||||
"omp",
|
||||
"openai",
|
||||
"opencode",
|
||||
"openrouter",
|
||||
"paseo",
|
||||
"pi",
|
||||
];
|
||||
|
||||
|
||||
@@ -220,13 +220,6 @@ export const AGENT_PROVIDER_DEFINITIONS: AgentProviderDefinition[] = [
|
||||
defaultModeId: null,
|
||||
modes: [],
|
||||
},
|
||||
{
|
||||
id: "paseo",
|
||||
label: "Paseo Agent",
|
||||
description: "Paseo's in-process agent harness with configurable inference providers",
|
||||
defaultModeId: null,
|
||||
modes: [],
|
||||
},
|
||||
];
|
||||
|
||||
export const DEV_AGENT_PROVIDER_DEFINITIONS: AgentProviderDefinition[] = [
|
||||
|
||||
@@ -65,10 +65,6 @@
|
||||
"@agentclientprotocol/sdk": "^0.17.1",
|
||||
"@anthropic-ai/claude-agent-sdk": "^0.3.195",
|
||||
"@anthropic-ai/sdk": "^0.104.2",
|
||||
"@earendil-works/pi-agent-core": "0.77.0",
|
||||
"@earendil-works/pi-ai": "0.77.0",
|
||||
"@earendil-works/pi-coding-agent": "0.77.0",
|
||||
"@earendil-works/pi-tui": "0.77.0",
|
||||
"@getpaseo/client": "0.1.103",
|
||||
"@getpaseo/highlight": "0.1.103",
|
||||
"@getpaseo/protocol": "0.1.103",
|
||||
@@ -99,7 +95,6 @@
|
||||
"uuid": "^9.0.1",
|
||||
"which": "^5.0.0",
|
||||
"ws": "^8.14.2",
|
||||
"yaml": "^2.8.4",
|
||||
"zod": "^4.4.3"
|
||||
},
|
||||
"devDependencies": {
|
||||
|
||||
@@ -1085,8 +1085,7 @@ export class AgentManager {
|
||||
this.foregroundRuns.clearAgent(agentId, existing);
|
||||
await this.closeReloadedSession(existing.session, agentId);
|
||||
|
||||
const canRehydrateFromProviderPersistence = rehydrateFromDisk && Boolean(handle);
|
||||
if (canRehydrateFromProviderPersistence) {
|
||||
if (rehydrateFromDisk) {
|
||||
// Wipe both durable and in-memory timeline so registerSession mints a
|
||||
// new epoch and hydrateTimelineFromProvider re-streams the freshly read
|
||||
// provider history into an empty timeline.
|
||||
@@ -1101,7 +1100,7 @@ export class AgentManager {
|
||||
createdAt: existing.createdAt,
|
||||
updatedAt: existing.updatedAt,
|
||||
lastUserMessageAt: existing.lastUserMessageAt,
|
||||
historyPrimed: canRehydrateFromProviderPersistence ? false : preservedHistoryPrimed,
|
||||
historyPrimed: rehydrateFromDisk ? false : preservedHistoryPrimed,
|
||||
lastUsage: preservedLastUsage,
|
||||
lastError: preservedLastError,
|
||||
attention: preservedAttention,
|
||||
@@ -1813,7 +1812,11 @@ export class AgentManager {
|
||||
nextLifecycle = "idle";
|
||||
}
|
||||
mutableAgent.lifecycle = nextLifecycle;
|
||||
const persistenceHandle = this.describePersistenceHandle(mutableAgent);
|
||||
const persistenceHandle =
|
||||
mutableAgent.session.describePersistence() ??
|
||||
(mutableAgent.runtimeInfo?.sessionId
|
||||
? { provider: mutableAgent.provider, sessionId: mutableAgent.runtimeInfo.sessionId }
|
||||
: null);
|
||||
if (persistenceHandle) {
|
||||
mutableAgent.persistence = attachPersistenceCwd(persistenceHandle, mutableAgent.cwd);
|
||||
}
|
||||
@@ -2829,11 +2832,11 @@ export class AgentManager {
|
||||
newInfo.sessionId !== agent.runtimeInfo?.sessionId ||
|
||||
newInfo.modeId !== agent.runtimeInfo?.modeId;
|
||||
agent.runtimeInfo = newInfo;
|
||||
if (!agent.persistence) {
|
||||
const persistenceHandle = this.describePersistenceHandle(agent);
|
||||
if (persistenceHandle) {
|
||||
agent.persistence = attachPersistenceCwd(persistenceHandle, agent.cwd);
|
||||
}
|
||||
if (!agent.persistence && newInfo.sessionId) {
|
||||
agent.persistence = attachPersistenceCwd(
|
||||
{ provider: agent.provider, sessionId: newInfo.sessionId },
|
||||
agent.cwd,
|
||||
);
|
||||
}
|
||||
// Emit state if runtimeInfo changed so clients get the updated model
|
||||
if (changed && options?.emit !== false) {
|
||||
@@ -3125,7 +3128,7 @@ export class AgentManager {
|
||||
|
||||
private onStreamThreadStarted(agent: ActiveManagedAgent): void {
|
||||
const previousSessionId = agent.persistence?.sessionId ?? null;
|
||||
const handle = this.describePersistenceHandle(agent);
|
||||
const handle = agent.session.describePersistence();
|
||||
if (handle) {
|
||||
agent.persistence = attachPersistenceCwd(handle, agent.cwd);
|
||||
if (agent.persistence?.sessionId !== previousSessionId) {
|
||||
@@ -3135,19 +3138,6 @@ export class AgentManager {
|
||||
void this.refreshRuntimeInfo(agent);
|
||||
}
|
||||
|
||||
private describePersistenceHandle(agent: ActiveManagedAgent): AgentPersistenceHandle | null {
|
||||
const handle = agent.session.describePersistence();
|
||||
if (handle) {
|
||||
return handle;
|
||||
}
|
||||
if (!agent.capabilities.supportsSessionPersistence) {
|
||||
return null;
|
||||
}
|
||||
return agent.runtimeInfo?.sessionId
|
||||
? { provider: agent.provider, sessionId: agent.runtimeInfo.sessionId }
|
||||
: null;
|
||||
}
|
||||
|
||||
private async onStreamTimelineEvent(params: {
|
||||
agent: ActiveManagedAgent;
|
||||
event: Extract<AgentStreamEvent, { type: "timeline" }>;
|
||||
@@ -3729,13 +3719,11 @@ export class AgentManager {
|
||||
agentId: string,
|
||||
): Promise<PreparedSessionConfig> {
|
||||
const storedConfig = await this.normalizeConfig(stripInternalPaseoMcpServer(config));
|
||||
const client = this.clients.get(storedConfig.provider);
|
||||
const mcpBaseUrl = client && this.shouldEnablePaseoTools(client) ? this.mcpBaseUrl : null;
|
||||
const launchConfig = this.applyDaemonAppendSystemPrompt(
|
||||
withRuntimePaseoMcpServer({
|
||||
config: storedConfig,
|
||||
agentId,
|
||||
mcpBaseUrl,
|
||||
mcpBaseUrl: this.mcpBaseUrl,
|
||||
mcpAuthToken: this.mcpAuthToken,
|
||||
}),
|
||||
);
|
||||
@@ -3768,7 +3756,7 @@ export class AgentManager {
|
||||
},
|
||||
};
|
||||
if (
|
||||
this.shouldEnablePaseoTools(client) &&
|
||||
this.paseoToolsEnabled &&
|
||||
client.capabilities.supportsNativePaseoTools &&
|
||||
this.paseoToolCatalogFactory
|
||||
) {
|
||||
@@ -3777,10 +3765,6 @@ export class AgentManager {
|
||||
return context;
|
||||
}
|
||||
|
||||
private shouldEnablePaseoTools(client: AgentClient): boolean {
|
||||
return this.paseoToolsEnabled || client.capabilities.requiresPaseoTools === true;
|
||||
}
|
||||
|
||||
private resolveProviderLaunchConfig(
|
||||
launchConfig: AgentSessionConfig,
|
||||
launchContext: AgentLaunchContext,
|
||||
|
||||
@@ -2803,12 +2803,14 @@ describe("create_schedule MCP tool", () => {
|
||||
|
||||
it("requires provider for schedules", async () => {
|
||||
const { agentManager, agentStorage } = createTestDeps();
|
||||
const create = vi.fn(async (input: CreateScheduleInput) => createStoredSchedule(input));
|
||||
const createOrReplace = vi.fn(async (input: CreateScheduleInput) =>
|
||||
createStoredSchedule(input),
|
||||
);
|
||||
const server = await createAgentMcpServer({
|
||||
agentManager,
|
||||
agentStorage,
|
||||
providerSnapshotManager: createOpenCodeManager().manager,
|
||||
scheduleService: { create } as unknown as ScheduleService,
|
||||
scheduleService: { createOrReplace } as unknown as ScheduleService,
|
||||
logger,
|
||||
});
|
||||
const tool = registeredTool(server, "create_schedule");
|
||||
@@ -2820,17 +2822,19 @@ describe("create_schedule MCP tool", () => {
|
||||
name: "Default schedule",
|
||||
}),
|
||||
).rejects.toThrow("provider is required when target is new-agent");
|
||||
expect(create).not.toHaveBeenCalled();
|
||||
expect(createOrReplace).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("keeps create_schedule provider overrides compatible with provider and provider/model forms", async () => {
|
||||
const { agentManager, agentStorage } = createTestDeps();
|
||||
const create = vi.fn(async (input: CreateScheduleInput) => createStoredSchedule(input));
|
||||
const createOrReplace = vi.fn(async (input: CreateScheduleInput) =>
|
||||
createStoredSchedule(input),
|
||||
);
|
||||
const server = await createAgentMcpServer({
|
||||
agentManager,
|
||||
agentStorage,
|
||||
providerSnapshotManager: createOpenCodeManager().manager,
|
||||
scheduleService: { create } as unknown as ScheduleService,
|
||||
scheduleService: { createOrReplace } as unknown as ScheduleService,
|
||||
logger,
|
||||
});
|
||||
const tool = registeredTool(server, "create_schedule");
|
||||
@@ -2846,7 +2850,7 @@ describe("create_schedule MCP tool", () => {
|
||||
provider: "codex/gpt-5.4",
|
||||
});
|
||||
|
||||
expect(create).toHaveBeenNthCalledWith(
|
||||
expect(createOrReplace).toHaveBeenNthCalledWith(
|
||||
1,
|
||||
expect.objectContaining({
|
||||
target: {
|
||||
@@ -2858,7 +2862,7 @@ describe("create_schedule MCP tool", () => {
|
||||
},
|
||||
}),
|
||||
);
|
||||
expect(create).toHaveBeenNthCalledWith(
|
||||
expect(createOrReplace).toHaveBeenNthCalledWith(
|
||||
2,
|
||||
expect.objectContaining({
|
||||
target: {
|
||||
@@ -2888,12 +2892,14 @@ describe("create_schedule MCP tool", () => {
|
||||
featureValues: { auto_accept: true },
|
||||
},
|
||||
} as ManagedAgent);
|
||||
const create = vi.fn(async (input: CreateScheduleInput) => createStoredSchedule(input));
|
||||
const createOrReplace = vi.fn(async (input: CreateScheduleInput) =>
|
||||
createStoredSchedule(input),
|
||||
);
|
||||
const server = await createAgentMcpServer({
|
||||
agentManager,
|
||||
agentStorage,
|
||||
providerSnapshotManager: createOpenCodeManager().manager,
|
||||
scheduleService: { create } as unknown as ScheduleService,
|
||||
scheduleService: { createOrReplace } as unknown as ScheduleService,
|
||||
callerAgentId: "parent-agent",
|
||||
logger,
|
||||
});
|
||||
@@ -2914,14 +2920,14 @@ describe("create_schedule MCP tool", () => {
|
||||
|
||||
it("passes timezone through cron create_schedule input", async () => {
|
||||
const { agentManager, agentStorage } = createTestDeps();
|
||||
const create = vi.fn(async (scheduleInput: CreateScheduleInput) =>
|
||||
const createOrReplace = vi.fn(async (scheduleInput: CreateScheduleInput) =>
|
||||
createStoredSchedule(scheduleInput),
|
||||
);
|
||||
const server = await createAgentMcpServer({
|
||||
agentManager,
|
||||
agentStorage,
|
||||
providerSnapshotManager: createOpenCodeManager().manager,
|
||||
scheduleService: { create } as unknown as ScheduleService,
|
||||
scheduleService: { createOrReplace } as unknown as ScheduleService,
|
||||
logger,
|
||||
});
|
||||
const tool = registeredTool(server, "create_schedule");
|
||||
@@ -2933,7 +2939,7 @@ describe("create_schedule MCP tool", () => {
|
||||
provider: "codex",
|
||||
});
|
||||
|
||||
expect(create).toHaveBeenCalledWith(
|
||||
expect(createOrReplace).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
cadence: {
|
||||
type: "cron",
|
||||
@@ -2946,12 +2952,12 @@ describe("create_schedule MCP tool", () => {
|
||||
|
||||
it("rejects removed create_schedule every input", async () => {
|
||||
const { agentManager, agentStorage } = createTestDeps();
|
||||
const create = vi.fn();
|
||||
const createOrReplace = vi.fn();
|
||||
const server = await createAgentMcpServer({
|
||||
agentManager,
|
||||
agentStorage,
|
||||
providerSnapshotManager: createOpenCodeManager().manager,
|
||||
scheduleService: { create } as unknown as ScheduleService,
|
||||
scheduleService: { createOrReplace } as unknown as ScheduleService,
|
||||
logger,
|
||||
});
|
||||
const tool = registeredTool(server, "create_schedule");
|
||||
@@ -2963,17 +2969,17 @@ describe("create_schedule MCP tool", () => {
|
||||
});
|
||||
expect(parsed.success).toBe(false);
|
||||
|
||||
expect(create).not.toHaveBeenCalled();
|
||||
expect(createOrReplace).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("rejects create_schedule without cron", async () => {
|
||||
const { agentManager, agentStorage } = createTestDeps();
|
||||
const create = vi.fn();
|
||||
const createOrReplace = vi.fn();
|
||||
const server = await createAgentMcpServer({
|
||||
agentManager,
|
||||
agentStorage,
|
||||
providerSnapshotManager: createOpenCodeManager().manager,
|
||||
scheduleService: { create } as unknown as ScheduleService,
|
||||
scheduleService: { createOrReplace } as unknown as ScheduleService,
|
||||
logger,
|
||||
});
|
||||
const tool = registeredTool(server, "create_schedule");
|
||||
@@ -2985,17 +2991,17 @@ describe("create_schedule MCP tool", () => {
|
||||
}),
|
||||
).rejects.toThrow(/cron/);
|
||||
|
||||
expect(create).not.toHaveBeenCalled();
|
||||
expect(createOrReplace).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it.each(["", " "])("rejects create_schedule blank timezone %#", async (timezone) => {
|
||||
const { agentManager, agentStorage } = createTestDeps();
|
||||
const create = vi.fn();
|
||||
const createOrReplace = vi.fn();
|
||||
const server = await createAgentMcpServer({
|
||||
agentManager,
|
||||
agentStorage,
|
||||
providerSnapshotManager: createOpenCodeManager().manager,
|
||||
scheduleService: { create } as unknown as ScheduleService,
|
||||
scheduleService: { createOrReplace } as unknown as ScheduleService,
|
||||
logger,
|
||||
});
|
||||
const tool = registeredTool(server, "create_schedule");
|
||||
@@ -3009,7 +3015,7 @@ describe("create_schedule MCP tool", () => {
|
||||
}),
|
||||
).rejects.toThrow();
|
||||
|
||||
expect(create).not.toHaveBeenCalled();
|
||||
expect(createOrReplace).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -3027,12 +3033,14 @@ describe("create_heartbeat MCP tool", () => {
|
||||
availableModes: [],
|
||||
config: { title: "Parent agent" },
|
||||
} as ManagedAgent);
|
||||
const create = vi.fn(async (input: CreateScheduleInput) => createStoredSchedule(input));
|
||||
const createOrReplace = vi.fn(async (input: CreateScheduleInput) =>
|
||||
createStoredSchedule(input),
|
||||
);
|
||||
const server = await createAgentMcpServer({
|
||||
agentManager,
|
||||
agentStorage,
|
||||
providerSnapshotManager: createOpenCodeManager().manager,
|
||||
scheduleService: { create } as unknown as ScheduleService,
|
||||
scheduleService: { createOrReplace } as unknown as ScheduleService,
|
||||
callerAgentId: "parent-agent",
|
||||
logger,
|
||||
});
|
||||
@@ -3045,7 +3053,7 @@ describe("create_heartbeat MCP tool", () => {
|
||||
name: "status heartbeat",
|
||||
});
|
||||
|
||||
expect(create).toHaveBeenCalledWith(
|
||||
expect(createOrReplace).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
prompt: "check status",
|
||||
cadence: {
|
||||
@@ -3061,12 +3069,12 @@ describe("create_heartbeat MCP tool", () => {
|
||||
|
||||
it("requires an agent-scoped session", async () => {
|
||||
const { agentManager, agentStorage } = createTestDeps();
|
||||
const create = vi.fn();
|
||||
const createOrReplace = vi.fn();
|
||||
const server = await createAgentMcpServer({
|
||||
agentManager,
|
||||
agentStorage,
|
||||
providerSnapshotManager: createOpenCodeManager().manager,
|
||||
scheduleService: { create } as unknown as ScheduleService,
|
||||
scheduleService: { createOrReplace } as unknown as ScheduleService,
|
||||
logger,
|
||||
});
|
||||
const tool = registeredTool(server, "create_heartbeat");
|
||||
@@ -3078,7 +3086,7 @@ describe("create_heartbeat MCP tool", () => {
|
||||
}),
|
||||
).rejects.toThrow("create_heartbeat requires an agent-scoped session");
|
||||
|
||||
expect(create).not.toHaveBeenCalled();
|
||||
expect(createOrReplace).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -36,8 +36,6 @@ import { GenericACPAgentClient } from "./providers/generic-acp-agent.js";
|
||||
import { KiroACPAgentClient } from "./providers/kiro-acp-agent.js";
|
||||
import { OpenCodeAgentClient } from "./providers/opencode-agent.js";
|
||||
import { PiRpcAgentClient } from "./providers/pi/agent.js";
|
||||
import { PaseoAgentClient } from "./providers/paseo-agent/agent.js";
|
||||
import type { PaseoAgentConfig } from "./providers/paseo-agent/config.js";
|
||||
import { MockLoadTestAgentClient } from "./providers/mock-load-test-agent.js";
|
||||
import { MockSlowProviderClient } from "./providers/mock-slow-provider.js";
|
||||
import {
|
||||
@@ -80,17 +78,11 @@ export interface BuildProviderRegistryOptions {
|
||||
workspaceGitService?: Pick<WorkspaceGitService, "resolveRepoRoot">;
|
||||
managedProcesses?: ManagedProcessRegistry;
|
||||
isDev?: boolean;
|
||||
paseoHome?: string;
|
||||
/**
|
||||
* Opaque Paseo Agent config blob. The registry only forwards it to the
|
||||
* paseo-agent client factory; it never reads the nested model providers.
|
||||
*/
|
||||
paseoAgentConfig?: PaseoAgentConfig;
|
||||
}
|
||||
|
||||
interface ProviderClientFactoryOptions extends Pick<
|
||||
BuildProviderRegistryOptions,
|
||||
"workspaceGitService" | "managedProcesses" | "paseoAgentConfig" | "paseoHome"
|
||||
"workspaceGitService" | "managedProcesses"
|
||||
> {
|
||||
providerParams?: unknown;
|
||||
customProvider?: {
|
||||
@@ -167,12 +159,6 @@ const PROVIDER_CLIENT_FACTORIES: Record<string, ProviderClientFactory> = {
|
||||
},
|
||||
commandsRpcType: "get_available_commands",
|
||||
}),
|
||||
paseo: (logger, _runtimeSettings, options) =>
|
||||
new PaseoAgentClient({
|
||||
logger,
|
||||
config: options?.paseoAgentConfig ?? {},
|
||||
paseoHome: options?.paseoHome,
|
||||
}),
|
||||
mock: (logger) => new MockLoadTestAgentClient(logger),
|
||||
"mock-slow": () => new MockSlowProviderClient(),
|
||||
};
|
||||
@@ -581,10 +567,7 @@ function createResolvedProviderClient(
|
||||
function buildResolvedBuiltinProviders(
|
||||
providerOverrides: Record<string, ProviderOverride>,
|
||||
runtimeSettings: AgentProviderRuntimeSettingsMap | undefined,
|
||||
options: Pick<
|
||||
BuildProviderRegistryOptions,
|
||||
"workspaceGitService" | "managedProcesses" | "paseoAgentConfig" | "paseoHome"
|
||||
>,
|
||||
options: Pick<BuildProviderRegistryOptions, "workspaceGitService" | "managedProcesses">,
|
||||
isDev: boolean,
|
||||
): Map<string, ResolvedProvider> {
|
||||
const resolvedProviders = new Map<string, ResolvedProvider>();
|
||||
@@ -615,8 +598,6 @@ function buildResolvedBuiltinProviders(
|
||||
workspaceGitService: options.workspaceGitService,
|
||||
managedProcesses: options.managedProcesses,
|
||||
providerParams: override?.params,
|
||||
paseoAgentConfig: options.paseoAgentConfig,
|
||||
paseoHome: options.paseoHome,
|
||||
}),
|
||||
});
|
||||
}
|
||||
@@ -736,8 +717,6 @@ export function buildProviderRegistry(
|
||||
{
|
||||
workspaceGitService: options?.workspaceGitService,
|
||||
managedProcesses: options?.managedProcesses,
|
||||
paseoAgentConfig: options?.paseoAgentConfig,
|
||||
paseoHome: options?.paseoHome,
|
||||
},
|
||||
options?.isDev === true,
|
||||
);
|
||||
|
||||
@@ -363,13 +363,12 @@ describe("ProviderSnapshotManager public surface", () => {
|
||||
copilot: { enabled: false },
|
||||
opencode: { enabled: false },
|
||||
pi: { enabled: false },
|
||||
paseo: { enabled: false },
|
||||
},
|
||||
});
|
||||
try {
|
||||
const entries = await manager.listProviders({ cwd: "/tmp/project", wait: true });
|
||||
const providers = entries.map((entry) => entry.provider).sort();
|
||||
expect(providers).toEqual(["claude", "codex", "copilot", "omp", "opencode", "paseo", "pi"]);
|
||||
expect(providers).toEqual(["claude", "codex", "copilot", "omp", "opencode", "pi"]);
|
||||
for (const entry of entries) {
|
||||
expect(entry.enabled).toBe(false);
|
||||
expect(entry.status).toBe("unavailable");
|
||||
@@ -1019,105 +1018,6 @@ describe("ProviderSnapshotManager applyMutableProviderConfig", () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe("ProviderSnapshotManager applyPaseoAgentConfig", () => {
|
||||
test("keeps unrelated provider loading state when Paseo Agent config changes", async () => {
|
||||
let resolveFetchStarted: (() => void) | undefined;
|
||||
let releaseFetch: (() => void) | undefined;
|
||||
const fetchStarted = new Promise<void>((resolveStarted) => {
|
||||
resolveFetchStarted = resolveStarted;
|
||||
});
|
||||
const fetchRelease = new Promise<void>((resolveRelease) => {
|
||||
releaseFetch = resolveRelease;
|
||||
});
|
||||
const manager = new ProviderSnapshotManager({
|
||||
logger: createTestLogger(),
|
||||
providerOverrides: {
|
||||
codex: { enabled: false },
|
||||
copilot: { enabled: false },
|
||||
opencode: { enabled: false },
|
||||
pi: { enabled: false },
|
||||
omp: { enabled: false },
|
||||
},
|
||||
extraClients: {
|
||||
claude: createExtraClient("claude", {
|
||||
async isAvailable() {
|
||||
return true;
|
||||
},
|
||||
async fetchCatalog() {
|
||||
resolveFetchStarted?.();
|
||||
await fetchRelease;
|
||||
return { models: [] as AgentModelDefinition[], modes: [] as AgentMode[] };
|
||||
},
|
||||
}),
|
||||
},
|
||||
paseoAgentConfig: {},
|
||||
});
|
||||
try {
|
||||
manager.getSnapshot();
|
||||
await fetchStarted;
|
||||
|
||||
manager.applyPaseoAgentConfig({
|
||||
providers: {
|
||||
"openrouter-main": {
|
||||
type: "openrouter",
|
||||
options: {
|
||||
apiKey: "sk-test",
|
||||
models: [{ id: "anthropic/claude-3.7-sonnet", label: "Claude" }],
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
expect(manager.getSnapshot().find((entry) => entry.provider === "claude")).toMatchObject({
|
||||
provider: "claude",
|
||||
status: "loading",
|
||||
enabled: true,
|
||||
});
|
||||
} finally {
|
||||
releaseFetch?.();
|
||||
manager.destroy();
|
||||
}
|
||||
});
|
||||
|
||||
test("refreshes Paseo Agent models without daemon restart", async () => {
|
||||
const manager = new ProviderSnapshotManager({
|
||||
logger: createTestLogger(),
|
||||
providerOverrides: {
|
||||
claude: { enabled: false },
|
||||
codex: { enabled: false },
|
||||
copilot: { enabled: false },
|
||||
opencode: { enabled: false },
|
||||
pi: { enabled: false },
|
||||
},
|
||||
paseoAgentConfig: {},
|
||||
});
|
||||
try {
|
||||
manager.applyPaseoAgentConfig({
|
||||
providers: {
|
||||
"openrouter-main": {
|
||||
type: "openrouter",
|
||||
options: {
|
||||
apiKey: "sk-test",
|
||||
models: [{ id: "anthropic/claude-3.7-sonnet", label: "Claude" }],
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
const models = await manager.listModels({ provider: "paseo", wait: true });
|
||||
expect(models).toEqual([
|
||||
expect.objectContaining({
|
||||
provider: "paseo",
|
||||
id: "openrouter-main/anthropic/claude-3.7-sonnet",
|
||||
label: "Claude",
|
||||
}),
|
||||
]);
|
||||
} finally {
|
||||
manager.destroy();
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe("ProviderSnapshotManager lifecycle", () => {
|
||||
test("on/off attaches and detaches change listeners", () => {
|
||||
const manager = new ProviderSnapshotManager({
|
||||
|
||||
@@ -27,7 +27,6 @@ import {
|
||||
shutdownAgentClients,
|
||||
type ProviderDefinition,
|
||||
} from "./provider-registry.js";
|
||||
import type { PaseoAgentConfig } from "./providers/paseo-agent/config.js";
|
||||
import { applyMutableProviderConfigToOverrides } from "../daemon-config-store.js";
|
||||
import {
|
||||
formatProviderDiagnostic,
|
||||
@@ -77,8 +76,6 @@ export interface ProviderSnapshotManagerOptions {
|
||||
extraClients?: Partial<Record<AgentProvider, AgentClient>>;
|
||||
refreshTimeoutMs?: number;
|
||||
diagnosticTimeoutMs?: number;
|
||||
paseoAgentConfig?: PaseoAgentConfig;
|
||||
paseoHome?: string;
|
||||
}
|
||||
|
||||
interface ProviderSnapshotRefreshOptions {
|
||||
@@ -164,11 +161,9 @@ export class ProviderSnapshotManager {
|
||||
private readonly managedProcesses?: ManagedProcessRegistry;
|
||||
private readonly isDev: boolean;
|
||||
private readonly extraClients: Partial<Record<AgentProvider, AgentClient>>;
|
||||
private readonly paseoHome: string | undefined;
|
||||
private runtimeSettings: AgentProviderRuntimeSettingsMap | undefined;
|
||||
private providerOverrides: Record<string, ProviderOverride> | undefined;
|
||||
private readonly baseProviderOverrides: Record<string, ProviderOverride> | undefined;
|
||||
private paseoAgentConfig: PaseoAgentConfig | undefined;
|
||||
private providerRegistry: Record<AgentProvider, ProviderDefinition>;
|
||||
private providerClients: Record<AgentProvider, AgentClient>;
|
||||
|
||||
@@ -178,11 +173,9 @@ export class ProviderSnapshotManager {
|
||||
this.managedProcesses = options.managedProcesses;
|
||||
this.isDev = options.isDev === true;
|
||||
this.extraClients = options.extraClients ?? {};
|
||||
this.paseoHome = options.paseoHome;
|
||||
this.runtimeSettings = options.runtimeSettings;
|
||||
this.providerOverrides = options.providerOverrides;
|
||||
this.baseProviderOverrides = options.providerOverrides;
|
||||
this.paseoAgentConfig = options.paseoAgentConfig;
|
||||
this.refreshTimeoutMs = resolveRefreshTimeoutMs(options.refreshTimeoutMs);
|
||||
this.diagnosticTimeoutMs = resolveDiagnosticTimeoutMs(
|
||||
options.diagnosticTimeoutMs,
|
||||
@@ -381,12 +374,16 @@ export class ProviderSnapshotManager {
|
||||
this.baseProviderOverrides,
|
||||
mutableProviders,
|
||||
);
|
||||
return this.rebuildRegistryAndReconcileSnapshots();
|
||||
}
|
||||
this.providerRegistry = this.buildRegistry();
|
||||
this.providerClients = { ...this.extraClients } as Record<AgentProvider, AgentClient>;
|
||||
|
||||
applyPaseoAgentConfig(config: PaseoAgentConfig | undefined): AgentManagerProviderState {
|
||||
this.paseoAgentConfig = config;
|
||||
return this.rebuildRegistryAndReconcileSnapshots();
|
||||
for (const cwd of this.snapshots.keys()) {
|
||||
this.providerLoads.delete(cwd);
|
||||
this.snapshots.set(cwd, this.reconcileSnapshotForRegistry(cwd));
|
||||
this.emitChange(cwd);
|
||||
}
|
||||
|
||||
return this.getAgentManagerProviderState();
|
||||
}
|
||||
|
||||
on(event: "change", listener: ProviderSnapshotChangeListener): this {
|
||||
@@ -424,8 +421,6 @@ export class ProviderSnapshotManager {
|
||||
workspaceGitService: this.workspaceGitService,
|
||||
managedProcesses: this.managedProcesses,
|
||||
isDev: this.isDev,
|
||||
paseoAgentConfig: this.paseoAgentConfig,
|
||||
paseoHome: this.paseoHome,
|
||||
});
|
||||
|
||||
for (const [provider, client] of Object.entries(this.extraClients) as Array<
|
||||
@@ -447,19 +442,6 @@ export class ProviderSnapshotManager {
|
||||
return registry;
|
||||
}
|
||||
|
||||
private rebuildRegistryAndReconcileSnapshots(): AgentManagerProviderState {
|
||||
this.providerRegistry = this.buildRegistry();
|
||||
this.providerClients = { ...this.extraClients } as Record<AgentProvider, AgentClient>;
|
||||
|
||||
for (const cwd of this.snapshots.keys()) {
|
||||
this.providerLoads.delete(cwd);
|
||||
this.snapshots.set(cwd, this.reconcileSnapshotForRegistry(cwd));
|
||||
this.emitChange(cwd);
|
||||
}
|
||||
|
||||
return this.getAgentManagerProviderState();
|
||||
}
|
||||
|
||||
private resolveParent(parent: ManagedAgent): AgentCreateConfigParent {
|
||||
const definition = this.requireProvider(parent.provider);
|
||||
return {
|
||||
@@ -585,7 +567,7 @@ export class ProviderSnapshotManager {
|
||||
defaultModeId: definition?.defaultModeId ?? null,
|
||||
};
|
||||
|
||||
if (!definition?.enabled) {
|
||||
if (!definition?.enabled || !current || current.status === "loading") {
|
||||
entries.set(provider, {
|
||||
...metadata,
|
||||
status: "unavailable",
|
||||
@@ -594,14 +576,6 @@ export class ProviderSnapshotManager {
|
||||
continue;
|
||||
}
|
||||
|
||||
if (!current || current.status === "loading") {
|
||||
entries.set(provider, {
|
||||
...metadata,
|
||||
status: "loading",
|
||||
});
|
||||
continue;
|
||||
}
|
||||
|
||||
entries.set(provider, {
|
||||
...current,
|
||||
...metadata,
|
||||
|
||||
@@ -1,31 +0,0 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { createToolPermissionPolicy, evaluateToolPermission } from "./agent-permissions.js";
|
||||
|
||||
describe("Paseo Agent tool permissions", () => {
|
||||
it("uses the first matching rule", () => {
|
||||
const allowFirst = createToolPermissionPolicy([
|
||||
{ tool: "bash", action: "allow" },
|
||||
{ tool: "bash", action: "deny" },
|
||||
]);
|
||||
const denyFirst = createToolPermissionPolicy([
|
||||
{ tool: "bash", action: "deny" },
|
||||
{ tool: "bash", action: "allow" },
|
||||
]);
|
||||
|
||||
expect(evaluateToolPermission(allowFirst, "bash")).toBe("allow");
|
||||
expect(evaluateToolPermission(denyFirst, "bash")).toBe("deny");
|
||||
});
|
||||
|
||||
it("matches wildcard tool names", () => {
|
||||
const policy = createToolPermissionPolicy([{ tool: "paseo__archive_*", action: "deny" }]);
|
||||
|
||||
expect(evaluateToolPermission(policy, "paseo__archive_agent")).toBe("deny");
|
||||
expect(evaluateToolPermission(policy, "paseo__list_agents")).toBe("allow");
|
||||
});
|
||||
|
||||
it("allows tools when no rule matches", () => {
|
||||
const policy = createToolPermissionPolicy([{ tool: "read", action: "deny" }]);
|
||||
|
||||
expect(evaluateToolPermission(policy, "bash")).toBe("allow");
|
||||
});
|
||||
});
|
||||
@@ -1,67 +0,0 @@
|
||||
import { z } from "zod";
|
||||
|
||||
const ToolPermissionActionSchema = z.enum(["allow", "deny"]);
|
||||
|
||||
export const ToolPermissionRuleSchema = z
|
||||
.object({
|
||||
tool: z.string().min(1),
|
||||
action: ToolPermissionActionSchema,
|
||||
})
|
||||
.strict();
|
||||
|
||||
export type ToolPermissionAction = z.infer<typeof ToolPermissionActionSchema>;
|
||||
export type ToolPermissionRule = z.infer<typeof ToolPermissionRuleSchema>;
|
||||
|
||||
export interface CompiledToolPermissionRule {
|
||||
rule: ToolPermissionRule;
|
||||
matches(toolName: string): boolean;
|
||||
}
|
||||
|
||||
export interface ToolPermissionPolicy {
|
||||
rules: ToolPermissionRule[];
|
||||
compiledRules: CompiledToolPermissionRule[];
|
||||
}
|
||||
|
||||
export function createToolPermissionPolicy(
|
||||
rules: ToolPermissionRule[] | undefined,
|
||||
): ToolPermissionPolicy {
|
||||
const normalizedRules = rules ?? [];
|
||||
return {
|
||||
rules: normalizedRules,
|
||||
compiledRules: normalizedRules.map((rule) => ({
|
||||
rule,
|
||||
matches: compileToolPattern(rule.tool),
|
||||
})),
|
||||
};
|
||||
}
|
||||
|
||||
export function evaluateToolPermission(
|
||||
policy: ToolPermissionPolicy | undefined,
|
||||
toolName: string,
|
||||
): ToolPermissionAction {
|
||||
for (const compiled of policy?.compiledRules ?? []) {
|
||||
if (compiled.matches(toolName)) {
|
||||
return compiled.rule.action;
|
||||
}
|
||||
}
|
||||
return "allow";
|
||||
}
|
||||
|
||||
function compileToolPattern(pattern: string): (toolName: string) => boolean {
|
||||
if (pattern === "*") {
|
||||
return () => true;
|
||||
}
|
||||
if (!pattern.includes("*")) {
|
||||
return (toolName) => toolName === pattern;
|
||||
}
|
||||
const matcher = new RegExp(`^${wildcardPatternToRegExp(pattern)}$`);
|
||||
return (toolName) => matcher.test(toolName);
|
||||
}
|
||||
|
||||
function wildcardPatternToRegExp(pattern: string): string {
|
||||
return pattern.split("*").map(escapeRegExp).join(".*");
|
||||
}
|
||||
|
||||
function escapeRegExp(value: string): string {
|
||||
return value.replace(/[|\\{}()[\]^$+?.]/g, "\\$&");
|
||||
}
|
||||
@@ -1,598 +0,0 @@
|
||||
import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from "node:fs";
|
||||
import { tmpdir } from "node:os";
|
||||
import { join } from "node:path";
|
||||
import type { AgentSessionEvent } from "@earendil-works/pi-coding-agent";
|
||||
import type { Logger } from "pino";
|
||||
import { afterEach, describe, expect, it } from "vitest";
|
||||
|
||||
import { createTestLogger } from "../../../../test-utils/test-logger.js";
|
||||
import type { AgentSessionConfig, AgentStreamEvent } from "../../agent-sdk-types.js";
|
||||
import { PaseoAgentClient, PaseoAgentSession } from "./agent.js";
|
||||
import { PaseoAgentConfigSchema, type PaseoAgentConfig } from "./config.js";
|
||||
import { storeOAuthCredential } from "./oauth-store.js";
|
||||
import type { PaseoAgentSessionHandle } from "./pi-services.js";
|
||||
|
||||
function makeConfig(): PaseoAgentConfig {
|
||||
return PaseoAgentConfigSchema.parse({
|
||||
defaultModel: "openrouter-main/test-model",
|
||||
providers: {
|
||||
"openrouter-main": {
|
||||
type: "openrouter",
|
||||
options: {
|
||||
baseUrl: "https://openrouter.ai/api/v1",
|
||||
apiKey: "sk-test",
|
||||
api: "openai-completions",
|
||||
models: [{ id: "test-model", label: "Test Model" }],
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
function sessionConfig(overrides?: Partial<AgentSessionConfig>): AgentSessionConfig {
|
||||
return { provider: "paseo", cwd: process.cwd(), ...overrides };
|
||||
}
|
||||
|
||||
function createRecordingLogger(): Logger & { warnings: Array<{ data: unknown; message: string }> } {
|
||||
const warnings: Array<{ data: unknown; message: string }> = [];
|
||||
const logger = {
|
||||
warnings,
|
||||
child: () => logger,
|
||||
debug: () => {},
|
||||
warn: (data: unknown, message: string) => {
|
||||
warnings.push({ data, message });
|
||||
},
|
||||
error: () => {},
|
||||
info: () => {},
|
||||
} as Logger & { warnings: Array<{ data: unknown; message: string }> };
|
||||
return logger;
|
||||
}
|
||||
|
||||
function deferred<T = void>(): {
|
||||
promise: Promise<T>;
|
||||
resolve: (value: T | PromiseLike<T>) => void;
|
||||
reject: (error: unknown) => void;
|
||||
} {
|
||||
let resolve!: (value: T | PromiseLike<T>) => void;
|
||||
let reject!: (error: unknown) => void;
|
||||
const promise = new Promise<T>((innerResolve, innerReject) => {
|
||||
resolve = innerResolve;
|
||||
reject = innerReject;
|
||||
});
|
||||
return { promise, resolve, reject };
|
||||
}
|
||||
|
||||
class FakeInProcessPiSession {
|
||||
readonly sessionId = "pi-session-1";
|
||||
readonly thinkingLevel = "medium";
|
||||
readonly model = { provider: "openrouter-main", id: "test-model" };
|
||||
readonly messages: Array<{ role: string; content: unknown }> = [];
|
||||
readonly agent = { state: { errorMessage: "" } };
|
||||
abortCalls = 0;
|
||||
disposeCalls = 0;
|
||||
promptCalls: Array<{ text: string; options: unknown }> = [];
|
||||
promptDeferred = deferred();
|
||||
private readonly subscribers = new Set<(event: AgentSessionEvent) => void>();
|
||||
|
||||
subscribe(callback: (event: AgentSessionEvent) => void): () => void {
|
||||
this.subscribers.add(callback);
|
||||
return () => {
|
||||
this.subscribers.delete(callback);
|
||||
};
|
||||
}
|
||||
|
||||
async prompt(text: string, options?: unknown): Promise<void> {
|
||||
this.promptCalls.push({ text, options });
|
||||
await this.promptDeferred.promise;
|
||||
}
|
||||
|
||||
async abort(): Promise<void> {
|
||||
this.abortCalls += 1;
|
||||
const error = new Error("Request was aborted");
|
||||
error.name = "AbortError";
|
||||
this.promptDeferred.reject(error);
|
||||
}
|
||||
|
||||
dispose(): void {
|
||||
this.disposeCalls += 1;
|
||||
}
|
||||
|
||||
getSessionStats() {
|
||||
return {
|
||||
sessionFile: undefined,
|
||||
sessionId: this.sessionId,
|
||||
userMessages: 1,
|
||||
assistantMessages: 1,
|
||||
toolCalls: 0,
|
||||
toolResults: 0,
|
||||
totalMessages: 2,
|
||||
tokens: { input: 3, output: 5, cacheRead: 2, cacheWrite: 0, total: 10 },
|
||||
cost: 0.01,
|
||||
contextUsage: { contextWindow: 200000, tokens: 1234, percentage: 0.6 },
|
||||
};
|
||||
}
|
||||
|
||||
setThinkingLevel(): void {}
|
||||
|
||||
async setModel(): Promise<void> {}
|
||||
|
||||
emit(event: AgentSessionEvent): void {
|
||||
for (const subscriber of this.subscribers) {
|
||||
subscriber(event);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function createPaseoProviderSession(): {
|
||||
fakePi: FakeInProcessPiSession;
|
||||
session: PaseoAgentSession;
|
||||
events: AgentStreamEvent[];
|
||||
mcpBridge: { closeCalls: number };
|
||||
} {
|
||||
const fakePi = new FakeInProcessPiSession();
|
||||
const handle = {
|
||||
session: fakePi,
|
||||
modelRegistry: { find: () => fakePi.model },
|
||||
resourceLoader: {},
|
||||
sessionManager: {},
|
||||
} as unknown as PaseoAgentSessionHandle;
|
||||
const mcpBridge = {
|
||||
tools: [],
|
||||
closeCalls: 0,
|
||||
async close() {
|
||||
this.closeCalls += 1;
|
||||
},
|
||||
};
|
||||
const session = new PaseoAgentSession(
|
||||
handle,
|
||||
sessionConfig(),
|
||||
mcpBridge as unknown as ConstructorParameters<typeof PaseoAgentSession>[2],
|
||||
null,
|
||||
[],
|
||||
);
|
||||
const events: AgentStreamEvent[] = [];
|
||||
session.subscribe((event) => events.push(event));
|
||||
return { fakePi, session, events, mcpBridge };
|
||||
}
|
||||
|
||||
describe("PaseoAgentClient", () => {
|
||||
const tempDirs: string[] = [];
|
||||
|
||||
afterEach(() => {
|
||||
for (const dir of tempDirs.splice(0)) {
|
||||
rmSync(dir, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
it("is available only when config has a usable model provider", async () => {
|
||||
const withConfig = new PaseoAgentClient({ logger: createTestLogger(), config: makeConfig() });
|
||||
expect(await withConfig.isAvailable()).toBe(true);
|
||||
|
||||
const empty = new PaseoAgentClient({
|
||||
logger: createTestLogger(),
|
||||
config: PaseoAgentConfigSchema.parse({}),
|
||||
});
|
||||
expect(await empty.isAvailable()).toBe(false);
|
||||
});
|
||||
|
||||
it("checks OAuth credentials in the configured Paseo home", async () => {
|
||||
const paseoHome = mkdtempSync(join(tmpdir(), "paseo-agent-client-"));
|
||||
const wrongHome = mkdtempSync(join(tmpdir(), "paseo-agent-wrong-home-"));
|
||||
tempDirs.push(paseoHome, wrongHome);
|
||||
const previousPaseoHome = process.env.PASEO_HOME;
|
||||
process.env.PASEO_HOME = wrongHome;
|
||||
storeOAuthCredential({
|
||||
providerInstance: "chatgpt",
|
||||
credential: { type: "oauth", access: "access-token", refresh: "refresh-token", expires: 0 },
|
||||
binding: { flow: "openai-codex", baseUrl: "https://chatgpt.com/backend-api" },
|
||||
env: { PASEO_HOME: paseoHome },
|
||||
});
|
||||
const config = PaseoAgentConfigSchema.parse({
|
||||
providers: {
|
||||
chatgpt: {
|
||||
type: "chatgpt",
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
try {
|
||||
const client = new PaseoAgentClient({ logger: createTestLogger(), config, paseoHome });
|
||||
expect(await client.isAvailable()).toBe(true);
|
||||
} finally {
|
||||
if (previousPaseoHome === undefined) {
|
||||
delete process.env.PASEO_HOME;
|
||||
} else {
|
||||
process.env.PASEO_HOME = previousPaseoHome;
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
it("does not treat an endpoint-mismatched OAuth credential as available", async () => {
|
||||
const paseoHome = mkdtempSync(join(tmpdir(), "paseo-agent-client-"));
|
||||
tempDirs.push(paseoHome);
|
||||
storeOAuthCredential({
|
||||
providerInstance: "chatgpt",
|
||||
credential: { type: "oauth", access: "access-token", refresh: "refresh-token", expires: 0 },
|
||||
binding: { flow: "openai-codex", baseUrl: "https://chatgpt.example.test/changed" },
|
||||
env: { PASEO_HOME: paseoHome },
|
||||
});
|
||||
const config = PaseoAgentConfigSchema.parse({
|
||||
providers: {
|
||||
chatgpt: {
|
||||
type: "chatgpt",
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
const client = new PaseoAgentClient({ logger: createTestLogger(), config, paseoHome });
|
||||
expect(await client.isAvailable()).toBe(false);
|
||||
});
|
||||
|
||||
it("lists only configured models, never Pi disk/default models", async () => {
|
||||
const client = new PaseoAgentClient({ logger: createTestLogger(), config: makeConfig() });
|
||||
const { models } = await client.fetchCatalog({
|
||||
scope: "workspace",
|
||||
cwd: process.cwd(),
|
||||
force: false,
|
||||
});
|
||||
expect(models.map((m) => m.id)).toEqual(["openrouter-main/test-model"]);
|
||||
expect(models[0]?.isDefault).toBe(true);
|
||||
});
|
||||
|
||||
it("throws when creating a session with no configured providers", async () => {
|
||||
const client = new PaseoAgentClient({
|
||||
logger: createTestLogger(),
|
||||
config: PaseoAgentConfigSchema.parse({}),
|
||||
});
|
||||
await expect(client.createSession(sessionConfig())).rejects.toThrow(/no configured/i);
|
||||
});
|
||||
|
||||
it("creates an in-process session bound to the configured model", async () => {
|
||||
const client = new PaseoAgentClient({ logger: createTestLogger(), config: makeConfig() });
|
||||
const session = await client.createSession(sessionConfig());
|
||||
try {
|
||||
expect(session.provider).toBe("paseo");
|
||||
const info = await session.getRuntimeInfo();
|
||||
expect(info.model).toBe("openrouter-main/test-model");
|
||||
// In-memory prototype: no durable persistence handle.
|
||||
expect(session.describePersistence()).toBeNull();
|
||||
} finally {
|
||||
await session.close();
|
||||
}
|
||||
});
|
||||
|
||||
it("honors an explicitly requested model over the default", async () => {
|
||||
const config = PaseoAgentConfigSchema.parse({
|
||||
defaultModel: "openrouter-main/a",
|
||||
providers: {
|
||||
"openrouter-main": {
|
||||
type: "openrouter",
|
||||
options: {
|
||||
baseUrl: "https://openrouter.ai/api/v1",
|
||||
apiKey: "sk-test",
|
||||
api: "openai-completions",
|
||||
models: [{ id: "a" }, { id: "b" }],
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
const client = new PaseoAgentClient({ logger: createTestLogger(), config });
|
||||
const session = await client.createSession(sessionConfig({ model: "openrouter-main/b" }));
|
||||
try {
|
||||
const info = await session.getRuntimeInfo();
|
||||
expect(info.model).toBe("openrouter-main/b");
|
||||
} finally {
|
||||
await session.close();
|
||||
}
|
||||
});
|
||||
|
||||
it("uses the selected agent as a model default", async () => {
|
||||
const paseoHome = mkdtempSync(join(tmpdir(), "paseo-agent-client-"));
|
||||
tempDirs.push(paseoHome);
|
||||
mkdirSync(join(paseoHome, "agents"), { recursive: true });
|
||||
writeFileSync(
|
||||
join(paseoHome, "agents", "orchestrator.md"),
|
||||
`---
|
||||
model: openrouter-main/b
|
||||
---
|
||||
Profile prompt.
|
||||
`,
|
||||
);
|
||||
const config = PaseoAgentConfigSchema.parse({
|
||||
defaultAgent: "orchestrator",
|
||||
providers: {
|
||||
"openrouter-main": {
|
||||
type: "openrouter",
|
||||
options: {
|
||||
baseUrl: "https://openrouter.ai/api/v1",
|
||||
apiKey: "sk-test",
|
||||
api: "openai-completions",
|
||||
models: [{ id: "a" }, { id: "b" }],
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
const client = new PaseoAgentClient({ logger: createTestLogger(), config, paseoHome });
|
||||
const session = await client.createSession(sessionConfig());
|
||||
try {
|
||||
const info = await session.getRuntimeInfo();
|
||||
expect(info.model).toBe("openrouter-main/b");
|
||||
} finally {
|
||||
await session.close();
|
||||
}
|
||||
});
|
||||
|
||||
it("prefers the selected agent model over the configured default model", async () => {
|
||||
const paseoHome = mkdtempSync(join(tmpdir(), "paseo-agent-client-"));
|
||||
tempDirs.push(paseoHome);
|
||||
mkdirSync(join(paseoHome, "agents"), { recursive: true });
|
||||
writeFileSync(
|
||||
join(paseoHome, "agents", "orchestrator.md"),
|
||||
`---
|
||||
model: openrouter-main/b
|
||||
---
|
||||
Profile prompt.
|
||||
`,
|
||||
);
|
||||
const config = PaseoAgentConfigSchema.parse({
|
||||
defaultAgent: "orchestrator",
|
||||
defaultModel: "openrouter-main/a",
|
||||
providers: {
|
||||
"openrouter-main": {
|
||||
type: "openrouter",
|
||||
options: {
|
||||
baseUrl: "https://openrouter.ai/api/v1",
|
||||
apiKey: "sk-test",
|
||||
api: "openai-completions",
|
||||
models: [{ id: "a" }, { id: "b" }],
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
const client = new PaseoAgentClient({ logger: createTestLogger(), config, paseoHome });
|
||||
const session = await client.createSession(sessionConfig());
|
||||
try {
|
||||
const info = await session.getRuntimeInfo();
|
||||
expect(info.model).toBe("openrouter-main/b");
|
||||
} finally {
|
||||
await session.close();
|
||||
}
|
||||
});
|
||||
|
||||
it("uses the requested mode as the selected agent definition", async () => {
|
||||
const paseoHome = mkdtempSync(join(tmpdir(), "paseo-agent-client-"));
|
||||
tempDirs.push(paseoHome);
|
||||
mkdirSync(join(paseoHome, "agents"), { recursive: true });
|
||||
writeFileSync(join(paseoHome, "agents", "builder.md"), "---\nname: Builder\n---\nBuild.");
|
||||
writeFileSync(
|
||||
join(paseoHome, "agents", "reviewer.md"),
|
||||
"---\nmodel: openrouter-main/b\n---\nReview.",
|
||||
);
|
||||
const config = PaseoAgentConfigSchema.parse({
|
||||
defaultAgent: "builder",
|
||||
providers: {
|
||||
"openrouter-main": {
|
||||
type: "openrouter",
|
||||
options: {
|
||||
baseUrl: "https://openrouter.ai/api/v1",
|
||||
apiKey: "sk-test",
|
||||
api: "openai-completions",
|
||||
models: [{ id: "a" }, { id: "b" }],
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
const client = new PaseoAgentClient({ logger: createTestLogger(), config, paseoHome });
|
||||
|
||||
const { modes } = await client.fetchCatalog({
|
||||
scope: "workspace",
|
||||
cwd: process.cwd(),
|
||||
force: false,
|
||||
});
|
||||
expect(modes).toEqual([
|
||||
{ id: "builder", label: "Builder" },
|
||||
{ id: "reviewer", label: "reviewer" },
|
||||
]);
|
||||
|
||||
const session = await client.createSession(sessionConfig({ modeId: "reviewer" }));
|
||||
try {
|
||||
const info = await session.getRuntimeInfo();
|
||||
expect(info.modeId).toBe("reviewer");
|
||||
expect(info.model).toBe("openrouter-main/b");
|
||||
await expect(session.getCurrentMode()).resolves.toBe("reviewer");
|
||||
} finally {
|
||||
await session.close();
|
||||
}
|
||||
});
|
||||
|
||||
it("warns when the configured profile expects a missing MCP server", async () => {
|
||||
const paseoHome = mkdtempSync(join(tmpdir(), "paseo-agent-client-"));
|
||||
tempDirs.push(paseoHome);
|
||||
mkdirSync(join(paseoHome, "agents"), { recursive: true });
|
||||
writeFileSync(
|
||||
join(paseoHome, "agents", "orchestrator.md"),
|
||||
`---
|
||||
mcp: [paseo, paseo]
|
||||
---
|
||||
Profile prompt.
|
||||
`,
|
||||
);
|
||||
const logger = createRecordingLogger();
|
||||
const client = new PaseoAgentClient({
|
||||
logger,
|
||||
config: PaseoAgentConfigSchema.parse({ ...makeConfig(), defaultAgent: "orchestrator" }),
|
||||
paseoHome,
|
||||
});
|
||||
const session = await client.createSession(sessionConfig());
|
||||
try {
|
||||
expect(logger.warnings).toEqual([
|
||||
{
|
||||
data: expect.objectContaining({ mcpServer: "paseo" }),
|
||||
message: expect.stringMatching(/expects an MCP server/i),
|
||||
},
|
||||
]);
|
||||
} finally {
|
||||
await session.close();
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe("PaseoAgentSession runtime events", () => {
|
||||
it("runs through a representative Pi event sequence", async () => {
|
||||
const { fakePi, session, events } = createPaseoProviderSession();
|
||||
const resultPromise = session.run("hello");
|
||||
|
||||
await Promise.resolve();
|
||||
fakePi.emit({ type: "agent_start" });
|
||||
fakePi.emit({ type: "turn_start" });
|
||||
fakePi.emit({
|
||||
type: "message_update",
|
||||
message: { role: "assistant", content: [] },
|
||||
assistantMessageEvent: { type: "thinking_delta", delta: "thinking" },
|
||||
});
|
||||
fakePi.emit({
|
||||
type: "tool_execution_start",
|
||||
toolCallId: "tool-1",
|
||||
toolName: "bash",
|
||||
args: { command: "pwd" },
|
||||
});
|
||||
fakePi.emit({
|
||||
type: "tool_execution_end",
|
||||
toolCallId: "tool-1",
|
||||
toolName: "bash",
|
||||
result: { output: "/tmp" },
|
||||
isError: false,
|
||||
});
|
||||
fakePi.emit({
|
||||
type: "message_update",
|
||||
message: { role: "assistant", content: [] },
|
||||
assistantMessageEvent: { type: "text_delta", delta: "done" },
|
||||
});
|
||||
fakePi.emit({ type: "agent_end", messages: [], willRetry: false });
|
||||
fakePi.promptDeferred.resolve();
|
||||
|
||||
await expect(resultPromise).resolves.toEqual({
|
||||
sessionId: "pi-session-1",
|
||||
finalText: "done",
|
||||
usage: {
|
||||
inputTokens: 3,
|
||||
cachedInputTokens: 2,
|
||||
outputTokens: 5,
|
||||
totalCostUsd: 0.01,
|
||||
contextWindowMaxTokens: 200000,
|
||||
contextWindowUsedTokens: 1234,
|
||||
},
|
||||
timeline: [
|
||||
{
|
||||
type: "user_message",
|
||||
text: "hello",
|
||||
messageId: expect.any(String),
|
||||
},
|
||||
{ type: "reasoning", text: "thinking" },
|
||||
{
|
||||
type: "tool_call",
|
||||
callId: "tool-1",
|
||||
name: "bash",
|
||||
status: "running",
|
||||
detail: { type: "shell", command: "pwd", output: undefined, exitCode: undefined },
|
||||
error: null,
|
||||
},
|
||||
{
|
||||
type: "tool_call",
|
||||
callId: "tool-1",
|
||||
name: "bash",
|
||||
status: "completed",
|
||||
detail: { type: "shell", command: "pwd", output: "/tmp", exitCode: null },
|
||||
error: null,
|
||||
},
|
||||
{ type: "assistant_message", text: "done" },
|
||||
],
|
||||
});
|
||||
expect(events.map((event) => event.type)).toEqual([
|
||||
"timeline",
|
||||
"thread_started",
|
||||
"turn_started",
|
||||
"timeline",
|
||||
"timeline",
|
||||
"timeline",
|
||||
"timeline",
|
||||
"turn_completed",
|
||||
]);
|
||||
});
|
||||
|
||||
it("keeps the active turn open when Pi agent_end says it will retry", async () => {
|
||||
const { fakePi, session, events } = createPaseoProviderSession();
|
||||
const resultPromise = session.run("retry please");
|
||||
|
||||
await Promise.resolve();
|
||||
fakePi.emit({
|
||||
type: "message_update",
|
||||
message: { role: "assistant", content: [] },
|
||||
assistantMessageEvent: { type: "text_delta", delta: "first attempt " },
|
||||
});
|
||||
fakePi.agent.state.errorMessage = "transient overflow";
|
||||
fakePi.emit({ type: "agent_end", messages: [], willRetry: true });
|
||||
expect(events.some((event) => event.type === "turn_failed")).toBe(false);
|
||||
expect(events.some((event) => event.type === "turn_completed")).toBe(false);
|
||||
|
||||
fakePi.agent.state.errorMessage = "";
|
||||
fakePi.emit({
|
||||
type: "message_update",
|
||||
message: { role: "assistant", content: [] },
|
||||
assistantMessageEvent: { type: "text_delta", delta: "retry success" },
|
||||
});
|
||||
fakePi.emit({ type: "agent_end", messages: [], willRetry: false });
|
||||
fakePi.promptDeferred.resolve();
|
||||
|
||||
await expect(resultPromise).resolves.toMatchObject({
|
||||
finalText: "first attempt retry success",
|
||||
});
|
||||
expect(events.filter((event) => event.type === "turn_completed")).toHaveLength(1);
|
||||
});
|
||||
|
||||
it("maps interrupt aborts to clean turn cancellation", async () => {
|
||||
const { fakePi, session, events } = createPaseoProviderSession();
|
||||
const resultPromise = session.run("cancel me");
|
||||
|
||||
await Promise.resolve();
|
||||
await session.interrupt();
|
||||
|
||||
await expect(resultPromise).resolves.toMatchObject({
|
||||
sessionId: "pi-session-1",
|
||||
finalText: "",
|
||||
timeline: [
|
||||
{
|
||||
type: "user_message",
|
||||
text: "cancel me",
|
||||
messageId: expect.any(String),
|
||||
},
|
||||
],
|
||||
});
|
||||
expect(fakePi.abortCalls).toBe(1);
|
||||
expect(events).toContainEqual({
|
||||
type: "turn_canceled",
|
||||
provider: "paseo",
|
||||
turnId: expect.any(String),
|
||||
reason: "interrupted",
|
||||
});
|
||||
expect(events.some((event) => event.type === "turn_failed")).toBe(false);
|
||||
});
|
||||
|
||||
it("aborts an active turn before close and close is idempotent", async () => {
|
||||
const { fakePi, session, events, mcpBridge } = createPaseoProviderSession();
|
||||
await session.startTurn("close me");
|
||||
|
||||
await Promise.all([session.close(), session.close()]);
|
||||
|
||||
expect(fakePi.abortCalls).toBe(1);
|
||||
expect(fakePi.disposeCalls).toBe(1);
|
||||
expect(mcpBridge.closeCalls).toBe(1);
|
||||
expect(events).toContainEqual({
|
||||
type: "turn_canceled",
|
||||
provider: "paseo",
|
||||
turnId: expect.any(String),
|
||||
reason: "interrupted",
|
||||
});
|
||||
expect(events.some((event) => event.type === "turn_failed")).toBe(false);
|
||||
});
|
||||
});
|
||||
@@ -1,731 +0,0 @@
|
||||
import { randomUUID } from "node:crypto";
|
||||
import { tmpdir } from "node:os";
|
||||
import { join } from "node:path";
|
||||
import type { Logger } from "pino";
|
||||
import type { ThinkingLevel } from "@earendil-works/pi-agent-core";
|
||||
import type { AgentSessionEvent } from "@earendil-works/pi-coding-agent";
|
||||
|
||||
import {
|
||||
type AgentCapabilityFlags,
|
||||
type AgentClient,
|
||||
type AgentLaunchContext,
|
||||
type AgentMode,
|
||||
type AgentPermissionRequest,
|
||||
type AgentPermissionResponse,
|
||||
type AgentPersistenceHandle,
|
||||
type AgentPromptInput,
|
||||
type AgentRunOptions,
|
||||
type AgentRunResult,
|
||||
type AgentRuntimeInfo,
|
||||
type AgentSession,
|
||||
type AgentSessionConfig,
|
||||
type AgentSlashCommand,
|
||||
type AgentStreamEvent,
|
||||
type FetchCatalogOptions,
|
||||
type ProviderCatalog,
|
||||
} from "../../agent-sdk-types.js";
|
||||
import { appendOrReplaceGrowingAssistantMessage, runProviderTurn } from "../provider-runner.js";
|
||||
import {
|
||||
PASEO_AGENT_PROVIDER,
|
||||
type PaseoAgentConfig,
|
||||
listPaseoAgentModels,
|
||||
paseoAgentHasUsableModel,
|
||||
paseoAgentModelProviders,
|
||||
parsePaseoAgentModelId,
|
||||
resolvePaseoAgentModel,
|
||||
} from "./config.js";
|
||||
import {
|
||||
convertPromptInput,
|
||||
getUserMessageText,
|
||||
mapToolDetail,
|
||||
parseToolArgs,
|
||||
parseToolResult,
|
||||
toAgentUsage,
|
||||
type PiTrackedToolCall,
|
||||
} from "./event-mapping.js";
|
||||
import { createMcpToolBridge, type McpToolBridge } from "./mcp-bridge.js";
|
||||
import {
|
||||
createBoundPaseoAgentAuthStorage,
|
||||
hasStoredOAuthCredential,
|
||||
type OAuthCredentialBinding,
|
||||
} from "./oauth-store.js";
|
||||
import { createPaseoAgentSession, type PaseoAgentSessionHandle } from "./pi-services.js";
|
||||
import { createToolPermissionPolicy } from "./agent-permissions.js";
|
||||
import {
|
||||
composePromptParts,
|
||||
listAgentDefinitionIds,
|
||||
loadAgentDefinition,
|
||||
type ResolvedAgentDefinition,
|
||||
} from "./prompt-profiles.js";
|
||||
|
||||
const DEFAULT_THINKING_LEVEL: ThinkingLevel = "medium";
|
||||
|
||||
const PASEO_AGENT_CAPABILITIES: AgentCapabilityFlags = {
|
||||
supportsStreaming: true,
|
||||
// Phase 4 uses in-memory sessions; resume/persistence is out of scope.
|
||||
supportsSessionPersistence: false,
|
||||
supportsDynamicModes: false,
|
||||
// MCP servers from AgentSessionConfig.mcpServers are bridged to Pi custom tools.
|
||||
supportsMcpServers: true,
|
||||
requiresPaseoTools: true,
|
||||
supportsReasoningStream: true,
|
||||
supportsToolInvocations: true,
|
||||
};
|
||||
|
||||
const THINKING_LEVELS: ReadonlySet<ThinkingLevel> = new Set<ThinkingLevel>([
|
||||
"off",
|
||||
"minimal",
|
||||
"low",
|
||||
"medium",
|
||||
"high",
|
||||
"xhigh",
|
||||
]);
|
||||
|
||||
function normalizeThinkingLevel(value: string | null | undefined): ThinkingLevel | null {
|
||||
return value && THINKING_LEVELS.has(value as ThinkingLevel) ? (value as ThinkingLevel) : null;
|
||||
}
|
||||
|
||||
function resolveIsolatedAgentDir(): string {
|
||||
// Paseo-owned, never ~/.pi. Inert because all Pi services are in-memory, but we
|
||||
// still keep it off the project tree and outside Pi's default global config.
|
||||
const base = process.env.PASEO_HOME ?? join(tmpdir(), "paseo-agent");
|
||||
return join(base, "pi-harness");
|
||||
}
|
||||
|
||||
function envForPaseoHome(paseoHome: string | undefined): NodeJS.ProcessEnv {
|
||||
return paseoHome ? { ...process.env, PASEO_HOME: paseoHome } : process.env;
|
||||
}
|
||||
|
||||
function errorToMessage(error: unknown): string {
|
||||
return error instanceof Error ? error.message : String(error);
|
||||
}
|
||||
|
||||
function isAbortError(error: unknown): boolean {
|
||||
if (error instanceof Error && error.name === "AbortError") {
|
||||
return true;
|
||||
}
|
||||
return /\brequest was aborted\b|\babort(ed)?\b/i.test(errorToMessage(error));
|
||||
}
|
||||
|
||||
function oauthCredentialBindings(
|
||||
providers: Awaited<ReturnType<typeof paseoAgentModelProviders>>,
|
||||
): Record<string, OAuthCredentialBinding> {
|
||||
const bindings: Record<string, OAuthCredentialBinding> = {};
|
||||
for (const provider of providers) {
|
||||
if (provider.oauth) {
|
||||
bindings[provider.name] = {
|
||||
flow: provider.oauth.flow,
|
||||
baseUrl: provider.config.baseUrl ?? "",
|
||||
};
|
||||
}
|
||||
}
|
||||
return bindings;
|
||||
}
|
||||
|
||||
interface PaseoAgentClientOptions {
|
||||
logger: Logger;
|
||||
config: PaseoAgentConfig;
|
||||
paseoHome?: string;
|
||||
}
|
||||
|
||||
export class PaseoAgentSession implements AgentSession {
|
||||
readonly provider = PASEO_AGENT_PROVIDER;
|
||||
readonly capabilities = PASEO_AGENT_CAPABILITIES;
|
||||
|
||||
private readonly subscribers = new Set<(event: AgentStreamEvent) => void>();
|
||||
private readonly activeToolCalls = new Map<string, PiTrackedToolCall>();
|
||||
private activeTurnId: string | null = null;
|
||||
private lastThinkingOptionId: string | null;
|
||||
private closePromise: Promise<void> | null = null;
|
||||
|
||||
constructor(
|
||||
private readonly handle: PaseoAgentSessionHandle,
|
||||
private readonly config: AgentSessionConfig,
|
||||
private readonly mcpBridge: McpToolBridge,
|
||||
private readonly agentId: string | null,
|
||||
private readonly availableAgents: AgentMode[],
|
||||
) {
|
||||
this.lastThinkingOptionId =
|
||||
normalizeThinkingLevel(config.thinkingOptionId) ?? this.piSession.thinkingLevel ?? null;
|
||||
this.piSession.subscribe((event) => this.handleSessionEvent(event));
|
||||
}
|
||||
|
||||
private get piSession() {
|
||||
return this.handle.session;
|
||||
}
|
||||
|
||||
get id(): string | null {
|
||||
return this.piSession.sessionId;
|
||||
}
|
||||
|
||||
private emit(event: AgentStreamEvent): void {
|
||||
for (const subscriber of this.subscribers) {
|
||||
subscriber(event);
|
||||
}
|
||||
}
|
||||
|
||||
private clearActiveTurn(): string | null {
|
||||
const turnId = this.activeTurnId;
|
||||
this.activeTurnId = null;
|
||||
this.activeToolCalls.clear();
|
||||
return turnId;
|
||||
}
|
||||
|
||||
private emitActiveTurnCanceled(reason: string): boolean {
|
||||
const turnId = this.clearActiveTurn();
|
||||
if (!turnId) {
|
||||
return false;
|
||||
}
|
||||
this.emit({
|
||||
type: "turn_canceled",
|
||||
provider: PASEO_AGENT_PROVIDER,
|
||||
turnId,
|
||||
reason,
|
||||
});
|
||||
return true;
|
||||
}
|
||||
|
||||
private emitToolCall(
|
||||
toolCallId: string,
|
||||
toolCall: PiTrackedToolCall,
|
||||
status: "running" | "completed" | "failed",
|
||||
result: ReturnType<typeof parseToolResult>,
|
||||
error: unknown,
|
||||
): void {
|
||||
const turnId = this.activeTurnId ?? undefined;
|
||||
const baseItem = {
|
||||
type: "tool_call" as const,
|
||||
callId: toolCallId,
|
||||
name: toolCall.toolName,
|
||||
detail: mapToolDetail(toolCall, result),
|
||||
};
|
||||
const item =
|
||||
status === "failed" ? { ...baseItem, status, error } : { ...baseItem, status, error: null };
|
||||
this.emit({ type: "timeline", provider: PASEO_AGENT_PROVIDER, turnId, item });
|
||||
}
|
||||
|
||||
private handleSessionEvent(event: AgentSessionEvent): void {
|
||||
const turnId = this.activeTurnId ?? undefined;
|
||||
switch (event.type) {
|
||||
case "agent_start":
|
||||
this.emit({
|
||||
type: "thread_started",
|
||||
provider: PASEO_AGENT_PROVIDER,
|
||||
sessionId: this.piSession.sessionId,
|
||||
});
|
||||
return;
|
||||
case "turn_start":
|
||||
this.emit({ type: "turn_started", provider: PASEO_AGENT_PROVIDER, turnId });
|
||||
return;
|
||||
case "message_update":
|
||||
this.handleMessageUpdate(event, turnId);
|
||||
return;
|
||||
case "tool_execution_start": {
|
||||
const toolCall = parseToolArgs(event.toolName, event.args);
|
||||
this.activeToolCalls.set(event.toolCallId, toolCall);
|
||||
this.emitToolCall(event.toolCallId, toolCall, "running", null, null);
|
||||
return;
|
||||
}
|
||||
case "tool_execution_update": {
|
||||
const toolCall = this.activeToolCalls.get(event.toolCallId);
|
||||
if (!toolCall) {
|
||||
return;
|
||||
}
|
||||
this.emitToolCall(
|
||||
event.toolCallId,
|
||||
toolCall,
|
||||
"running",
|
||||
parseToolResult(event.partialResult),
|
||||
null,
|
||||
);
|
||||
return;
|
||||
}
|
||||
case "tool_execution_end": {
|
||||
const toolCall =
|
||||
this.activeToolCalls.get(event.toolCallId) ?? parseToolArgs(event.toolName, null);
|
||||
this.activeToolCalls.delete(event.toolCallId);
|
||||
const result = parseToolResult(event.result);
|
||||
const status = event.isError ? "failed" : "completed";
|
||||
this.emitToolCall(
|
||||
event.toolCallId,
|
||||
toolCall,
|
||||
status,
|
||||
result,
|
||||
event.isError ? event.result : null,
|
||||
);
|
||||
return;
|
||||
}
|
||||
case "agent_end":
|
||||
this.handleAgentEnd(event);
|
||||
return;
|
||||
default:
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
private handleMessageUpdate(
|
||||
event: Extract<AgentSessionEvent, { type: "message_update" }>,
|
||||
turnId: string | undefined,
|
||||
): void {
|
||||
if (event.message.role !== "assistant") {
|
||||
return;
|
||||
}
|
||||
if (event.assistantMessageEvent.type === "text_delta") {
|
||||
this.emit({
|
||||
type: "timeline",
|
||||
provider: PASEO_AGENT_PROVIDER,
|
||||
turnId,
|
||||
item: { type: "assistant_message", text: event.assistantMessageEvent.delta ?? "" },
|
||||
});
|
||||
return;
|
||||
}
|
||||
if (event.assistantMessageEvent.type === "thinking_delta") {
|
||||
this.emit({
|
||||
type: "timeline",
|
||||
provider: PASEO_AGENT_PROVIDER,
|
||||
turnId,
|
||||
item: { type: "reasoning", text: event.assistantMessageEvent.delta ?? "" },
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
private handleAgentEnd(event: Extract<AgentSessionEvent, { type: "agent_end" }>): void {
|
||||
if (event.willRetry) {
|
||||
return;
|
||||
}
|
||||
const usage = toAgentUsage(this.piSession.getSessionStats());
|
||||
const currentTurnId = this.clearActiveTurn() ?? undefined;
|
||||
if (!currentTurnId) {
|
||||
return;
|
||||
}
|
||||
const errorMessage = this.piSession.agent.state.errorMessage;
|
||||
if (errorMessage) {
|
||||
this.emit({
|
||||
type: "turn_failed",
|
||||
provider: PASEO_AGENT_PROVIDER,
|
||||
turnId: currentTurnId,
|
||||
error: errorMessage,
|
||||
});
|
||||
return;
|
||||
}
|
||||
this.emit({
|
||||
type: "turn_completed",
|
||||
provider: PASEO_AGENT_PROVIDER,
|
||||
turnId: currentTurnId,
|
||||
usage,
|
||||
});
|
||||
}
|
||||
|
||||
private emitSubmittedUserMessage(text: string, messageId: string, turnId: string): void {
|
||||
if (text.trim().length === 0) {
|
||||
return;
|
||||
}
|
||||
this.emit({
|
||||
type: "timeline",
|
||||
provider: PASEO_AGENT_PROVIDER,
|
||||
turnId,
|
||||
item: { type: "user_message", text, messageId },
|
||||
});
|
||||
}
|
||||
|
||||
async run(prompt: AgentPromptInput, options?: AgentRunOptions): Promise<AgentRunResult> {
|
||||
return runProviderTurn({
|
||||
prompt,
|
||||
runOptions: options,
|
||||
startTurn: (p, o) => this.startTurn(p, o),
|
||||
subscribe: (callback) => this.subscribe(callback),
|
||||
getSessionId: () => this.piSession.sessionId,
|
||||
reduceFinalText: appendOrReplaceGrowingAssistantMessage,
|
||||
});
|
||||
}
|
||||
|
||||
async startTurn(
|
||||
prompt: AgentPromptInput,
|
||||
options?: AgentRunOptions,
|
||||
): Promise<{ turnId: string }> {
|
||||
if (this.activeTurnId) {
|
||||
throw new Error("A Paseo Agent turn is already active");
|
||||
}
|
||||
const payload = convertPromptInput(prompt);
|
||||
const turnId = randomUUID();
|
||||
const messageId = options?.messageId ?? randomUUID();
|
||||
this.activeTurnId = turnId;
|
||||
this.emitSubmittedUserMessage(payload.text, messageId, turnId);
|
||||
|
||||
void this.piSession
|
||||
.prompt(payload.text, payload.images ? { images: payload.images } : undefined)
|
||||
.catch((error: unknown) => {
|
||||
if (this.activeTurnId !== turnId) {
|
||||
return;
|
||||
}
|
||||
if (isAbortError(error)) {
|
||||
this.emitActiveTurnCanceled(errorToMessage(error));
|
||||
return;
|
||||
}
|
||||
const failedTurnId = this.clearActiveTurn() ?? turnId;
|
||||
this.emit({
|
||||
type: "turn_failed",
|
||||
provider: PASEO_AGENT_PROVIDER,
|
||||
turnId: failedTurnId,
|
||||
error: errorToMessage(error),
|
||||
});
|
||||
});
|
||||
|
||||
return { turnId };
|
||||
}
|
||||
|
||||
subscribe(callback: (event: AgentStreamEvent) => void): () => void {
|
||||
this.subscribers.add(callback);
|
||||
return () => {
|
||||
this.subscribers.delete(callback);
|
||||
};
|
||||
}
|
||||
|
||||
async *streamHistory(): AsyncGenerator<AgentStreamEvent> {
|
||||
const pendingToolCalls = new Map<string, PiTrackedToolCall>();
|
||||
let userIndex = 0;
|
||||
|
||||
for (const message of this.piSession.messages) {
|
||||
if (message.role === "user") {
|
||||
const text = getUserMessageText(message.content);
|
||||
if (text) {
|
||||
yield {
|
||||
type: "timeline",
|
||||
provider: PASEO_AGENT_PROVIDER,
|
||||
item: { type: "user_message", text, messageId: `paseo-agent-user-${userIndex}` },
|
||||
};
|
||||
}
|
||||
userIndex += 1;
|
||||
continue;
|
||||
}
|
||||
if (message.role === "assistant") {
|
||||
for (const content of message.content) {
|
||||
if (content.type === "text" && content.text) {
|
||||
yield {
|
||||
type: "timeline",
|
||||
provider: PASEO_AGENT_PROVIDER,
|
||||
item: { type: "assistant_message", text: content.text },
|
||||
};
|
||||
continue;
|
||||
}
|
||||
if (content.type === "thinking" && content.thinking) {
|
||||
yield {
|
||||
type: "timeline",
|
||||
provider: PASEO_AGENT_PROVIDER,
|
||||
item: { type: "reasoning", text: content.thinking },
|
||||
};
|
||||
continue;
|
||||
}
|
||||
if (content.type === "toolCall") {
|
||||
const tracked = parseToolArgs(content.name, content.arguments);
|
||||
pendingToolCalls.set(content.id, tracked);
|
||||
yield {
|
||||
type: "timeline",
|
||||
provider: PASEO_AGENT_PROVIDER,
|
||||
item: {
|
||||
type: "tool_call",
|
||||
callId: content.id,
|
||||
name: tracked.toolName,
|
||||
status: "running",
|
||||
detail: mapToolDetail(tracked, null),
|
||||
error: null,
|
||||
},
|
||||
};
|
||||
}
|
||||
}
|
||||
continue;
|
||||
}
|
||||
if (message.role === "toolResult") {
|
||||
const tracked =
|
||||
pendingToolCalls.get(message.toolCallId) ?? parseToolArgs(message.toolName, null);
|
||||
pendingToolCalls.delete(message.toolCallId);
|
||||
const detail = mapToolDetail(tracked, parseToolResult({ content: message.content }));
|
||||
const base = {
|
||||
type: "tool_call" as const,
|
||||
callId: message.toolCallId,
|
||||
name: tracked.toolName,
|
||||
detail,
|
||||
};
|
||||
yield {
|
||||
type: "timeline",
|
||||
provider: PASEO_AGENT_PROVIDER,
|
||||
item: message.isError
|
||||
? { ...base, status: "failed", error: "Tool call failed" }
|
||||
: { ...base, status: "completed", error: null },
|
||||
};
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async getRuntimeInfo(): Promise<AgentRuntimeInfo> {
|
||||
const model = this.piSession.model;
|
||||
return {
|
||||
provider: PASEO_AGENT_PROVIDER,
|
||||
sessionId: this.piSession.sessionId,
|
||||
model: model ? `${model.provider}/${model.id}` : null,
|
||||
thinkingOptionId:
|
||||
normalizeThinkingLevel(this.lastThinkingOptionId) ?? this.piSession.thinkingLevel ?? null,
|
||||
modeId: this.agentId,
|
||||
};
|
||||
}
|
||||
|
||||
async getAvailableModes(): Promise<AgentMode[]> {
|
||||
return this.availableAgents;
|
||||
}
|
||||
|
||||
async getCurrentMode(): Promise<string | null> {
|
||||
return this.agentId;
|
||||
}
|
||||
|
||||
async setMode(modeId: string): Promise<void> {
|
||||
if (modeId === this.agentId) {
|
||||
return;
|
||||
}
|
||||
throw new Error("Paseo Agent selection is fixed when the session starts");
|
||||
}
|
||||
|
||||
getPendingPermissions(): AgentPermissionRequest[] {
|
||||
return [];
|
||||
}
|
||||
|
||||
async respondToPermission(
|
||||
_requestId: string,
|
||||
_response: AgentPermissionResponse,
|
||||
): Promise<void> {}
|
||||
|
||||
describePersistence(): AgentPersistenceHandle | null {
|
||||
return null;
|
||||
}
|
||||
|
||||
async interrupt(): Promise<void> {
|
||||
const canceled = this.emitActiveTurnCanceled("interrupted");
|
||||
try {
|
||||
await this.piSession.abort();
|
||||
} catch (error) {
|
||||
if (!canceled || !isAbortError(error)) {
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async close(): Promise<void> {
|
||||
this.closePromise ??= (async () => {
|
||||
try {
|
||||
if (this.activeTurnId) {
|
||||
await this.interrupt();
|
||||
}
|
||||
} finally {
|
||||
this.piSession.dispose();
|
||||
await this.mcpBridge.close();
|
||||
}
|
||||
})();
|
||||
await this.closePromise;
|
||||
}
|
||||
|
||||
async listCommands(): Promise<AgentSlashCommand[]> {
|
||||
return [];
|
||||
}
|
||||
|
||||
async setModel(modelId: string | null): Promise<void> {
|
||||
if (!modelId) {
|
||||
return;
|
||||
}
|
||||
const reference = parsePaseoAgentModelId(modelId);
|
||||
if (!reference) {
|
||||
throw new Error(`Invalid Paseo Agent model: ${modelId}`);
|
||||
}
|
||||
const model = this.handle.modelRegistry.find(reference.provider, reference.id);
|
||||
if (!model) {
|
||||
throw new Error(`Unknown Paseo Agent model: ${modelId}`);
|
||||
}
|
||||
await this.piSession.setModel(model);
|
||||
this.config.model = modelId;
|
||||
}
|
||||
|
||||
async setThinkingOption(thinkingOptionId: string | null): Promise<void> {
|
||||
const level = normalizeThinkingLevel(thinkingOptionId) ?? DEFAULT_THINKING_LEVEL;
|
||||
this.piSession.setThinkingLevel(level);
|
||||
this.lastThinkingOptionId = level;
|
||||
this.config.thinkingOptionId = level;
|
||||
}
|
||||
}
|
||||
|
||||
export class PaseoAgentClient implements AgentClient {
|
||||
readonly provider = PASEO_AGENT_PROVIDER;
|
||||
readonly capabilities = PASEO_AGENT_CAPABILITIES;
|
||||
|
||||
private readonly logger: Logger;
|
||||
private readonly config: PaseoAgentConfig;
|
||||
private readonly paseoHome: string | undefined;
|
||||
|
||||
constructor(options: PaseoAgentClientOptions) {
|
||||
this.logger = options.logger;
|
||||
this.config = options.config;
|
||||
this.paseoHome = options.paseoHome;
|
||||
}
|
||||
|
||||
async createSession(
|
||||
config: AgentSessionConfig,
|
||||
_launchContext?: AgentLaunchContext,
|
||||
): Promise<AgentSession> {
|
||||
const modelProviders = await paseoAgentModelProviders(this.config);
|
||||
if (modelProviders.length === 0) {
|
||||
throw new Error(
|
||||
"Paseo Agent has no configured model providers. Add agents.paseo.providers to your Paseo config.",
|
||||
);
|
||||
}
|
||||
|
||||
const availableAgents = this.loadAvailableAgentModes();
|
||||
const agent = this.loadSelectedAgent(config.modeId);
|
||||
this.verifyExpectedMcpServers(agent, config.mcpServers);
|
||||
const model = resolvePaseoAgentModel(this.config, config.model, modelProviders, agent?.model);
|
||||
const thinkingLevel = normalizeThinkingLevel(config.thinkingOptionId) ?? undefined;
|
||||
const composedPrompt = composePromptParts({
|
||||
agent,
|
||||
systemPrompt: config.systemPrompt,
|
||||
daemonAppendSystemPrompt: config.daemonAppendSystemPrompt,
|
||||
});
|
||||
this.logger.debug(
|
||||
{
|
||||
provider: PASEO_AGENT_PROVIDER,
|
||||
model: model ? `${model.provider}/${model.id}` : null,
|
||||
agent: agent?.id ?? null,
|
||||
},
|
||||
"Creating Paseo Agent session",
|
||||
);
|
||||
|
||||
// Catalog OAuth providers use a Paseo-owned, file-backed AuthStorage so Pi reads
|
||||
// the stored credential and persists refreshed tokens (rotation) back to it.
|
||||
const usesOAuth = modelProviders.some((provider) => provider.oauth);
|
||||
const authStorage = usesOAuth
|
||||
? createBoundPaseoAgentAuthStorage(
|
||||
oauthCredentialBindings(modelProviders),
|
||||
envForPaseoHome(this.paseoHome),
|
||||
)
|
||||
: undefined;
|
||||
|
||||
// Bridge Paseo-injected MCP servers (e.g. the `paseo` HTTP server) into Pi tools.
|
||||
const permissionPolicy = createToolPermissionPolicy(agent?.permissions);
|
||||
const mcpBridge = await createMcpToolBridge({
|
||||
mcpServers: config.mcpServers,
|
||||
logger: this.logger,
|
||||
});
|
||||
|
||||
try {
|
||||
const handle = await createPaseoAgentSession({
|
||||
cwd: config.cwd,
|
||||
agentDir: resolveIsolatedAgentDir(),
|
||||
modelProviders,
|
||||
...(model ? { model } : {}),
|
||||
...(thinkingLevel ? { thinkingLevel } : {}),
|
||||
...(authStorage ? { authStorage } : {}),
|
||||
...(mcpBridge.tools.length > 0 ? { customTools: mcpBridge.tools } : {}),
|
||||
...(agent?.tools ? { tools: agent.tools } : {}),
|
||||
permissionPolicy,
|
||||
...(composedPrompt ? { composedPrompt } : {}),
|
||||
});
|
||||
return new PaseoAgentSession(handle, config, mcpBridge, agent?.id ?? null, availableAgents);
|
||||
} catch (error) {
|
||||
await mcpBridge.close();
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
async resumeSession(): Promise<AgentSession> {
|
||||
throw new Error("Paseo Agent does not support session resume in this prototype");
|
||||
}
|
||||
|
||||
async fetchCatalog(_options: FetchCatalogOptions): Promise<ProviderCatalog> {
|
||||
return {
|
||||
models: listPaseoAgentModels(this.config),
|
||||
modes: await this.loadAvailableAgentModes(),
|
||||
};
|
||||
}
|
||||
|
||||
async isAvailable(): Promise<boolean> {
|
||||
const env = envForPaseoHome(this.paseoHome);
|
||||
return paseoAgentHasUsableModel(this.config, env, (providerInstance, binding) =>
|
||||
hasStoredOAuthCredential(providerInstance, env, binding),
|
||||
);
|
||||
}
|
||||
|
||||
private loadSelectedAgent(requestedAgentId: string | undefined): ResolvedAgentDefinition | null {
|
||||
const selectedAgentId =
|
||||
requestedAgentId ?? this.config.defaultAgent ?? this.config.defaultProfile;
|
||||
if (!this.paseoHome || !selectedAgentId) {
|
||||
return null;
|
||||
}
|
||||
try {
|
||||
const agent = loadAgentDefinition(this.paseoHome, selectedAgentId);
|
||||
if (!agent) {
|
||||
this.logger.warn(
|
||||
{ provider: PASEO_AGENT_PROVIDER, agent: selectedAgentId },
|
||||
"Configured Paseo Agent definition was not found",
|
||||
);
|
||||
}
|
||||
return agent;
|
||||
} catch (error) {
|
||||
this.logger.warn(
|
||||
{
|
||||
provider: PASEO_AGENT_PROVIDER,
|
||||
agent: selectedAgentId,
|
||||
error: error instanceof Error ? error.message : String(error),
|
||||
},
|
||||
"Configured Paseo Agent definition could not be loaded",
|
||||
);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
private loadAvailableAgentModes(): AgentMode[] {
|
||||
const paseoHome = this.paseoHome;
|
||||
if (!paseoHome) {
|
||||
return [];
|
||||
}
|
||||
return listAgentDefinitionIds(paseoHome).flatMap((agentId): AgentMode[] => {
|
||||
try {
|
||||
const agent = loadAgentDefinition(paseoHome, agentId);
|
||||
if (!agent) {
|
||||
return [];
|
||||
}
|
||||
return [
|
||||
{
|
||||
id: agent.id,
|
||||
label: agent.frontmatter.name ?? agent.id,
|
||||
...(agent.frontmatter.description
|
||||
? { description: agent.frontmatter.description }
|
||||
: {}),
|
||||
},
|
||||
];
|
||||
} catch (error) {
|
||||
this.logger.warn(
|
||||
{
|
||||
provider: PASEO_AGENT_PROVIDER,
|
||||
agent: agentId,
|
||||
error: error instanceof Error ? error.message : String(error),
|
||||
},
|
||||
"Paseo Agent definition could not be listed",
|
||||
);
|
||||
return [];
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
private verifyExpectedMcpServers(
|
||||
agent: ResolvedAgentDefinition | null,
|
||||
configuredServers: AgentSessionConfig["mcpServers"],
|
||||
): void {
|
||||
for (const serverName of new Set(agent?.expectedMcpServers ?? [])) {
|
||||
if (!configuredServers?.[serverName]) {
|
||||
this.logger.warn(
|
||||
{
|
||||
provider: PASEO_AGENT_PROVIDER,
|
||||
agent: agent?.id ?? null,
|
||||
mcpServer: serverName,
|
||||
},
|
||||
"Paseo Agent definition expects an MCP server that is not configured for this session",
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,84 +0,0 @@
|
||||
import type { KnownProvider } from "@earendil-works/pi-ai";
|
||||
|
||||
export interface PaseoAgentKeyAuthHint {
|
||||
kind: "api_key";
|
||||
envVar: string;
|
||||
keyUrl?: string;
|
||||
placeholder?: string;
|
||||
hint?: string;
|
||||
}
|
||||
|
||||
export interface PaseoAgentOAuthAuthHint {
|
||||
kind: "oauth";
|
||||
flow?: string;
|
||||
}
|
||||
|
||||
export type PaseoAgentCatalogAuthHint = PaseoAgentKeyAuthHint | PaseoAgentOAuthAuthHint;
|
||||
|
||||
export interface PaseoAgentCatalogRef {
|
||||
id: string;
|
||||
piProvider: KnownProvider;
|
||||
label: string;
|
||||
iconName?: string;
|
||||
docsUrl?: string;
|
||||
auth?: PaseoAgentCatalogAuthHint;
|
||||
defaultModels?: boolean;
|
||||
}
|
||||
|
||||
export const PASEO_AGENT_PROVIDER_CATALOG = [
|
||||
{
|
||||
id: "openrouter",
|
||||
piProvider: "openrouter",
|
||||
label: "OpenRouter",
|
||||
iconName: "openrouter",
|
||||
auth: { kind: "api_key", envVar: "OPENROUTER_API_KEY" },
|
||||
defaultModels: false,
|
||||
},
|
||||
{
|
||||
id: "chatgpt",
|
||||
piProvider: "openai-codex",
|
||||
label: "ChatGPT",
|
||||
iconName: "openai",
|
||||
},
|
||||
{
|
||||
id: "kimi",
|
||||
piProvider: "kimi-coding",
|
||||
label: "Kimi Coding Plan",
|
||||
iconName: "kimi",
|
||||
auth: { kind: "api_key", envVar: "KIMI_API_KEY" },
|
||||
},
|
||||
{
|
||||
id: "opencode-go",
|
||||
piProvider: "opencode-go",
|
||||
label: "OpenCode Go",
|
||||
iconName: "opencode",
|
||||
auth: { kind: "api_key", envVar: "OPENCODE_API_KEY" },
|
||||
},
|
||||
] as const satisfies readonly PaseoAgentCatalogRef[];
|
||||
|
||||
const PASEO_AGENT_PROVIDER_ALIASES: Record<string, string> = {
|
||||
"openai-codex": "chatgpt",
|
||||
};
|
||||
|
||||
export function resolvePaseoAgentCatalogEntry(
|
||||
providerType: string,
|
||||
): PaseoAgentCatalogRef | undefined {
|
||||
const canonicalId = PASEO_AGENT_PROVIDER_ALIASES[providerType] ?? providerType;
|
||||
return PASEO_AGENT_PROVIDER_CATALOG.find((entry) => entry.id === canonicalId);
|
||||
}
|
||||
|
||||
export function knownPaseoAgentCatalogIds(): string[] {
|
||||
return PASEO_AGENT_PROVIDER_CATALOG.map((entry) => entry.id);
|
||||
}
|
||||
|
||||
export function unknownPaseoAgentProviderTypeMessage(providerType: string): string {
|
||||
return `Unknown model provider type "${providerType}". Known provider ids: ${knownPaseoAgentCatalogIds().join(", ")}. Update the host if this provider is newer than it.`;
|
||||
}
|
||||
|
||||
export function requirePaseoAgentCatalogEntry(providerType: string): PaseoAgentCatalogRef {
|
||||
const entry = resolvePaseoAgentCatalogEntry(providerType);
|
||||
if (!entry) {
|
||||
throw new Error(unknownPaseoAgentProviderTypeMessage(providerType));
|
||||
}
|
||||
return entry;
|
||||
}
|
||||
@@ -1,436 +0,0 @@
|
||||
import { mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs";
|
||||
import { tmpdir } from "node:os";
|
||||
import { join } from "node:path";
|
||||
import { getModels } from "@earendil-works/pi-ai";
|
||||
import { afterEach, beforeEach, describe, expect, test, vi } from "vitest";
|
||||
|
||||
import { createTestLogger } from "../../../../test-utils/test-logger.js";
|
||||
import { loadPersistedConfig, savePersistedConfig } from "../../../persisted-config.js";
|
||||
import { PaseoAgentConfigService } from "./config-service.js";
|
||||
import { PaseoAgentConfigSchema } from "./config.js";
|
||||
import { paseoAgentAuthStoragePath, storeOAuthCredential } from "./oauth-store.js";
|
||||
|
||||
function piCatalogModels(provider: Parameters<typeof getModels>[0]) {
|
||||
return getModels(provider).map((model) => ({
|
||||
id: model.id,
|
||||
label: model.name,
|
||||
api: model.api,
|
||||
reasoning: model.reasoning,
|
||||
contextWindow: model.contextWindow,
|
||||
maxTokens: model.maxTokens,
|
||||
}));
|
||||
}
|
||||
|
||||
describe("PaseoAgentConfigService", () => {
|
||||
let home: string;
|
||||
|
||||
beforeEach(() => {
|
||||
home = mkdtempSync(join(tmpdir(), "paseo-agent-config-service-"));
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
rmSync(home, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
test("persists an OpenRouter provider and returns only redacted auth state", () => {
|
||||
const onConfigChanged = vi.fn();
|
||||
const service = new PaseoAgentConfigService({
|
||||
paseoHome: home,
|
||||
logger: createTestLogger(),
|
||||
onConfigChanged,
|
||||
});
|
||||
|
||||
const provider = service.setProvider({
|
||||
name: "openrouter-main",
|
||||
providerType: "openrouter",
|
||||
options: {
|
||||
apiKey: "sk-secret-openrouter",
|
||||
headers: { Authorization: "Bearer header-secret" },
|
||||
models: [{ id: "anthropic/claude-3.7-sonnet", reasoning: true }],
|
||||
},
|
||||
});
|
||||
|
||||
const persisted = loadPersistedConfig(home);
|
||||
expect(persisted.agents?.paseo?.providers?.["openrouter-main"]).toMatchObject({
|
||||
type: "openrouter",
|
||||
options: { apiKey: "sk-secret-openrouter" },
|
||||
});
|
||||
expect(provider).toMatchObject({
|
||||
name: "openrouter-main",
|
||||
providerType: "openrouter",
|
||||
auth: { kind: "api_key", configured: true, source: "literal" },
|
||||
available: true,
|
||||
});
|
||||
expect(JSON.stringify(service.getProviders())).not.toContain("sk-secret-openrouter");
|
||||
expect(JSON.stringify(service.getProviders())).not.toContain("header-secret");
|
||||
expect(onConfigChanged).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ providers: expect.any(Object) }),
|
||||
);
|
||||
});
|
||||
|
||||
test("reports API-key default env auth state without resolving the key value", () => {
|
||||
const missingService = new PaseoAgentConfigService({
|
||||
paseoHome: home,
|
||||
logger: createTestLogger(),
|
||||
env: { PASEO_HOME: home },
|
||||
});
|
||||
missingService.setProvider({
|
||||
name: "openrouter-main",
|
||||
providerType: "openrouter",
|
||||
options: {
|
||||
models: [{ id: "anthropic/claude-3.7-sonnet" }],
|
||||
},
|
||||
});
|
||||
|
||||
expect(missingService.getProviders().providers[0]).toMatchObject({
|
||||
auth: {
|
||||
kind: "api_key",
|
||||
configured: false,
|
||||
source: "default_env",
|
||||
hint: "OPENROUTER_API_KEY",
|
||||
},
|
||||
available: false,
|
||||
});
|
||||
|
||||
const presentService = new PaseoAgentConfigService({
|
||||
paseoHome: home,
|
||||
logger: createTestLogger(),
|
||||
env: { PASEO_HOME: home, OPENROUTER_API_KEY: "sk-env-secret" },
|
||||
});
|
||||
|
||||
expect(presentService.getProviders().providers[0]).toMatchObject({
|
||||
auth: {
|
||||
kind: "api_key",
|
||||
configured: true,
|
||||
source: "default_env",
|
||||
hint: "OPENROUTER_API_KEY",
|
||||
},
|
||||
available: true,
|
||||
});
|
||||
expect(JSON.stringify(presentService.getProviders())).not.toContain("sk-env-secret");
|
||||
});
|
||||
|
||||
test("reports configured API-key providers as available even without exposed models", () => {
|
||||
const service = new PaseoAgentConfigService({
|
||||
paseoHome: home,
|
||||
logger: createTestLogger(),
|
||||
});
|
||||
|
||||
service.setProvider({
|
||||
name: "openrouter",
|
||||
providerType: "openrouter",
|
||||
options: {
|
||||
apiKey: "sk-test",
|
||||
},
|
||||
});
|
||||
|
||||
expect(service.getProviders().providers[0]).toMatchObject({
|
||||
auth: { kind: "api_key", configured: true, source: "literal" },
|
||||
models: [],
|
||||
available: true,
|
||||
});
|
||||
});
|
||||
|
||||
test("rejects an unknown provider type with a clear error and persists nothing", () => {
|
||||
const service = new PaseoAgentConfigService({
|
||||
paseoHome: home,
|
||||
logger: createTestLogger(),
|
||||
});
|
||||
|
||||
expect(() =>
|
||||
service.setProvider({
|
||||
name: "future-main",
|
||||
providerType: "kimi-coding",
|
||||
options: { apiKey: "sk-test", models: [{ id: "kimi-k3" }] },
|
||||
}),
|
||||
).toThrow(
|
||||
/Unknown model provider type "kimi-coding". Known provider ids: openrouter, chatgpt, kimi, opencode-go/,
|
||||
);
|
||||
expect(loadPersistedConfig(home).agents?.paseo?.providers).toBeUndefined();
|
||||
});
|
||||
|
||||
test("schema accepts an unknown type structurally and the service rejects it", () => {
|
||||
const parsed = PaseoAgentConfigSchema.parse({
|
||||
providers: {
|
||||
future: { type: "future-provider", options: { models: [{ id: "m" }] } },
|
||||
},
|
||||
});
|
||||
expect(parsed.providers?.future?.type).toBe("future-provider");
|
||||
savePersistedConfig(
|
||||
home,
|
||||
{
|
||||
agents: { paseo: parsed },
|
||||
},
|
||||
createTestLogger(),
|
||||
);
|
||||
const service = new PaseoAgentConfigService({
|
||||
paseoHome: home,
|
||||
logger: createTestLogger(),
|
||||
});
|
||||
|
||||
expect(() => service.getProviders()).toThrow(
|
||||
/Unknown model provider type "future-provider". Known provider ids: openrouter, chatgpt, kimi, opencode-go/,
|
||||
);
|
||||
});
|
||||
|
||||
test("maps the legacy provider type alias to the catalog id on write", () => {
|
||||
const service = new PaseoAgentConfigService({
|
||||
paseoHome: home,
|
||||
logger: createTestLogger(),
|
||||
});
|
||||
|
||||
const provider = service.setProvider({
|
||||
name: "chatgpt",
|
||||
providerType: "openai-codex",
|
||||
options: {},
|
||||
});
|
||||
|
||||
expect(provider.providerType).toBe("chatgpt");
|
||||
expect(loadPersistedConfig(home).agents?.paseo?.providers?.chatgpt?.type).toBe("chatgpt");
|
||||
});
|
||||
|
||||
test("does not persist Pi catalog defaults as instance model overrides", () => {
|
||||
const service = new PaseoAgentConfigService({
|
||||
paseoHome: home,
|
||||
logger: createTestLogger(),
|
||||
});
|
||||
const chatgptCatalog = service.getCatalog().find((entry) => entry.id === "chatgpt");
|
||||
if (!chatgptCatalog) {
|
||||
throw new Error("missing chatgpt catalog entry");
|
||||
}
|
||||
|
||||
service.setProvider({
|
||||
name: "chatgpt",
|
||||
providerType: "chatgpt",
|
||||
options: { models: chatgptCatalog.models },
|
||||
});
|
||||
|
||||
expect(loadPersistedConfig(home).agents?.paseo?.providers?.chatgpt?.options.models).toBe(
|
||||
undefined,
|
||||
);
|
||||
});
|
||||
|
||||
test("preserves shared config fields when writing agents.paseo", () => {
|
||||
const logger = createTestLogger();
|
||||
savePersistedConfig(
|
||||
home,
|
||||
{
|
||||
daemon: { appendSystemPrompt: "Keep existing daemon settings." },
|
||||
app: { baseUrl: "http://localhost:8081" },
|
||||
agents: {
|
||||
providers: {
|
||||
gemini: {
|
||||
extends: "acp",
|
||||
label: "Gemini",
|
||||
command: ["gemini", "--acp"],
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
logger,
|
||||
);
|
||||
const service = new PaseoAgentConfigService({
|
||||
paseoHome: home,
|
||||
logger,
|
||||
});
|
||||
|
||||
service.setProvider({
|
||||
name: "openrouter-main",
|
||||
providerType: "openrouter",
|
||||
options: {
|
||||
apiKey: "sk-secret-openrouter",
|
||||
models: [{ id: "anthropic/claude-3.7-sonnet" }],
|
||||
},
|
||||
});
|
||||
|
||||
const persisted = loadPersistedConfig(home);
|
||||
expect(persisted.daemon?.appendSystemPrompt).toBe("Keep existing daemon settings.");
|
||||
expect(persisted.app?.baseUrl).toBe("http://localhost:8081");
|
||||
expect(persisted.agents?.providers?.gemini).toMatchObject({
|
||||
extends: "acp",
|
||||
label: "Gemini",
|
||||
});
|
||||
expect(persisted.agents?.paseo?.providers?.["openrouter-main"]?.options.apiKey).toBe(
|
||||
"sk-secret-openrouter",
|
||||
);
|
||||
});
|
||||
|
||||
test("stores OAuth credentials in the Paseo-owned auth store with future fields intact", () => {
|
||||
const service = new PaseoAgentConfigService({
|
||||
paseoHome: home,
|
||||
logger: createTestLogger(),
|
||||
});
|
||||
service.setProvider({
|
||||
name: "chatgpt",
|
||||
providerType: "chatgpt",
|
||||
options: {},
|
||||
});
|
||||
|
||||
service.storeOAuthCredential("chatgpt", {
|
||||
type: "oauth",
|
||||
access: "access-token",
|
||||
refresh: "refresh-token",
|
||||
expires: 123,
|
||||
accountId: "acct_123",
|
||||
futureField: { keep: true },
|
||||
});
|
||||
|
||||
const authPath = paseoAgentAuthStoragePath({ PASEO_HOME: home });
|
||||
const stored = JSON.parse(readFileSync(authPath, "utf8"));
|
||||
expect(stored.chatgpt).toMatchObject({
|
||||
type: "oauth",
|
||||
access: "access-token",
|
||||
refresh: "refresh-token",
|
||||
binding: {
|
||||
flow: "openai-codex",
|
||||
baseUrl: "https://chatgpt.com/backend-api",
|
||||
},
|
||||
futureField: { keep: true },
|
||||
});
|
||||
expect(authPath).toBe(join(home, "paseo-agent", "auth.json"));
|
||||
});
|
||||
|
||||
test("reports OAuth auth as stored without returning tokens", () => {
|
||||
const service = new PaseoAgentConfigService({
|
||||
paseoHome: home,
|
||||
logger: createTestLogger(),
|
||||
});
|
||||
service.setProvider({
|
||||
name: "chatgpt",
|
||||
providerType: "chatgpt",
|
||||
options: {},
|
||||
});
|
||||
service.storeOAuthCredential("chatgpt", {
|
||||
type: "oauth",
|
||||
access: "access-token",
|
||||
refresh: "refresh-token",
|
||||
expires: 123,
|
||||
});
|
||||
|
||||
const providers = service.getProviders();
|
||||
expect(providers.providers).toEqual([
|
||||
expect.objectContaining({
|
||||
name: "chatgpt",
|
||||
providerType: "chatgpt",
|
||||
models: piCatalogModels("openai-codex"),
|
||||
auth: { kind: "oauth", configured: true, source: "stored" },
|
||||
available: true,
|
||||
}),
|
||||
]);
|
||||
expect(JSON.stringify(providers)).not.toContain("access-token");
|
||||
expect(JSON.stringify(providers)).not.toContain("refresh-token");
|
||||
});
|
||||
|
||||
test("renames the provider display name without moving stored OAuth credentials", () => {
|
||||
const service = new PaseoAgentConfigService({
|
||||
paseoHome: home,
|
||||
logger: createTestLogger(),
|
||||
});
|
||||
service.setProvider({
|
||||
name: "subscription",
|
||||
providerType: "chatgpt",
|
||||
options: {},
|
||||
});
|
||||
service.storeOAuthCredential("subscription", {
|
||||
type: "oauth",
|
||||
access: "access-token",
|
||||
refresh: "refresh-token",
|
||||
expires: 123,
|
||||
});
|
||||
|
||||
const provider = service.renameProvider("subscription", "Work account");
|
||||
|
||||
expect(provider).toMatchObject({
|
||||
name: "subscription",
|
||||
displayName: "Work account",
|
||||
auth: { kind: "oauth", configured: true, source: "stored" },
|
||||
available: true,
|
||||
});
|
||||
const stored = JSON.parse(
|
||||
readFileSync(paseoAgentAuthStoragePath({ PASEO_HOME: home }), "utf8"),
|
||||
);
|
||||
expect(Object.keys(stored)).toEqual(["subscription"]);
|
||||
expect(stored.subscription).toMatchObject({
|
||||
type: "oauth",
|
||||
refresh: "refresh-token",
|
||||
});
|
||||
});
|
||||
|
||||
test("reports OAuth as missing or needing attention for absent and mismatched credentials", () => {
|
||||
const service = new PaseoAgentConfigService({
|
||||
paseoHome: home,
|
||||
logger: createTestLogger(),
|
||||
});
|
||||
service.setProvider({
|
||||
name: "chatgpt",
|
||||
providerType: "chatgpt",
|
||||
options: {},
|
||||
});
|
||||
|
||||
expect(service.getProviders().providers[0]).toMatchObject({
|
||||
auth: { kind: "oauth", configured: false },
|
||||
available: false,
|
||||
});
|
||||
|
||||
storeOAuthCredential({
|
||||
providerInstance: "chatgpt",
|
||||
env: { PASEO_HOME: home },
|
||||
binding: { flow: "openai-codex", baseUrl: "https://chatgpt.example.test/changed" },
|
||||
credential: {
|
||||
type: "oauth",
|
||||
access: "access-token",
|
||||
refresh: "refresh-token",
|
||||
expires: 123,
|
||||
},
|
||||
});
|
||||
|
||||
expect(service.getProviders().providers[0]).toMatchObject({
|
||||
auth: {
|
||||
kind: "oauth",
|
||||
configured: false,
|
||||
source: "stored",
|
||||
hint: "binding_mismatch",
|
||||
},
|
||||
available: false,
|
||||
});
|
||||
expect(JSON.stringify(service.getProviders())).not.toContain("access-token");
|
||||
expect(JSON.stringify(service.getProviders())).not.toContain("refresh-token");
|
||||
});
|
||||
|
||||
test("removes providers and clears a default model owned by that provider", () => {
|
||||
const service = new PaseoAgentConfigService({
|
||||
paseoHome: home,
|
||||
logger: createTestLogger(),
|
||||
});
|
||||
service.setProvider({
|
||||
name: "openrouter-main",
|
||||
providerType: "openrouter",
|
||||
options: {
|
||||
apiKey: "sk-secret-openrouter",
|
||||
models: [{ id: "anthropic/claude-3.7-sonnet" }],
|
||||
},
|
||||
});
|
||||
writeFileSync(
|
||||
join(home, "config.json"),
|
||||
JSON.stringify({
|
||||
agents: {
|
||||
paseo: {
|
||||
defaultModel: "openrouter-main/anthropic/claude-3.7-sonnet",
|
||||
providers: {
|
||||
"openrouter-main": {
|
||||
type: "openrouter",
|
||||
options: {
|
||||
apiKey: "sk-secret-openrouter",
|
||||
models: [{ id: "anthropic/claude-3.7-sonnet" }],
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
}),
|
||||
);
|
||||
|
||||
expect(service.removeProvider("openrouter-main")).toBe(true);
|
||||
expect(service.getProviders()).toEqual({ defaultModel: null, providers: [] });
|
||||
});
|
||||
});
|
||||
@@ -1,341 +0,0 @@
|
||||
import type { Logger } from "pino";
|
||||
import type {
|
||||
PaseoAgentOAuthCredential,
|
||||
PaseoAgentProviderAuthState,
|
||||
RedactedPaseoAgentProviderConfig,
|
||||
} from "@getpaseo/protocol/messages";
|
||||
|
||||
import {
|
||||
loadPersistedConfig,
|
||||
savePersistedConfig,
|
||||
type PersistedConfig,
|
||||
} from "../../../persisted-config.js";
|
||||
import {
|
||||
isPaseoAgentDefaultModelSelection,
|
||||
PaseoAgentConfigSchema,
|
||||
paseoAgentCatalogManifests,
|
||||
type PaseoAgentConfig,
|
||||
type PaseoAgentCatalogManifestEntry,
|
||||
type PaseoAgentProviderModelConfig,
|
||||
resolvePaseoAgentCatalogAuth,
|
||||
resolvePaseoAgentProviderModels,
|
||||
resolvePaseoAgentProviderSettings,
|
||||
} from "./config.js";
|
||||
import { requirePaseoAgentCatalogEntry, type PaseoAgentCatalogRef } from "./catalog.js";
|
||||
import {
|
||||
getStoredOAuthCredentialState,
|
||||
storeOAuthCredential,
|
||||
type OAuthCredentialBinding,
|
||||
} from "./oauth-store.js";
|
||||
import { isRefreshTokenExpressionConfigured } from "./oauth-credentials.js";
|
||||
import { findEnvReferences } from "./env-references.js";
|
||||
|
||||
interface PaseoAgentConfigServiceOptions {
|
||||
paseoHome: string;
|
||||
logger: Logger;
|
||||
env?: NodeJS.ProcessEnv;
|
||||
onConfigChanged?: (config: PaseoAgentConfig | undefined) => void;
|
||||
}
|
||||
|
||||
interface SetProviderInput {
|
||||
name: string;
|
||||
providerType: string;
|
||||
displayName?: string;
|
||||
options: {
|
||||
apiKey?: string;
|
||||
baseUrl?: string;
|
||||
api?: string;
|
||||
headers?: Record<string, string>;
|
||||
authHeader?: boolean;
|
||||
models?: PaseoAgentProviderModelConfig[];
|
||||
};
|
||||
}
|
||||
|
||||
function resolveEnv(paseoHome: string, env?: NodeJS.ProcessEnv): NodeJS.ProcessEnv {
|
||||
return env ?? { ...process.env, PASEO_HOME: paseoHome };
|
||||
}
|
||||
|
||||
function authStateForApiKey(
|
||||
value: string | undefined,
|
||||
fallbackEnvVar: string | undefined,
|
||||
env: NodeJS.ProcessEnv,
|
||||
): PaseoAgentProviderAuthState {
|
||||
if (!value && fallbackEnvVar) {
|
||||
return {
|
||||
kind: "api_key",
|
||||
configured: Boolean(env[fallbackEnvVar]),
|
||||
source: "default_env",
|
||||
hint: fallbackEnvVar,
|
||||
};
|
||||
}
|
||||
if (!value) {
|
||||
return { kind: "none", configured: false };
|
||||
}
|
||||
if (value.startsWith("!")) {
|
||||
return { kind: "api_key", configured: true, source: "command" };
|
||||
}
|
||||
const referencedVars = findEnvReferences(value);
|
||||
if (referencedVars.length > 0) {
|
||||
return {
|
||||
kind: "api_key",
|
||||
configured: referencedVars.every((name) => Boolean(env[name])),
|
||||
source: "env",
|
||||
hint: referencedVars.join(","),
|
||||
};
|
||||
}
|
||||
return { kind: "api_key", configured: true, source: "literal" };
|
||||
}
|
||||
|
||||
function copyCatalogEntry(entry: PaseoAgentCatalogManifestEntry): PaseoAgentCatalogManifestEntry {
|
||||
return {
|
||||
...entry,
|
||||
...(entry.headers ? { headers: { ...entry.headers } } : {}),
|
||||
auth: { ...entry.auth },
|
||||
models: entry.models.map((model) => ({ ...model })),
|
||||
};
|
||||
}
|
||||
|
||||
function providerOptionsForPersist(
|
||||
options: SetProviderInput["options"],
|
||||
catalogEntry: PaseoAgentCatalogRef,
|
||||
): SetProviderInput["options"] {
|
||||
if (!isPaseoAgentDefaultModelSelection(options.models, catalogEntry)) {
|
||||
return options;
|
||||
}
|
||||
const rest = { ...options };
|
||||
delete rest.models;
|
||||
return rest;
|
||||
}
|
||||
|
||||
function oauthBindingForSettings(
|
||||
flow: string,
|
||||
settings: ReturnType<typeof resolvePaseoAgentProviderSettings>,
|
||||
): OAuthCredentialBinding {
|
||||
return { flow, baseUrl: settings.baseUrl };
|
||||
}
|
||||
|
||||
function readPaseoAgentConfig(persisted: PersistedConfig): PaseoAgentConfig {
|
||||
return validatePaseoAgentConfig(PaseoAgentConfigSchema.parse(persisted.agents?.paseo ?? {}));
|
||||
}
|
||||
|
||||
function validatePaseoAgentConfig(config: PaseoAgentConfig): PaseoAgentConfig {
|
||||
for (const entry of Object.values(config.providers ?? {})) {
|
||||
requirePaseoAgentCatalogEntry(entry.type);
|
||||
}
|
||||
return config;
|
||||
}
|
||||
|
||||
function redactedProviders(
|
||||
config: PaseoAgentConfig,
|
||||
env: NodeJS.ProcessEnv,
|
||||
): RedactedPaseoAgentProviderConfig[] {
|
||||
return Object.entries(config.providers ?? {}).map(([name, entry]) => {
|
||||
const catalogEntry = requirePaseoAgentCatalogEntry(entry.type);
|
||||
const authManifest = resolvePaseoAgentCatalogAuth(catalogEntry);
|
||||
const settings = resolvePaseoAgentProviderSettings(entry, catalogEntry);
|
||||
const models = resolvePaseoAgentProviderModels(entry, catalogEntry);
|
||||
let auth: PaseoAgentProviderAuthState;
|
||||
if (authManifest.kind === "oauth") {
|
||||
const hasRefreshToken =
|
||||
entry.options.refreshToken &&
|
||||
isRefreshTokenExpressionConfigured(entry.options.refreshToken, env);
|
||||
if (hasRefreshToken) {
|
||||
auth = { kind: "oauth", configured: true, source: "refresh_token" };
|
||||
} else {
|
||||
const stored = getStoredOAuthCredentialState(
|
||||
name,
|
||||
env,
|
||||
oauthBindingForSettings(authManifest.flow, settings),
|
||||
);
|
||||
if (stored.present && stored.bindingMatches) {
|
||||
auth = { kind: "oauth", configured: true, source: "stored" };
|
||||
} else if (stored.present) {
|
||||
auth = {
|
||||
kind: "oauth",
|
||||
configured: false,
|
||||
source: "stored",
|
||||
hint: "binding_mismatch",
|
||||
};
|
||||
} else {
|
||||
auth = { kind: "oauth", configured: false };
|
||||
}
|
||||
}
|
||||
} else {
|
||||
auth = authStateForApiKey(entry.options.apiKey, authManifest.envVar, env);
|
||||
}
|
||||
const provider: RedactedPaseoAgentProviderConfig = {
|
||||
name,
|
||||
providerType: catalogEntry.id,
|
||||
models: models.map((model) => Object.assign({}, model)),
|
||||
auth,
|
||||
available: auth.configured,
|
||||
error: null,
|
||||
};
|
||||
if (entry.displayName) {
|
||||
provider.displayName = entry.displayName;
|
||||
}
|
||||
provider.baseUrl = settings.baseUrl;
|
||||
provider.api = settings.api;
|
||||
return provider;
|
||||
});
|
||||
}
|
||||
|
||||
function mergePaseoAgentConfig(
|
||||
persisted: PersistedConfig,
|
||||
paseoConfig: PaseoAgentConfig | undefined,
|
||||
): PersistedConfig {
|
||||
return {
|
||||
...persisted,
|
||||
agents: {
|
||||
...persisted.agents,
|
||||
paseo: paseoConfig,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
export class PaseoAgentConfigService {
|
||||
private readonly paseoHome: string;
|
||||
private readonly logger: Logger;
|
||||
private readonly env: NodeJS.ProcessEnv;
|
||||
private readonly onConfigChanged: ((config: PaseoAgentConfig | undefined) => void) | undefined;
|
||||
|
||||
constructor(options: PaseoAgentConfigServiceOptions) {
|
||||
this.paseoHome = options.paseoHome;
|
||||
this.logger = options.logger.child({ module: "paseo-agent-config-service" });
|
||||
this.env = resolveEnv(options.paseoHome, options.env);
|
||||
this.onConfigChanged = options.onConfigChanged;
|
||||
}
|
||||
|
||||
getCatalog(): PaseoAgentCatalogManifestEntry[] {
|
||||
return paseoAgentCatalogManifests().map(copyCatalogEntry);
|
||||
}
|
||||
|
||||
getProviders(): { defaultModel: string | null; providers: RedactedPaseoAgentProviderConfig[] } {
|
||||
const config = readPaseoAgentConfig(loadPersistedConfig(this.paseoHome, this.logger));
|
||||
return {
|
||||
defaultModel: config.defaultModel ?? null,
|
||||
providers: redactedProviders(config, this.env),
|
||||
};
|
||||
}
|
||||
|
||||
setProvider(input: SetProviderInput): RedactedPaseoAgentProviderConfig {
|
||||
const catalogEntry = requirePaseoAgentCatalogEntry(input.providerType);
|
||||
const next = this.updateConfig((current) =>
|
||||
PaseoAgentConfigSchema.parse({
|
||||
...current,
|
||||
providers: {
|
||||
...current.providers,
|
||||
[input.name]: {
|
||||
type: catalogEntry.id,
|
||||
...((input.displayName ?? current.providers?.[input.name]?.displayName)
|
||||
? { displayName: input.displayName ?? current.providers?.[input.name]?.displayName }
|
||||
: {}),
|
||||
options: providerOptionsForPersist(input.options, catalogEntry),
|
||||
},
|
||||
},
|
||||
}),
|
||||
);
|
||||
return this.requireRedactedProvider(next, input.name);
|
||||
}
|
||||
|
||||
renameProvider(name: string, displayName: string): RedactedPaseoAgentProviderConfig {
|
||||
const trimmedDisplayName = displayName.trim();
|
||||
const next = this.updateConfig((current) => {
|
||||
const provider = current.providers?.[name];
|
||||
if (!provider) {
|
||||
throw new Error(`Paseo Agent provider '${name}' is not configured.`);
|
||||
}
|
||||
return PaseoAgentConfigSchema.parse({
|
||||
...current,
|
||||
providers: {
|
||||
...current.providers,
|
||||
[name]: {
|
||||
...provider,
|
||||
displayName: trimmedDisplayName,
|
||||
},
|
||||
},
|
||||
});
|
||||
});
|
||||
return this.requireRedactedProvider(next, name);
|
||||
}
|
||||
|
||||
removeProvider(name: string): boolean {
|
||||
let removed = false;
|
||||
this.updateConfig((current) => {
|
||||
const providers = { ...current.providers };
|
||||
removed = Object.prototype.hasOwnProperty.call(providers, name);
|
||||
delete providers[name];
|
||||
return PaseoAgentConfigSchema.parse({
|
||||
...current,
|
||||
...(Object.keys(providers).length > 0 ? { providers } : { providers: undefined }),
|
||||
...(current.defaultModel?.startsWith(`${name}/`) ? { defaultModel: undefined } : {}),
|
||||
});
|
||||
});
|
||||
return removed;
|
||||
}
|
||||
|
||||
getOAuthCredentialBinding(providerName: string): OAuthCredentialBinding {
|
||||
const config = readPaseoAgentConfig(loadPersistedConfig(this.paseoHome, this.logger));
|
||||
const entry = config.providers?.[providerName];
|
||||
if (!entry) {
|
||||
throw new Error(`Paseo Agent provider '${providerName}' is not configured.`);
|
||||
}
|
||||
const catalogEntry = requirePaseoAgentCatalogEntry(entry.type);
|
||||
const authManifest = resolvePaseoAgentCatalogAuth(catalogEntry);
|
||||
if (authManifest.kind !== "oauth") {
|
||||
throw new Error(`Paseo Agent provider '${providerName}' does not use OAuth.`);
|
||||
}
|
||||
const settings = resolvePaseoAgentProviderSettings(entry, catalogEntry);
|
||||
return oauthBindingForSettings(authManifest.flow, settings);
|
||||
}
|
||||
|
||||
storeOAuthCredential(
|
||||
providerName: string,
|
||||
credential: PaseoAgentOAuthCredential,
|
||||
binding: OAuthCredentialBinding | undefined = undefined,
|
||||
): PaseoAgentProviderAuthState {
|
||||
const config = readPaseoAgentConfig(loadPersistedConfig(this.paseoHome, this.logger));
|
||||
const entry = config.providers?.[providerName];
|
||||
if (!entry) {
|
||||
throw new Error(`Paseo Agent provider '${providerName}' is not configured.`);
|
||||
}
|
||||
const catalogEntry = requirePaseoAgentCatalogEntry(entry.type);
|
||||
const authManifest = resolvePaseoAgentCatalogAuth(catalogEntry);
|
||||
if (authManifest.kind !== "oauth") {
|
||||
throw new Error(`Paseo Agent provider '${providerName}' does not use OAuth.`);
|
||||
}
|
||||
const settings = resolvePaseoAgentProviderSettings(entry, catalogEntry);
|
||||
storeOAuthCredential({
|
||||
providerInstance: providerName,
|
||||
credential,
|
||||
binding: binding ?? oauthBindingForSettings(authManifest.flow, settings),
|
||||
env: this.env,
|
||||
});
|
||||
this.onConfigChanged?.(config);
|
||||
return (
|
||||
this.requireRedactedProvider(config, providerName).auth ?? {
|
||||
kind: "oauth",
|
||||
configured: false,
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
private requireRedactedProvider(
|
||||
config: PaseoAgentConfig,
|
||||
name: string,
|
||||
): RedactedPaseoAgentProviderConfig {
|
||||
const provider = redactedProviders(config, this.env).find((entry) => entry.name === name);
|
||||
if (!provider) {
|
||||
throw new Error(`Paseo Agent provider '${name}' was not found after update.`);
|
||||
}
|
||||
return provider;
|
||||
}
|
||||
|
||||
private updateConfig(update: (current: PaseoAgentConfig) => PaseoAgentConfig): PaseoAgentConfig {
|
||||
const persisted = loadPersistedConfig(this.paseoHome, this.logger);
|
||||
const next = validatePaseoAgentConfig(update(readPaseoAgentConfig(persisted)));
|
||||
savePersistedConfig(this.paseoHome, mergePaseoAgentConfig(persisted, next), this.logger);
|
||||
this.onConfigChanged?.(next);
|
||||
return next;
|
||||
}
|
||||
}
|
||||
@@ -1,395 +0,0 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { getModels } from "@earendil-works/pi-ai";
|
||||
|
||||
import {
|
||||
PaseoAgentConfigSchema,
|
||||
encodePaseoAgentModelId,
|
||||
listPaseoAgentModels,
|
||||
paseoAgentHasUsableModel,
|
||||
paseoAgentModelProviders,
|
||||
parsePaseoAgentModelId,
|
||||
resolvePaseoAgentCatalogAuth,
|
||||
resolvePaseoAgentModel,
|
||||
type PaseoAgentConfig,
|
||||
} from "./config.js";
|
||||
import { PASEO_AGENT_PROVIDER_CATALOG } from "./catalog.js";
|
||||
|
||||
const CATALOG_AUTH_ENV_VARS = [
|
||||
...new Set([
|
||||
"OPENROUTER_API_KEY",
|
||||
"KIMI_API_KEY",
|
||||
"OPENCODE_API_KEY",
|
||||
...PASEO_AGENT_PROVIDER_CATALOG.flatMap((entry) =>
|
||||
entry.auth?.kind === "api_key" ? [entry.auth.envVar] : [],
|
||||
),
|
||||
]),
|
||||
];
|
||||
|
||||
function piModelIds(provider: Parameters<typeof getModels>[0]): string[] {
|
||||
return getModels(provider).map((model) => model.id);
|
||||
}
|
||||
|
||||
function deleteEnvVars(names: readonly string[]): Map<string, string | undefined> {
|
||||
const previousValues = new Map<string, string | undefined>();
|
||||
for (const name of names) {
|
||||
previousValues.set(name, process.env[name]);
|
||||
delete process.env[name];
|
||||
}
|
||||
|
||||
return previousValues;
|
||||
}
|
||||
|
||||
function restoreEnvVars(previousValues: ReadonlyMap<string, string | undefined>): void {
|
||||
for (const [name, value] of previousValues) {
|
||||
if (value === undefined) {
|
||||
delete process.env[name];
|
||||
} else {
|
||||
process.env[name] = value;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function resolveCatalogAuthEntries(): Array<
|
||||
[string, ReturnType<typeof resolvePaseoAgentCatalogAuth>]
|
||||
> {
|
||||
return PASEO_AGENT_PROVIDER_CATALOG.map((entry) => [
|
||||
entry.id,
|
||||
resolvePaseoAgentCatalogAuth(entry),
|
||||
]);
|
||||
}
|
||||
|
||||
function configWith(overrides?: Partial<PaseoAgentConfig>): PaseoAgentConfig {
|
||||
return PaseoAgentConfigSchema.parse({
|
||||
providers: {
|
||||
"openrouter-main": {
|
||||
type: "openrouter",
|
||||
options: {
|
||||
apiKey: "sk-test",
|
||||
models: [
|
||||
{ id: "anthropic/claude", label: "Claude", reasoning: true },
|
||||
{ id: "openai/gpt", reasoning: false },
|
||||
],
|
||||
},
|
||||
},
|
||||
},
|
||||
...overrides,
|
||||
});
|
||||
}
|
||||
|
||||
describe("PaseoAgentConfigSchema", () => {
|
||||
it("rejects unknown keys (strict)", () => {
|
||||
expect(() => PaseoAgentConfigSchema.parse({ providers: {}, unexpected: true })).toThrow();
|
||||
});
|
||||
|
||||
it("accepts unknown model provider types structurally", () => {
|
||||
const config = PaseoAgentConfigSchema.parse({
|
||||
providers: {
|
||||
future: { type: "future-provider", options: { models: [{ id: "m" }] } },
|
||||
},
|
||||
});
|
||||
expect(config.providers?.future?.type).toBe("future-provider");
|
||||
});
|
||||
|
||||
it("rejects an empty instance model override", () => {
|
||||
expect(() =>
|
||||
PaseoAgentConfigSchema.parse({
|
||||
providers: { p: { type: "openrouter", options: { models: [] } } },
|
||||
}),
|
||||
).toThrow();
|
||||
});
|
||||
|
||||
it("accepts a provider entry without options when the catalog supplies defaults", () => {
|
||||
const config = PaseoAgentConfigSchema.parse({
|
||||
providers: { chatgpt: { type: "chatgpt" } },
|
||||
});
|
||||
expect(config.providers?.chatgpt?.options).toEqual({});
|
||||
});
|
||||
|
||||
it("accepts multiple entries of the same type with distinct names", () => {
|
||||
const config = PaseoAgentConfigSchema.parse({
|
||||
providers: {
|
||||
"openrouter-a": {
|
||||
type: "openrouter",
|
||||
options: { apiKey: "sk-a", models: [{ id: "model-a" }] },
|
||||
},
|
||||
"openrouter-b": {
|
||||
type: "openrouter",
|
||||
options: {
|
||||
baseUrl: "https://proxy.test/v1",
|
||||
apiKey: "sk-b",
|
||||
models: [{ id: "model-b" }],
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
expect(Object.keys(config.providers ?? {})).toEqual(["openrouter-a", "openrouter-b"]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("model id encoding", () => {
|
||||
it("round-trips provider + model id", () => {
|
||||
const id = encodePaseoAgentModelId("openrouter-main", "anthropic/claude");
|
||||
expect(parsePaseoAgentModelId(id)).toEqual({
|
||||
provider: "openrouter-main",
|
||||
id: "anthropic/claude",
|
||||
});
|
||||
});
|
||||
|
||||
it("returns null for an unprefixed id", () => {
|
||||
expect(parsePaseoAgentModelId("noslash")).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe("resolvePaseoAgentCatalogAuth", () => {
|
||||
it("resolves every catalog entry without provider key env vars", () => {
|
||||
const previousValues = deleteEnvVars(CATALOG_AUTH_ENV_VARS);
|
||||
try {
|
||||
expect(resolveCatalogAuthEntries()).toEqual([
|
||||
["openrouter", { kind: "api_key", envVar: "OPENROUTER_API_KEY" }],
|
||||
["chatgpt", { kind: "oauth", flow: "openai-codex" }],
|
||||
["kimi", { kind: "api_key", envVar: "KIMI_API_KEY" }],
|
||||
["opencode-go", { kind: "api_key", envVar: "OPENCODE_API_KEY" }],
|
||||
]);
|
||||
} finally {
|
||||
restoreEnvVars(previousValues);
|
||||
}
|
||||
});
|
||||
|
||||
it("assigns icon names to every Paseo Agent catalog entry", () => {
|
||||
const iconNames = new Map(
|
||||
PASEO_AGENT_PROVIDER_CATALOG.map((entry) => [entry.id, entry.iconName]),
|
||||
);
|
||||
|
||||
expect(iconNames).toEqual(
|
||||
new Map([
|
||||
["openrouter", "openrouter"],
|
||||
["chatgpt", "openai"],
|
||||
["kimi", "kimi"],
|
||||
["opencode-go", "opencode"],
|
||||
]),
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe("listPaseoAgentModels", () => {
|
||||
it("exposes every configured model with provider-prefixed ids", () => {
|
||||
const models = listPaseoAgentModels(configWith());
|
||||
expect(models.map((m) => m.id)).toEqual([
|
||||
"openrouter-main/anthropic/claude",
|
||||
"openrouter-main/openai/gpt",
|
||||
]);
|
||||
expect(models.every((m) => m.provider === "paseo")).toBe(true);
|
||||
});
|
||||
|
||||
it("uses catalog model defaults when an instance does not override them", () => {
|
||||
const models = listPaseoAgentModels(
|
||||
PaseoAgentConfigSchema.parse({ providers: { chatgpt: { type: "chatgpt" } } }),
|
||||
);
|
||||
expect(models.map((m) => m.id)).toEqual(
|
||||
piModelIds("openai-codex").map((modelId) => `chatgpt/${modelId}`),
|
||||
);
|
||||
});
|
||||
|
||||
it("marks the configured default model", () => {
|
||||
const models = listPaseoAgentModels(configWith({ defaultModel: "openrouter-main/openai/gpt" }));
|
||||
const defaults = models.filter((m) => m.isDefault).map((m) => m.id);
|
||||
expect(defaults).toEqual(["openrouter-main/openai/gpt"]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("paseoAgentModelProviders", () => {
|
||||
it("applies OpenRouter catalog defaults", async () => {
|
||||
const [provider] = await paseoAgentModelProviders(configWith());
|
||||
expect(provider.name).toBe("openrouter-main");
|
||||
expect(provider.config.baseUrl).toBe("https://openrouter.ai/api/v1");
|
||||
expect(provider.config.apiKey).toBe("sk-test");
|
||||
expect(provider.config.models?.[0]).toMatchObject({
|
||||
id: "anthropic/claude",
|
||||
name: "Claude",
|
||||
api: "openai-completions",
|
||||
reasoning: true,
|
||||
contextWindow: 128_000,
|
||||
maxTokens: 16_384,
|
||||
});
|
||||
});
|
||||
|
||||
it("falls back to the catalog env var when no apiKey is given", async () => {
|
||||
const config = PaseoAgentConfigSchema.parse({
|
||||
providers: {
|
||||
openrouter: { type: "openrouter", options: { models: [{ id: "m" }] } },
|
||||
},
|
||||
});
|
||||
const [provider] = await paseoAgentModelProviders(config);
|
||||
expect(provider.config.apiKey).toBe("$OPENROUTER_API_KEY");
|
||||
});
|
||||
|
||||
it("applies the Kimi catalog API and default header", async () => {
|
||||
const config = PaseoAgentConfigSchema.parse({
|
||||
providers: {
|
||||
kimi: { type: "kimi", options: { models: [{ id: "kimi-k2" }] } },
|
||||
},
|
||||
});
|
||||
const [provider] = await paseoAgentModelProviders(config);
|
||||
expect(provider.config.baseUrl).toBe("https://api.kimi.com/coding");
|
||||
expect(provider.config.apiKey).toBe("$KIMI_API_KEY");
|
||||
expect(provider.config.api).toBe("anthropic-messages");
|
||||
expect(provider.config.models?.[0]?.headers).toEqual({ "User-Agent": "KimiCLI/1.5" });
|
||||
expect(provider.config.models?.[0]?.api).toBe("anthropic-messages");
|
||||
});
|
||||
|
||||
it("applies the OpenCode Go catalog base URL", async () => {
|
||||
const config = PaseoAgentConfigSchema.parse({
|
||||
providers: {
|
||||
go: { type: "opencode-go", options: { models: [{ id: "glm-5" }] } },
|
||||
},
|
||||
});
|
||||
const [provider] = await paseoAgentModelProviders(config);
|
||||
expect(provider.config.baseUrl).toBe("https://opencode.ai/zen/go/v1");
|
||||
expect(provider.config.apiKey).toBe("$OPENCODE_API_KEY");
|
||||
expect(provider.config.api).toBe("openai-completions");
|
||||
});
|
||||
|
||||
it("maps OAuth catalog entries to flow-based providers without an api key", async () => {
|
||||
const [provider] = await paseoAgentModelProviders(
|
||||
PaseoAgentConfigSchema.parse({ providers: { chatgpt: { type: "chatgpt" } } }),
|
||||
{},
|
||||
);
|
||||
expect(provider.name).toBe("chatgpt");
|
||||
expect(provider.oauth).toEqual({ flow: "openai-codex" });
|
||||
expect(provider.config.apiKey).toBeUndefined();
|
||||
expect(provider.config.api).toBe("openai-codex-responses");
|
||||
expect(provider.config.baseUrl).toBe("https://chatgpt.com/backend-api");
|
||||
expect(provider.config.models?.map((model) => model.id)).toEqual(piModelIds("openai-codex"));
|
||||
});
|
||||
|
||||
it("lets instance models override catalog default models", async () => {
|
||||
const [provider] = await paseoAgentModelProviders(
|
||||
PaseoAgentConfigSchema.parse({
|
||||
providers: {
|
||||
chatgpt: {
|
||||
type: "chatgpt",
|
||||
options: { models: [{ id: "gpt-other", reasoning: false }] },
|
||||
},
|
||||
},
|
||||
}),
|
||||
);
|
||||
expect(provider.config.models?.map((model) => model.id)).toEqual(["gpt-other"]);
|
||||
});
|
||||
|
||||
it("maps the legacy type alias to the catalog entry", async () => {
|
||||
const [provider] = await paseoAgentModelProviders(
|
||||
PaseoAgentConfigSchema.parse({
|
||||
providers: {
|
||||
chatgpt: { type: "openai-codex" },
|
||||
},
|
||||
}),
|
||||
);
|
||||
expect(provider.oauth).toEqual({ flow: "openai-codex" });
|
||||
expect(provider.config.models?.map((model) => model.id)).toEqual(piModelIds("openai-codex"));
|
||||
});
|
||||
|
||||
it("rejects unknown provider types at runtime with known ids", async () => {
|
||||
const config = PaseoAgentConfigSchema.parse({
|
||||
providers: {
|
||||
mystery: { type: "mystery", options: { models: [{ id: "m" }] } },
|
||||
},
|
||||
});
|
||||
await expect(paseoAgentModelProviders(config)).rejects.toThrow(
|
||||
/Unknown model provider type "mystery". Known provider ids: openrouter, chatgpt, kimi, opencode-go/,
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe("paseoAgentHasUsableModel", () => {
|
||||
it("is true for a literal api key", () => {
|
||||
expect(paseoAgentHasUsableModel(configWith(), {})).toBe(true);
|
||||
});
|
||||
|
||||
it("is false when no providers are configured", () => {
|
||||
expect(paseoAgentHasUsableModel(PaseoAgentConfigSchema.parse({}), {})).toBe(false);
|
||||
});
|
||||
|
||||
it("is false for an API-key provider without a configured key", () => {
|
||||
const config = PaseoAgentConfigSchema.parse({
|
||||
providers: {
|
||||
openrouter: { type: "openrouter", options: { models: [{ id: "m" }] } },
|
||||
},
|
||||
});
|
||||
expect(paseoAgentHasUsableModel(config, {})).toBe(false);
|
||||
});
|
||||
|
||||
it("follows the env var for an env-backed key", () => {
|
||||
const config = PaseoAgentConfigSchema.parse({
|
||||
providers: { openrouter: { type: "openrouter", options: { models: [{ id: "m" }] } } },
|
||||
});
|
||||
expect(paseoAgentHasUsableModel(config, {})).toBe(false);
|
||||
expect(paseoAgentHasUsableModel(config, { OPENROUTER_API_KEY: "sk-env" })).toBe(true);
|
||||
});
|
||||
|
||||
it("uses the OAuth store predicate, or an advanced refresh token", () => {
|
||||
const config = PaseoAgentConfigSchema.parse({
|
||||
providers: { chatgpt: { type: "chatgpt" } },
|
||||
});
|
||||
expect(paseoAgentHasUsableModel(config, {})).toBe(false);
|
||||
expect(paseoAgentHasUsableModel(config, {}, () => true)).toBe(true);
|
||||
|
||||
const refreshConfig = PaseoAgentConfigSchema.parse({
|
||||
providers: {
|
||||
chatgpt: { type: "chatgpt", options: { refreshToken: "$OAUTH_REFRESH_TOKEN" } },
|
||||
},
|
||||
});
|
||||
expect(paseoAgentHasUsableModel(refreshConfig, { OAUTH_REFRESH_TOKEN: "rt-env" })).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe("resolvePaseoAgentModel", () => {
|
||||
it("prefers the explicit request, then agent model, then default, then first configured", () => {
|
||||
const config = configWith({ defaultModel: "openrouter-main/openai/gpt" });
|
||||
expect(resolvePaseoAgentModel(config, "openrouter-main/anthropic/claude")).toEqual({
|
||||
provider: "openrouter-main",
|
||||
id: "anthropic/claude",
|
||||
});
|
||||
expect(
|
||||
resolvePaseoAgentModel(config, null, undefined, "openrouter-main/anthropic/claude"),
|
||||
).toEqual({
|
||||
provider: "openrouter-main",
|
||||
id: "anthropic/claude",
|
||||
});
|
||||
expect(resolvePaseoAgentModel(config, null)).toEqual({
|
||||
provider: "openrouter-main",
|
||||
id: "openai/gpt",
|
||||
});
|
||||
expect(resolvePaseoAgentModel(configWith(), null)).toEqual({
|
||||
provider: "openrouter-main",
|
||||
id: "anthropic/claude",
|
||||
});
|
||||
});
|
||||
|
||||
it("returns undefined when no providers are configured", () => {
|
||||
expect(resolvePaseoAgentModel(PaseoAgentConfigSchema.parse({}), null)).toBeUndefined();
|
||||
});
|
||||
|
||||
it("uses catalog default models during implicit selection", () => {
|
||||
expect(
|
||||
resolvePaseoAgentModel(
|
||||
PaseoAgentConfigSchema.parse({ providers: { chatgpt: { type: "chatgpt" } } }),
|
||||
null,
|
||||
),
|
||||
).toEqual({ provider: "chatgpt", id: piModelIds("openai-codex")[0] });
|
||||
});
|
||||
|
||||
it("ignores an implicit default whose provider is not registered", () => {
|
||||
const config = configWith({ defaultModel: "ghost/model" });
|
||||
expect(resolvePaseoAgentModel(config, null)).toEqual({
|
||||
provider: "openrouter-main",
|
||||
id: "anthropic/claude",
|
||||
});
|
||||
});
|
||||
|
||||
it("honors an explicit request even if its provider is not registered", () => {
|
||||
expect(resolvePaseoAgentModel(configWith(), "ghost/model")).toEqual({
|
||||
provider: "ghost",
|
||||
id: "model",
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -1,470 +0,0 @@
|
||||
import { getModels, type Api, type Model } from "@earendil-works/pi-ai";
|
||||
import { getOAuthProvider } from "@earendil-works/pi-ai/oauth";
|
||||
import type { PaseoAgentCatalogEntry as PaseoAgentCatalogManifestEntry } from "@getpaseo/protocol/messages";
|
||||
import { z } from "zod";
|
||||
|
||||
import type { AgentModelDefinition } from "../../agent-sdk-types.js";
|
||||
import {
|
||||
isRefreshTokenExpressionConfigured,
|
||||
resolveRefreshTokenExpression,
|
||||
} from "./oauth-credentials.js";
|
||||
import type { OAuthCredentialBinding } from "./oauth-store.js";
|
||||
import type { PaseoAgentModelProvider, PaseoAgentModelReference } from "./pi-services.js";
|
||||
import {
|
||||
PASEO_AGENT_PROVIDER_CATALOG,
|
||||
requirePaseoAgentCatalogEntry,
|
||||
type PaseoAgentCatalogRef,
|
||||
} from "./catalog.js";
|
||||
import { findEnvReferences } from "./env-references.js";
|
||||
|
||||
export const PASEO_AGENT_PROVIDER = "paseo";
|
||||
|
||||
const PaseoAgentModelSchema = z
|
||||
.object({
|
||||
id: z.string().min(1),
|
||||
label: z.string().min(1).optional(),
|
||||
api: z.string().min(1).optional(),
|
||||
reasoning: z.boolean().optional(),
|
||||
contextWindow: z.number().int().positive().optional(),
|
||||
maxTokens: z.number().int().positive().optional(),
|
||||
})
|
||||
.strict();
|
||||
|
||||
const PaseoAgentProviderOptionsSchema = z
|
||||
.object({
|
||||
apiKey: z.string().min(1).optional(),
|
||||
baseUrl: z.string().url().optional(),
|
||||
api: z.string().min(1).optional(),
|
||||
headers: z.record(z.string(), z.string()).optional(),
|
||||
authHeader: z.boolean().optional(),
|
||||
refreshToken: z.string().min(1).optional(),
|
||||
models: z.array(PaseoAgentModelSchema).min(1).optional(),
|
||||
})
|
||||
.strict();
|
||||
|
||||
const PaseoAgentModelProviderSchema = z
|
||||
.object({
|
||||
type: z.string().min(1),
|
||||
displayName: z.string().min(1).optional(),
|
||||
options: PaseoAgentProviderOptionsSchema.default({}),
|
||||
})
|
||||
.strict();
|
||||
|
||||
export const PaseoAgentConfigSchema = z
|
||||
.object({
|
||||
defaultModel: z.string().min(1).optional(),
|
||||
defaultAgent: z.string().min(1).optional(),
|
||||
defaultProfile: z.string().min(1).optional(),
|
||||
providers: z.record(z.string(), PaseoAgentModelProviderSchema).optional(),
|
||||
})
|
||||
.strict();
|
||||
|
||||
export type PaseoAgentConfig = z.infer<typeof PaseoAgentConfigSchema>;
|
||||
export type PaseoAgentModelProviderEntry = z.infer<typeof PaseoAgentModelProviderSchema>;
|
||||
export type PaseoAgentProviderModelConfig = z.infer<typeof PaseoAgentModelSchema>;
|
||||
export type { PaseoAgentCatalogManifestEntry };
|
||||
|
||||
type PiModel = Model<Api>;
|
||||
type PiModelConfig = NonNullable<PaseoAgentModelProvider["config"]["models"]>[number];
|
||||
type ProviderOptions = PaseoAgentModelProviderEntry["options"];
|
||||
|
||||
const DEFAULT_INPUT: PiModelConfig["input"] = ["text"];
|
||||
const ZERO_COST: PiModelConfig["cost"] = { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 };
|
||||
const DEFAULT_CONTEXT_WINDOW = 128_000;
|
||||
const DEFAULT_MAX_TOKENS = 16_384;
|
||||
const DEFAULT_MODEL_FIELDS = [
|
||||
"id",
|
||||
"label",
|
||||
"api",
|
||||
"reasoning",
|
||||
"contextWindow",
|
||||
"maxTokens",
|
||||
] as const;
|
||||
|
||||
type ResolvedCatalogAuth =
|
||||
| { kind: "api_key"; envVar: string; keyUrl?: string; placeholder?: string; hint?: string }
|
||||
| { kind: "oauth"; flow: string };
|
||||
|
||||
export interface ResolvedProviderSettings {
|
||||
baseUrl: string;
|
||||
api: Api;
|
||||
apiKey?: string;
|
||||
headers?: Record<string, string>;
|
||||
authHeader?: boolean;
|
||||
}
|
||||
|
||||
function entries(config: PaseoAgentConfig): [string, PaseoAgentModelProviderEntry][] {
|
||||
return Object.entries(config.providers ?? {});
|
||||
}
|
||||
|
||||
function getPaseoAgentPiModels(catalogEntry: PaseoAgentCatalogRef): PiModel[] {
|
||||
return getModels(catalogEntry.piProvider);
|
||||
}
|
||||
|
||||
function requirePaseoAgentPrimaryModel(catalogEntry: PaseoAgentCatalogRef): PiModel {
|
||||
const first = getPaseoAgentPiModels(catalogEntry)[0];
|
||||
if (!first) {
|
||||
throw new Error(`Paseo Agent provider "${catalogEntry.id}" has no Pi models.`);
|
||||
}
|
||||
return first;
|
||||
}
|
||||
|
||||
function defaultPaseoAgentPiModels(catalogEntry: PaseoAgentCatalogRef): PiModel[] {
|
||||
return catalogEntry.defaultModels === false ? [] : getPaseoAgentPiModels(catalogEntry);
|
||||
}
|
||||
|
||||
export function resolvePaseoAgentCatalogAuth(
|
||||
catalogEntry: PaseoAgentCatalogRef,
|
||||
): ResolvedCatalogAuth {
|
||||
if (catalogEntry.auth?.kind === "oauth") {
|
||||
return { kind: "oauth", flow: catalogEntry.auth.flow ?? catalogEntry.piProvider };
|
||||
}
|
||||
|
||||
if (!catalogEntry.auth && getOAuthProvider(catalogEntry.piProvider)) {
|
||||
return { kind: "oauth", flow: catalogEntry.piProvider };
|
||||
}
|
||||
|
||||
const envVar = catalogEntry.auth?.kind === "api_key" ? catalogEntry.auth.envVar : undefined;
|
||||
if (!envVar) {
|
||||
throw new Error(`Paseo Agent provider "${catalogEntry.id}" has no auth source.`);
|
||||
}
|
||||
|
||||
return {
|
||||
kind: "api_key",
|
||||
envVar,
|
||||
...(catalogEntry.auth?.kind === "api_key" && catalogEntry.auth.keyUrl
|
||||
? { keyUrl: catalogEntry.auth.keyUrl }
|
||||
: {}),
|
||||
...(catalogEntry.auth?.kind === "api_key" && catalogEntry.auth.placeholder
|
||||
? { placeholder: catalogEntry.auth.placeholder }
|
||||
: {}),
|
||||
...(catalogEntry.auth?.kind === "api_key" && catalogEntry.auth.hint
|
||||
? { hint: catalogEntry.auth.hint }
|
||||
: {}),
|
||||
};
|
||||
}
|
||||
|
||||
export function resolvePaseoAgentProviderSettings(
|
||||
entry: PaseoAgentModelProviderEntry,
|
||||
catalogEntry: PaseoAgentCatalogRef = requirePaseoAgentCatalogEntry(entry.type),
|
||||
): ResolvedProviderSettings {
|
||||
const primaryModel = requirePaseoAgentPrimaryModel(catalogEntry);
|
||||
const auth = resolvePaseoAgentCatalogAuth(catalogEntry);
|
||||
const apiKey = auth.kind === "api_key" ? (entry.options.apiKey ?? `$${auth.envVar}`) : undefined;
|
||||
return {
|
||||
baseUrl: entry.options.baseUrl ?? primaryModel.baseUrl,
|
||||
api: entry.options.api ?? primaryModel.api,
|
||||
...(apiKey ? { apiKey } : {}),
|
||||
...(entry.options.headers ? { headers: entry.options.headers } : {}),
|
||||
...(entry.options.authHeader ? { authHeader: entry.options.authHeader } : {}),
|
||||
};
|
||||
}
|
||||
|
||||
function toCatalogModel(model: PiModel): PaseoAgentProviderModelConfig {
|
||||
return {
|
||||
id: model.id,
|
||||
label: model.name,
|
||||
api: model.api,
|
||||
reasoning: model.reasoning,
|
||||
contextWindow: model.contextWindow,
|
||||
maxTokens: model.maxTokens,
|
||||
};
|
||||
}
|
||||
|
||||
export function resolvePaseoAgentProviderModels(
|
||||
entry: PaseoAgentModelProviderEntry,
|
||||
catalogEntry: PaseoAgentCatalogRef = requirePaseoAgentCatalogEntry(entry.type),
|
||||
): PaseoAgentProviderModelConfig[] {
|
||||
return entry.options.models ?? defaultPaseoAgentPiModels(catalogEntry).map(toCatalogModel);
|
||||
}
|
||||
|
||||
export function isPaseoAgentDefaultModelSelection(
|
||||
models: PaseoAgentProviderModelConfig[] | undefined,
|
||||
catalogEntry: PaseoAgentCatalogRef,
|
||||
): boolean {
|
||||
if (!models) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const defaults = defaultPaseoAgentPiModels(catalogEntry).map(toCatalogModel);
|
||||
if (models.length !== defaults.length || defaults.length === 0) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return models.every((model, index) => {
|
||||
const defaultModel = defaults[index];
|
||||
return DEFAULT_MODEL_FIELDS.every((field) => model[field] === defaultModel?.[field]);
|
||||
});
|
||||
}
|
||||
|
||||
export function paseoAgentCatalogManifests(): PaseoAgentCatalogManifestEntry[] {
|
||||
return PASEO_AGENT_PROVIDER_CATALOG.map((catalogEntry: PaseoAgentCatalogRef) => {
|
||||
const primaryModel = requirePaseoAgentPrimaryModel(catalogEntry);
|
||||
const manifest: PaseoAgentCatalogManifestEntry = {
|
||||
id: catalogEntry.id,
|
||||
label: catalogEntry.label,
|
||||
api: primaryModel.api,
|
||||
baseUrl: primaryModel.baseUrl,
|
||||
auth: resolvePaseoAgentCatalogAuth(catalogEntry),
|
||||
models: defaultPaseoAgentPiModels(catalogEntry).map(toCatalogModel),
|
||||
};
|
||||
if (catalogEntry.iconName) {
|
||||
manifest.iconName = catalogEntry.iconName;
|
||||
}
|
||||
if (catalogEntry.docsUrl) {
|
||||
manifest.docsUrl = catalogEntry.docsUrl;
|
||||
}
|
||||
if (primaryModel.headers) {
|
||||
manifest.headers = { ...primaryModel.headers };
|
||||
}
|
||||
return manifest;
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether a resolved API-key value is actually configured. Mirrors Pi's config-value
|
||||
* semantics without importing Pi: literals and `!command` values count as present;
|
||||
* `$ENV` / `${ENV}` references count only when every referenced var is set.
|
||||
*/
|
||||
function isAuthConfigured(value: string | undefined, env: NodeJS.ProcessEnv): boolean {
|
||||
if (!value) {
|
||||
return false;
|
||||
}
|
||||
if (value.startsWith("!")) {
|
||||
return true;
|
||||
}
|
||||
const referencedVars = findEnvReferences(value);
|
||||
if (referencedVars.length === 0) {
|
||||
return true;
|
||||
}
|
||||
return referencedVars.every((name) => Boolean(env[name]));
|
||||
}
|
||||
|
||||
export function encodePaseoAgentModelId(providerName: string, modelId: string): string {
|
||||
return `${providerName}/${modelId}`;
|
||||
}
|
||||
|
||||
export function parsePaseoAgentModelId(modelId: string): PaseoAgentModelReference | null {
|
||||
const slash = modelId.indexOf("/");
|
||||
if (slash <= 0 || slash === modelId.length - 1) {
|
||||
return null;
|
||||
}
|
||||
return { provider: modelId.slice(0, slash), id: modelId.slice(slash + 1) };
|
||||
}
|
||||
|
||||
function applyModelOverrides(
|
||||
model: PiModel,
|
||||
options: ProviderOptions,
|
||||
override?: PaseoAgentProviderModelConfig,
|
||||
): PiModelConfig {
|
||||
return {
|
||||
id: override?.id ?? model.id,
|
||||
name: override?.label ?? model.name,
|
||||
api: override?.api ?? options.api ?? model.api,
|
||||
baseUrl: options.baseUrl ?? model.baseUrl,
|
||||
reasoning: override?.reasoning ?? model.reasoning,
|
||||
...(model.thinkingLevelMap ? { thinkingLevelMap: model.thinkingLevelMap } : {}),
|
||||
input: model.input,
|
||||
cost: model.cost,
|
||||
contextWindow: override?.contextWindow ?? model.contextWindow,
|
||||
maxTokens: override?.maxTokens ?? model.maxTokens,
|
||||
...(model.headers ? { headers: { ...model.headers } } : {}),
|
||||
...(model.compat ? { compat: model.compat } : {}),
|
||||
};
|
||||
}
|
||||
|
||||
function customModelFromOptions(
|
||||
model: PaseoAgentProviderModelConfig,
|
||||
options: ProviderOptions,
|
||||
fallback: PiModel,
|
||||
): PiModelConfig {
|
||||
return {
|
||||
id: model.id,
|
||||
name: model.label ?? model.id,
|
||||
api: model.api ?? options.api ?? fallback.api,
|
||||
baseUrl: options.baseUrl ?? fallback.baseUrl,
|
||||
reasoning: model.reasoning ?? false,
|
||||
input: fallback.input ?? DEFAULT_INPUT,
|
||||
cost: ZERO_COST,
|
||||
contextWindow: model.contextWindow ?? DEFAULT_CONTEXT_WINDOW,
|
||||
maxTokens: model.maxTokens ?? DEFAULT_MAX_TOKENS,
|
||||
...(fallback.headers ? { headers: { ...fallback.headers } } : {}),
|
||||
};
|
||||
}
|
||||
|
||||
function toPiModels(
|
||||
entry: PaseoAgentModelProviderEntry,
|
||||
catalogEntry: PaseoAgentCatalogRef,
|
||||
): PiModelConfig[] {
|
||||
const piModels = getPaseoAgentPiModels(catalogEntry);
|
||||
const piModelsById = new Map(piModels.map((model) => [model.id, model]));
|
||||
const selectedModels = entry.options.models;
|
||||
if (!selectedModels) {
|
||||
return defaultPaseoAgentPiModels(catalogEntry).map((model) =>
|
||||
applyModelOverrides(model, entry.options),
|
||||
);
|
||||
}
|
||||
|
||||
const fallback = requirePaseoAgentPrimaryModel(catalogEntry);
|
||||
return selectedModels.map((model) => {
|
||||
const piModel = piModelsById.get(model.id);
|
||||
return piModel
|
||||
? applyModelOverrides(piModel, entry.options, model)
|
||||
: customModelFromOptions(model, entry.options, fallback);
|
||||
});
|
||||
}
|
||||
|
||||
export async function paseoAgentModelProviders(
|
||||
config: PaseoAgentConfig,
|
||||
env: NodeJS.ProcessEnv = process.env,
|
||||
): Promise<PaseoAgentModelProvider[]> {
|
||||
const providers: PaseoAgentModelProvider[] = [];
|
||||
|
||||
for (const [name, entry] of entries(config)) {
|
||||
const catalogEntry = requirePaseoAgentCatalogEntry(entry.type);
|
||||
const auth = resolvePaseoAgentCatalogAuth(catalogEntry);
|
||||
const settings = resolvePaseoAgentProviderSettings(entry, catalogEntry);
|
||||
const models = toPiModels(entry, catalogEntry);
|
||||
const providerConfig = {
|
||||
baseUrl: settings.baseUrl,
|
||||
api: settings.api,
|
||||
...(settings.headers ? { headers: settings.headers } : {}),
|
||||
models,
|
||||
};
|
||||
|
||||
if (auth.kind === "oauth") {
|
||||
const refreshToken = entry.options.refreshToken
|
||||
? await resolveRefreshTokenExpression(entry.options.refreshToken, env)
|
||||
: undefined;
|
||||
providers.push({
|
||||
name,
|
||||
config: providerConfig,
|
||||
oauth: { flow: auth.flow, ...(refreshToken ? { refreshToken } : {}) },
|
||||
});
|
||||
continue;
|
||||
}
|
||||
|
||||
providers.push({
|
||||
name,
|
||||
config: {
|
||||
...providerConfig,
|
||||
...(settings.apiKey ? { apiKey: settings.apiKey } : {}),
|
||||
...(settings.authHeader ? { authHeader: settings.authHeader } : {}),
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
return providers;
|
||||
}
|
||||
|
||||
export function listPaseoAgentModels(config: PaseoAgentConfig): AgentModelDefinition[] {
|
||||
const models: AgentModelDefinition[] = [];
|
||||
for (const [name, entry] of entries(config)) {
|
||||
const catalogEntry = requirePaseoAgentCatalogEntry(entry.type);
|
||||
for (const model of resolvePaseoAgentProviderModels(entry, catalogEntry)) {
|
||||
const id = encodePaseoAgentModelId(name, model.id);
|
||||
models.push({
|
||||
provider: PASEO_AGENT_PROVIDER,
|
||||
id,
|
||||
label: model.label ?? model.id,
|
||||
description: `${name} - ${model.id}`,
|
||||
isDefault: config.defaultModel === id,
|
||||
});
|
||||
}
|
||||
}
|
||||
return models;
|
||||
}
|
||||
|
||||
export function paseoAgentHasUsableModel(
|
||||
config: PaseoAgentConfig,
|
||||
env: NodeJS.ProcessEnv = process.env,
|
||||
isOAuthAuthed: (providerInstance: string, binding: OAuthCredentialBinding) => boolean = () =>
|
||||
false,
|
||||
): boolean {
|
||||
return entries(config).some(([name, entry]) => {
|
||||
const catalogEntry = requirePaseoAgentCatalogEntry(entry.type);
|
||||
const models = resolvePaseoAgentProviderModels(entry, catalogEntry);
|
||||
if (models.length === 0) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const auth = resolvePaseoAgentCatalogAuth(catalogEntry);
|
||||
if (auth.kind === "oauth") {
|
||||
if (
|
||||
entry.options.refreshToken &&
|
||||
isRefreshTokenExpressionConfigured(entry.options.refreshToken, env)
|
||||
) {
|
||||
return true;
|
||||
}
|
||||
return isOAuthAuthed(name, {
|
||||
flow: auth.flow,
|
||||
baseUrl: resolvePaseoAgentProviderSettings(entry, catalogEntry).baseUrl,
|
||||
});
|
||||
}
|
||||
return isAuthConfigured(resolvePaseoAgentProviderSettings(entry, catalogEntry).apiKey, env);
|
||||
});
|
||||
}
|
||||
|
||||
export function resolvePaseoAgentModel(
|
||||
config: PaseoAgentConfig,
|
||||
requestedModelId: string | null | undefined,
|
||||
registeredProviders: PaseoAgentModelProvider[] = paseoAgentModelInventory(config),
|
||||
agentDefaultModelId?: string | null,
|
||||
): PaseoAgentModelReference | undefined {
|
||||
if (requestedModelId) {
|
||||
return parsePaseoAgentModelId(requestedModelId) ?? undefined;
|
||||
}
|
||||
|
||||
for (const candidate of [agentDefaultModelId, config.defaultModel, firstModelId(config)]) {
|
||||
if (!candidate) {
|
||||
continue;
|
||||
}
|
||||
const parsed = parsePaseoAgentModelId(candidate);
|
||||
if (parsed && hasRegisteredModel(registeredProviders, parsed)) {
|
||||
return parsed;
|
||||
}
|
||||
}
|
||||
|
||||
return firstRegisteredModel(registeredProviders);
|
||||
}
|
||||
|
||||
function paseoAgentModelInventory(config: PaseoAgentConfig): PaseoAgentModelProvider[] {
|
||||
return entries(config).map(([name, entry]) => {
|
||||
const catalogEntry = requirePaseoAgentCatalogEntry(entry.type);
|
||||
return { name, config: { models: toPiModels(entry, catalogEntry) } };
|
||||
});
|
||||
}
|
||||
|
||||
function firstModelId(config: PaseoAgentConfig): string | undefined {
|
||||
for (const [name, entry] of entries(config)) {
|
||||
const catalogEntry = requirePaseoAgentCatalogEntry(entry.type);
|
||||
const first = resolvePaseoAgentProviderModels(entry, catalogEntry)[0];
|
||||
if (first) {
|
||||
return encodePaseoAgentModelId(name, first.id);
|
||||
}
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
|
||||
function hasRegisteredModel(
|
||||
providers: PaseoAgentModelProvider[],
|
||||
model: PaseoAgentModelReference,
|
||||
): boolean {
|
||||
return providers.some(
|
||||
(provider) =>
|
||||
provider.name === model.provider &&
|
||||
provider.config.models?.some((registered) => registered.id === model.id),
|
||||
);
|
||||
}
|
||||
|
||||
function firstRegisteredModel(
|
||||
providers: PaseoAgentModelProvider[],
|
||||
): PaseoAgentModelReference | undefined {
|
||||
for (const provider of providers) {
|
||||
const first = provider.config.models?.[0];
|
||||
if (first) {
|
||||
return { provider: provider.name, id: first.id };
|
||||
}
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
@@ -1,23 +0,0 @@
|
||||
const ENV_REFERENCE_PATTERN = /\$\{?([A-Za-z_][A-Za-z0-9_]*)\}?/g;
|
||||
const ENV_REFERENCE_DETECT = /\$\{?[A-Za-z_][A-Za-z0-9_]*\}?/;
|
||||
|
||||
export function findEnvReferences(value: string): string[] {
|
||||
return Array.from(value.matchAll(ENV_REFERENCE_PATTERN), (match) => match[1]);
|
||||
}
|
||||
|
||||
export function hasEnvReference(value: string): boolean {
|
||||
return ENV_REFERENCE_DETECT.test(value);
|
||||
}
|
||||
|
||||
export function substituteEnvReferences(value: string, env: NodeJS.ProcessEnv): string | undefined {
|
||||
let missing = false;
|
||||
const result = value.replace(ENV_REFERENCE_PATTERN, (_match, name: string) => {
|
||||
const resolved = env[name];
|
||||
if (resolved === undefined) {
|
||||
missing = true;
|
||||
return "";
|
||||
}
|
||||
return resolved;
|
||||
});
|
||||
return missing ? undefined : result;
|
||||
}
|
||||
@@ -1,78 +0,0 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
|
||||
import {
|
||||
convertPromptInput,
|
||||
mapToolDetail,
|
||||
parseToolArgs,
|
||||
parseToolResult,
|
||||
} from "./event-mapping.js";
|
||||
|
||||
describe("parseToolArgs", () => {
|
||||
it("classifies known built-in tools", () => {
|
||||
expect(parseToolArgs("bash", { command: "ls" }).kind).toBe("bash");
|
||||
expect(parseToolArgs("read", { path: "/tmp/a" }).kind).toBe("read");
|
||||
expect(parseToolArgs("grep", { pattern: "foo" }).kind).toBe("grep");
|
||||
});
|
||||
|
||||
it("falls back to unknown for unrecognized tools or bad args", () => {
|
||||
expect(parseToolArgs("mcp__paseo__do", { anything: 1 }).kind).toBe("unknown");
|
||||
expect(parseToolArgs("bash", { notACommand: true }).kind).toBe("unknown");
|
||||
});
|
||||
|
||||
it("accepts legacy edit args (old_string/new_string)", () => {
|
||||
const call = parseToolArgs("edit", {
|
||||
path: "/tmp/a",
|
||||
old_string: "x",
|
||||
new_string: "y",
|
||||
});
|
||||
expect(call.kind).toBe("edit");
|
||||
if (call.kind === "edit") {
|
||||
expect(call.args.edits[0]).toEqual({ oldText: "x", newText: "y" });
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe("mapToolDetail", () => {
|
||||
it("maps a bash call with exit code and output", () => {
|
||||
const call = parseToolArgs("bash", { command: "echo hi" });
|
||||
const detail = mapToolDetail(call, parseToolResult({ output: "hi", exitCode: 0 }));
|
||||
expect(detail).toMatchObject({ type: "shell", command: "echo hi", output: "hi", exitCode: 0 });
|
||||
});
|
||||
|
||||
it("maps an edit call to a diff detail", () => {
|
||||
const call = parseToolArgs("edit", {
|
||||
path: "/tmp/a",
|
||||
edits: [{ oldText: "a", newText: "b" }],
|
||||
});
|
||||
const detail = mapToolDetail(call, parseToolResult({ details: { diff: "--- diff ---" } }));
|
||||
expect(detail).toMatchObject({
|
||||
type: "edit",
|
||||
filePath: "/tmp/a",
|
||||
oldString: "a",
|
||||
newString: "b",
|
||||
unifiedDiff: "--- diff ---",
|
||||
});
|
||||
});
|
||||
|
||||
it("maps unknown tools to a passthrough detail", () => {
|
||||
const call = parseToolArgs("mystery", { foo: 1 });
|
||||
const detail = mapToolDetail(call, parseToolResult("done"));
|
||||
expect(detail.type).toBe("unknown");
|
||||
});
|
||||
});
|
||||
|
||||
describe("convertPromptInput", () => {
|
||||
it("passes a plain string through", () => {
|
||||
expect(convertPromptInput("hello")).toEqual({ text: "hello" });
|
||||
});
|
||||
|
||||
it("joins text blocks and collects images", () => {
|
||||
const payload = convertPromptInput([
|
||||
{ type: "text", text: "one" },
|
||||
{ type: "image", data: "base64", mimeType: "image/png" },
|
||||
{ type: "text", text: "two" },
|
||||
]);
|
||||
expect(payload.text).toBe("one\n\ntwo");
|
||||
expect(payload.images).toEqual([{ type: "image", data: "base64", mimeType: "image/png" }]);
|
||||
});
|
||||
});
|
||||
@@ -1,365 +0,0 @@
|
||||
import type {
|
||||
BashToolInput,
|
||||
EditToolInput,
|
||||
FindToolInput,
|
||||
GrepToolInput,
|
||||
LsToolInput,
|
||||
ReadToolInput,
|
||||
SessionStats,
|
||||
WriteToolInput,
|
||||
} from "@earendil-works/pi-coding-agent";
|
||||
import type { ImageContent, TextContent } from "@earendil-works/pi-ai";
|
||||
import { z } from "zod";
|
||||
|
||||
import type { AgentPromptInput, AgentUsage, ToolCallDetail } from "../../agent-sdk-types.js";
|
||||
import { renderPromptAttachmentAsText } from "../../prompt-attachments.js";
|
||||
|
||||
// Pure event/tool/model mapping ported from the old direct Pi provider. These
|
||||
// translate Pi's harness shapes into Paseo's provider-agnostic timeline types.
|
||||
// Kept free of any session/runtime state so they can be unit-tested directly.
|
||||
|
||||
export interface PiPromptPayload {
|
||||
text: string;
|
||||
images?: ImageContent[];
|
||||
}
|
||||
|
||||
interface ToolCallOutputSummary {
|
||||
output?: string;
|
||||
exitCode?: number | null;
|
||||
}
|
||||
|
||||
interface PiBashToolCall {
|
||||
kind: "bash";
|
||||
toolName: "bash";
|
||||
args: BashToolInput;
|
||||
}
|
||||
interface PiReadToolCall {
|
||||
kind: "read";
|
||||
toolName: "read";
|
||||
args: ReadToolInput;
|
||||
}
|
||||
interface PiEditToolCall {
|
||||
kind: "edit";
|
||||
toolName: "edit";
|
||||
args: EditToolInput;
|
||||
}
|
||||
interface PiWriteToolCall {
|
||||
kind: "write";
|
||||
toolName: "write";
|
||||
args: WriteToolInput;
|
||||
}
|
||||
interface PiFindToolCall {
|
||||
kind: "find";
|
||||
toolName: "find";
|
||||
args: FindToolInput;
|
||||
}
|
||||
interface PiGrepToolCall {
|
||||
kind: "grep";
|
||||
toolName: "grep";
|
||||
args: GrepToolInput;
|
||||
}
|
||||
interface PiLsToolCall {
|
||||
kind: "ls";
|
||||
toolName: "ls";
|
||||
args: LsToolInput;
|
||||
}
|
||||
interface PiUnknownToolCall {
|
||||
kind: "unknown";
|
||||
toolName: string;
|
||||
args: unknown;
|
||||
}
|
||||
|
||||
export type PiTrackedToolCall =
|
||||
| PiBashToolCall
|
||||
| PiReadToolCall
|
||||
| PiEditToolCall
|
||||
| PiWriteToolCall
|
||||
| PiFindToolCall
|
||||
| PiGrepToolCall
|
||||
| PiLsToolCall
|
||||
| PiUnknownToolCall;
|
||||
|
||||
const PiToolResultTextContentSchema = z.object({ type: z.literal("text"), text: z.string() });
|
||||
const PiToolResultUnknownContentSchema = z.object({ type: z.string() }).passthrough();
|
||||
const PiToolResultContentSchema = z.union([
|
||||
PiToolResultTextContentSchema,
|
||||
PiToolResultUnknownContentSchema,
|
||||
]);
|
||||
const PiToolResultObjectSchema = z
|
||||
.object({
|
||||
output: z.string().optional(),
|
||||
stdout: z.string().optional(),
|
||||
text: z.string().optional(),
|
||||
content: z.array(PiToolResultContentSchema).optional(),
|
||||
exitCode: z.number().optional(),
|
||||
code: z.number().optional(),
|
||||
details: z.object({ diff: z.string().optional() }).passthrough().optional(),
|
||||
})
|
||||
.passthrough();
|
||||
const PiToolResultSchema = z.union([z.string(), PiToolResultObjectSchema, z.null()]);
|
||||
|
||||
type PiToolResult = z.infer<typeof PiToolResultSchema>;
|
||||
|
||||
const BashToolInputSchema: z.ZodType<BashToolInput> = z.object({
|
||||
command: z.string(),
|
||||
timeout: z.number().optional(),
|
||||
});
|
||||
const ReadToolInputSchema: z.ZodType<ReadToolInput> = z.object({
|
||||
path: z.string(),
|
||||
offset: z.number().optional(),
|
||||
limit: z.number().optional(),
|
||||
});
|
||||
const EditToolInputSchema: z.ZodType<EditToolInput> = z.object({
|
||||
path: z.string(),
|
||||
edits: z.array(z.object({ oldText: z.string(), newText: z.string() })),
|
||||
});
|
||||
const LegacyEditToolInputSchema = z.object({
|
||||
path: z.string(),
|
||||
old_string: z.string().optional(),
|
||||
oldString: z.string().optional(),
|
||||
new_string: z.string().optional(),
|
||||
newString: z.string().optional(),
|
||||
});
|
||||
const WriteToolInputSchema: z.ZodType<WriteToolInput> = z.object({
|
||||
path: z.string(),
|
||||
content: z.string(),
|
||||
});
|
||||
const FindToolInputSchema: z.ZodType<FindToolInput> = z.object({
|
||||
pattern: z.string(),
|
||||
path: z.string().optional(),
|
||||
limit: z.number().optional(),
|
||||
});
|
||||
const GrepToolInputSchema: z.ZodType<GrepToolInput> = z.object({
|
||||
pattern: z.string(),
|
||||
path: z.string().optional(),
|
||||
glob: z.string().optional(),
|
||||
ignoreCase: z.boolean().optional(),
|
||||
literal: z.boolean().optional(),
|
||||
context: z.number().optional(),
|
||||
limit: z.number().optional(),
|
||||
});
|
||||
const LsToolInputSchema: z.ZodType<LsToolInput> = z.object({
|
||||
path: z.string().optional(),
|
||||
limit: z.number().optional(),
|
||||
});
|
||||
|
||||
type SimpleToolKind = "bash" | "read" | "write" | "find" | "grep" | "ls";
|
||||
const SIMPLE_TOOL_SCHEMAS: {
|
||||
[K in SimpleToolKind]: { safeParse: (data: unknown) => { success: boolean; data?: unknown } };
|
||||
} = {
|
||||
bash: BashToolInputSchema,
|
||||
read: ReadToolInputSchema,
|
||||
write: WriteToolInputSchema,
|
||||
find: FindToolInputSchema,
|
||||
grep: GrepToolInputSchema,
|
||||
ls: LsToolInputSchema,
|
||||
};
|
||||
|
||||
export function parseToolResult(rawResult: unknown): PiToolResult {
|
||||
const parsed = PiToolResultSchema.safeParse(rawResult);
|
||||
return parsed.success ? parsed.data : null;
|
||||
}
|
||||
|
||||
function normalizeLegacyEditArgs(rawArgs: unknown): EditToolInput | null {
|
||||
const parsed = LegacyEditToolInputSchema.safeParse(rawArgs);
|
||||
if (!parsed.success) {
|
||||
return null;
|
||||
}
|
||||
const oldText = parsed.data.old_string ?? parsed.data.oldString;
|
||||
const newText = parsed.data.new_string ?? parsed.data.newString;
|
||||
if (!oldText || newText === undefined) {
|
||||
return null;
|
||||
}
|
||||
return { path: parsed.data.path, edits: [{ oldText, newText }] };
|
||||
}
|
||||
|
||||
function parseEditToolArgs(rawArgs: unknown): PiTrackedToolCall {
|
||||
const parsed = EditToolInputSchema.safeParse(rawArgs);
|
||||
if (parsed.success) {
|
||||
return { kind: "edit", toolName: "edit", args: parsed.data };
|
||||
}
|
||||
const legacyArgs = normalizeLegacyEditArgs(rawArgs);
|
||||
if (legacyArgs) {
|
||||
return { kind: "edit", toolName: "edit", args: legacyArgs };
|
||||
}
|
||||
return { kind: "unknown", toolName: "edit", args: rawArgs ?? null };
|
||||
}
|
||||
|
||||
export function parseToolArgs(toolName: string, rawArgs: unknown): PiTrackedToolCall {
|
||||
if (toolName === "edit") {
|
||||
return parseEditToolArgs(rawArgs);
|
||||
}
|
||||
const schema = SIMPLE_TOOL_SCHEMAS[toolName as SimpleToolKind];
|
||||
if (schema) {
|
||||
const parsed = schema.safeParse(rawArgs);
|
||||
if (parsed.success) {
|
||||
return { kind: toolName as SimpleToolKind, toolName, args: parsed.data } as PiTrackedToolCall;
|
||||
}
|
||||
}
|
||||
return { kind: "unknown", toolName, args: rawArgs ?? null };
|
||||
}
|
||||
|
||||
export function extractTextFromToolResult(result: PiToolResult): string | undefined {
|
||||
if (typeof result === "string") {
|
||||
return result;
|
||||
}
|
||||
if (!result) {
|
||||
return undefined;
|
||||
}
|
||||
const directText = result.output ?? result.stdout ?? result.text;
|
||||
if (directText) {
|
||||
return directText;
|
||||
}
|
||||
if (!result.content) {
|
||||
return undefined;
|
||||
}
|
||||
const textParts: string[] = [];
|
||||
for (const block of result.content) {
|
||||
if (block.type === "text" && "text" in block) {
|
||||
textParts.push(block.text as string);
|
||||
}
|
||||
}
|
||||
return textParts.length > 0 ? textParts.join("\n") : undefined;
|
||||
}
|
||||
|
||||
function resolveToolCallOutput(result: PiToolResult): ToolCallOutputSummary {
|
||||
if (typeof result === "string") {
|
||||
return { output: result };
|
||||
}
|
||||
if (!result) {
|
||||
return {};
|
||||
}
|
||||
const summary: ToolCallOutputSummary = { output: extractTextFromToolResult(result) };
|
||||
if (typeof result.exitCode === "number") {
|
||||
return { ...summary, exitCode: result.exitCode };
|
||||
}
|
||||
if (typeof result.code === "number") {
|
||||
return { ...summary, exitCode: result.code };
|
||||
}
|
||||
return { ...summary, exitCode: null };
|
||||
}
|
||||
|
||||
export function mapToolDetail(toolCall: PiTrackedToolCall, result?: PiToolResult): ToolCallDetail {
|
||||
const parsedResult = result ?? null;
|
||||
switch (toolCall.kind) {
|
||||
case "bash": {
|
||||
const summary = resolveToolCallOutput(parsedResult);
|
||||
return {
|
||||
type: "shell",
|
||||
command: toolCall.args.command,
|
||||
output: summary.output,
|
||||
exitCode: summary.exitCode,
|
||||
};
|
||||
}
|
||||
case "read":
|
||||
return {
|
||||
type: "read",
|
||||
filePath: toolCall.args.path,
|
||||
content: extractTextFromToolResult(parsedResult),
|
||||
offset: toolCall.args.offset,
|
||||
limit: toolCall.args.limit,
|
||||
};
|
||||
case "edit": {
|
||||
const firstEdit = toolCall.args.edits[0];
|
||||
const unifiedDiff =
|
||||
parsedResult && typeof parsedResult !== "string" ? parsedResult.details?.diff : undefined;
|
||||
return {
|
||||
type: "edit",
|
||||
filePath: toolCall.args.path,
|
||||
oldString: firstEdit?.oldText,
|
||||
newString: firstEdit?.newText,
|
||||
unifiedDiff,
|
||||
};
|
||||
}
|
||||
case "write":
|
||||
return { type: "write", filePath: toolCall.args.path, content: toolCall.args.content };
|
||||
case "find":
|
||||
return {
|
||||
type: "search",
|
||||
query: toolCall.args.pattern,
|
||||
toolName: "search",
|
||||
content: typeof parsedResult === "string" ? parsedResult : undefined,
|
||||
};
|
||||
case "grep":
|
||||
return {
|
||||
type: "search",
|
||||
query: toolCall.args.pattern,
|
||||
toolName: "grep",
|
||||
content: typeof parsedResult === "string" ? parsedResult : undefined,
|
||||
};
|
||||
case "ls":
|
||||
return {
|
||||
type: "search",
|
||||
query: toolCall.args.path ?? "ls",
|
||||
content: typeof parsedResult === "string" ? parsedResult : undefined,
|
||||
};
|
||||
default:
|
||||
return { type: "unknown", input: toolCall.args, output: parsedResult };
|
||||
}
|
||||
}
|
||||
|
||||
export function convertPromptInput(prompt: AgentPromptInput): PiPromptPayload {
|
||||
if (typeof prompt === "string") {
|
||||
return { text: prompt };
|
||||
}
|
||||
const textParts: string[] = [];
|
||||
const images: ImageContent[] = [];
|
||||
for (const block of prompt) {
|
||||
if (block.type === "text") {
|
||||
textParts.push(block.text);
|
||||
continue;
|
||||
}
|
||||
if (block.type === "image") {
|
||||
images.push({ type: "image", data: block.data, mimeType: block.mimeType });
|
||||
continue;
|
||||
}
|
||||
textParts.push(renderPromptAttachmentAsText(block));
|
||||
}
|
||||
const payload: PiPromptPayload = { text: textParts.join("\n\n") };
|
||||
if (images.length > 0) {
|
||||
payload.images = images;
|
||||
}
|
||||
return payload;
|
||||
}
|
||||
|
||||
export function toAgentUsage(stats: SessionStats): AgentUsage | undefined {
|
||||
const inputTokens = stats.tokens.input;
|
||||
const cachedInputTokens = stats.tokens.cacheRead;
|
||||
const outputTokens = stats.tokens.output;
|
||||
const totalCostUsd = stats.cost;
|
||||
const contextWindowMaxTokens = stats.contextUsage?.contextWindow ?? undefined;
|
||||
const contextWindowUsedTokens = stats.contextUsage?.tokens ?? undefined;
|
||||
if (
|
||||
inputTokens === 0 &&
|
||||
cachedInputTokens === 0 &&
|
||||
outputTokens === 0 &&
|
||||
totalCostUsd === 0 &&
|
||||
contextWindowMaxTokens === undefined &&
|
||||
contextWindowUsedTokens === undefined
|
||||
) {
|
||||
return undefined;
|
||||
}
|
||||
return {
|
||||
inputTokens,
|
||||
cachedInputTokens,
|
||||
outputTokens,
|
||||
totalCostUsd,
|
||||
...(typeof contextWindowMaxTokens === "number" ? { contextWindowMaxTokens } : {}),
|
||||
...(typeof contextWindowUsedTokens === "number" ? { contextWindowUsedTokens } : {}),
|
||||
};
|
||||
}
|
||||
|
||||
const PiTextContentSchema = z.object({ type: z.literal("text"), text: z.string() });
|
||||
|
||||
export function getUserMessageText(content: string | (TextContent | ImageContent)[]): string {
|
||||
if (typeof content === "string") {
|
||||
return content;
|
||||
}
|
||||
const textParts: string[] = [];
|
||||
for (const block of content) {
|
||||
if (PiTextContentSchema.safeParse(block).success) {
|
||||
textParts.push((block as TextContent).text);
|
||||
}
|
||||
}
|
||||
return textParts.join("\n\n");
|
||||
}
|
||||
@@ -1,169 +0,0 @@
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
|
||||
import { createTestLogger } from "../../../../test-utils/test-logger.js";
|
||||
import type { McpServerConfig } from "../../agent-sdk-types.js";
|
||||
import {
|
||||
type McpCallToolResult,
|
||||
type McpConnection,
|
||||
type McpConnectionFactory,
|
||||
type McpToolInfo,
|
||||
createMcpToolBridge,
|
||||
mapMcpToolContent,
|
||||
} from "./mcp-bridge.js";
|
||||
|
||||
interface FakeConnectionSpec {
|
||||
tools?: McpToolInfo[];
|
||||
listToolsError?: Error;
|
||||
callResult?: McpCallToolResult;
|
||||
}
|
||||
|
||||
function fakeConnection(spec: FakeConnectionSpec) {
|
||||
const calls: { toolName: string; args: Record<string, unknown> }[] = [];
|
||||
const closed = { value: false };
|
||||
const connection: McpConnection = {
|
||||
async listTools() {
|
||||
if (spec.listToolsError) {
|
||||
throw spec.listToolsError;
|
||||
}
|
||||
return spec.tools ?? [];
|
||||
},
|
||||
async callTool(toolName, args) {
|
||||
calls.push({ toolName, args });
|
||||
return spec.callResult ?? { content: [{ type: "text", text: "ok" }] };
|
||||
},
|
||||
async close() {
|
||||
closed.value = true;
|
||||
},
|
||||
};
|
||||
return { connection, calls, closed };
|
||||
}
|
||||
|
||||
const HTTP_SERVER: McpServerConfig = { type: "http", url: "https://example.test/mcp" };
|
||||
|
||||
describe("mapMcpToolContent", () => {
|
||||
it("maps text and image blocks, notes other kinds", () => {
|
||||
const content = mapMcpToolContent({
|
||||
content: [
|
||||
{ type: "text", text: "hello" },
|
||||
{ type: "image", data: "b64", mimeType: "image/png" },
|
||||
{ type: "resource_link", uri: "file:///x" },
|
||||
{ type: "audio", mimeType: "audio/wav" },
|
||||
],
|
||||
});
|
||||
expect(content).toEqual([
|
||||
{ type: "text", text: "hello" },
|
||||
{ type: "image", data: "b64", mimeType: "image/png" },
|
||||
{ type: "text", text: "[resource file:///x]" },
|
||||
{ type: "text", text: "[audio audio/wav]" },
|
||||
]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("createMcpToolBridge", () => {
|
||||
it("lists tools from each server as namespaced Pi tools", async () => {
|
||||
const { connection } = fakeConnection({
|
||||
tools: [
|
||||
{ name: "do_thing", description: "does a thing", inputSchema: { type: "object" } },
|
||||
{ name: "other", inputSchema: { type: "object" } },
|
||||
],
|
||||
});
|
||||
const connect: McpConnectionFactory = async () => connection;
|
||||
|
||||
const bridge = await createMcpToolBridge({
|
||||
mcpServers: { paseo: HTTP_SERVER },
|
||||
logger: createTestLogger(),
|
||||
connect,
|
||||
});
|
||||
|
||||
expect(bridge.tools.map((t) => t.name)).toEqual(["paseo__do_thing", "paseo__other"]);
|
||||
expect(bridge.tools[0]?.description).toBe("does a thing");
|
||||
await bridge.close();
|
||||
});
|
||||
|
||||
it("proxies execute to callTool and maps the result", async () => {
|
||||
const { connection, calls } = fakeConnection({
|
||||
tools: [{ name: "echo", inputSchema: { type: "object" } }],
|
||||
callResult: { content: [{ type: "text", text: "pong" }] },
|
||||
});
|
||||
const bridge = await createMcpToolBridge({
|
||||
mcpServers: { paseo: HTTP_SERVER },
|
||||
logger: createTestLogger(),
|
||||
connect: async () => connection,
|
||||
});
|
||||
|
||||
const tool = bridge.tools[0];
|
||||
const result = await tool.execute("call-1", { msg: "ping" }, undefined, undefined, {} as never);
|
||||
|
||||
expect(calls).toEqual([{ toolName: "echo", args: { msg: "ping" } }]);
|
||||
expect(result.content).toEqual([{ type: "text", text: "pong" }]);
|
||||
await bridge.close();
|
||||
});
|
||||
|
||||
it("throws from execute when the MCP result is an error", async () => {
|
||||
const { connection } = fakeConnection({
|
||||
tools: [{ name: "boom", inputSchema: { type: "object" } }],
|
||||
callResult: { isError: true, content: [{ type: "text", text: "kaboom" }] },
|
||||
});
|
||||
const bridge = await createMcpToolBridge({
|
||||
mcpServers: { paseo: HTTP_SERVER },
|
||||
logger: createTestLogger(),
|
||||
connect: async () => connection,
|
||||
});
|
||||
|
||||
await expect(
|
||||
bridge.tools[0].execute("call-1", {}, undefined, undefined, {} as never),
|
||||
).rejects.toThrow(/kaboom/);
|
||||
await bridge.close();
|
||||
});
|
||||
|
||||
it("skips a server whose listTools fails but still closes it on teardown", async () => {
|
||||
const { connection, closed } = fakeConnection({ listToolsError: new Error("nope") });
|
||||
const bridge = await createMcpToolBridge({
|
||||
mcpServers: { paseo: HTTP_SERVER },
|
||||
logger: createTestLogger(),
|
||||
connect: async () => connection,
|
||||
});
|
||||
|
||||
expect(bridge.tools).toHaveLength(0);
|
||||
await bridge.close();
|
||||
expect(closed.value).toBe(true);
|
||||
});
|
||||
|
||||
it("skips a server that fails to connect without throwing", async () => {
|
||||
const connect = vi.fn(async () => {
|
||||
throw new Error("connect failed");
|
||||
});
|
||||
const bridge = await createMcpToolBridge({
|
||||
mcpServers: { paseo: HTTP_SERVER },
|
||||
logger: createTestLogger(),
|
||||
connect,
|
||||
});
|
||||
|
||||
expect(bridge.tools).toHaveLength(0);
|
||||
expect(connect).toHaveBeenCalledTimes(1);
|
||||
await bridge.close();
|
||||
});
|
||||
|
||||
it("closes every connection on teardown", async () => {
|
||||
const a = fakeConnection({ tools: [{ name: "t", inputSchema: { type: "object" } }] });
|
||||
const b = fakeConnection({ tools: [{ name: "u", inputSchema: { type: "object" } }] });
|
||||
const connect: McpConnectionFactory = async (serverName) =>
|
||||
serverName === "a" ? a.connection : b.connection;
|
||||
|
||||
const bridge = await createMcpToolBridge({
|
||||
mcpServers: { a: HTTP_SERVER, b: HTTP_SERVER },
|
||||
logger: createTestLogger(),
|
||||
connect,
|
||||
});
|
||||
await bridge.close();
|
||||
|
||||
expect(a.closed.value).toBe(true);
|
||||
expect(b.closed.value).toBe(true);
|
||||
});
|
||||
|
||||
it("produces no tools when there are no MCP servers", async () => {
|
||||
const bridge = await createMcpToolBridge({ logger: createTestLogger() });
|
||||
expect(bridge.tools).toHaveLength(0);
|
||||
await bridge.close();
|
||||
});
|
||||
});
|
||||
@@ -1,245 +0,0 @@
|
||||
import { Client } from "@modelcontextprotocol/sdk/client/index.js";
|
||||
import { SSEClientTransport } from "@modelcontextprotocol/sdk/client/sse.js";
|
||||
import { StdioClientTransport } from "@modelcontextprotocol/sdk/client/stdio.js";
|
||||
import { StreamableHTTPClientTransport } from "@modelcontextprotocol/sdk/client/streamableHttp.js";
|
||||
import type { Transport } from "@modelcontextprotocol/sdk/shared/transport.js";
|
||||
import type { ImageContent, TextContent } from "@earendil-works/pi-ai";
|
||||
import type { Logger } from "pino";
|
||||
|
||||
import type { AgentToolResultLike, ToolDefinition } from "./pi-services.js";
|
||||
import type { McpServerConfig } from "../../agent-sdk-types.js";
|
||||
import { mcpInputSchemaToTypeBox } from "./mcp-schema.js";
|
||||
|
||||
// Provider-owned bridge that turns `AgentSessionConfig.mcpServers` into Pi
|
||||
// `customTools`. It owns the MCP client lifecycle: connect on creation, expose tools,
|
||||
// proxy execution, and tear down on session close. Network/transport construction is
|
||||
// behind an injectable connection factory so the bridge is testable with a fake.
|
||||
|
||||
const MCP_CLIENT_NAME = "paseo-agent";
|
||||
const MCP_CLIENT_VERSION = "0.1.0";
|
||||
|
||||
export interface McpToolInfo {
|
||||
name: string;
|
||||
description?: string;
|
||||
inputSchema?: unknown;
|
||||
}
|
||||
|
||||
interface McpContentBlock {
|
||||
type: string;
|
||||
text?: string;
|
||||
data?: string;
|
||||
mimeType?: string;
|
||||
uri?: string;
|
||||
resource?: { text?: string; uri?: string; mimeType?: string; blob?: string };
|
||||
}
|
||||
|
||||
export interface McpCallToolResult {
|
||||
content?: McpContentBlock[];
|
||||
isError?: boolean;
|
||||
structuredContent?: unknown;
|
||||
}
|
||||
|
||||
/** A live connection to one MCP server. The default impl wraps the MCP SDK Client. */
|
||||
export interface McpConnection {
|
||||
listTools(): Promise<McpToolInfo[]>;
|
||||
callTool(toolName: string, args: Record<string, unknown>): Promise<McpCallToolResult>;
|
||||
close(): Promise<void>;
|
||||
}
|
||||
|
||||
export type McpConnectionFactory = (
|
||||
serverName: string,
|
||||
config: McpServerConfig,
|
||||
) => Promise<McpConnection>;
|
||||
|
||||
export interface McpToolBridge {
|
||||
/** Pi custom tools for every successfully-listed MCP tool. */
|
||||
tools: ToolDefinition[];
|
||||
/** Close every MCP connection. Idempotent and best-effort. */
|
||||
close(): Promise<void>;
|
||||
}
|
||||
|
||||
function buildTransport(config: McpServerConfig): Transport {
|
||||
switch (config.type) {
|
||||
case "http":
|
||||
return new StreamableHTTPClientTransport(
|
||||
new URL(config.url),
|
||||
config.headers ? { requestInit: { headers: config.headers } } : undefined,
|
||||
);
|
||||
case "sse":
|
||||
return new SSEClientTransport(
|
||||
new URL(config.url),
|
||||
config.headers ? { requestInit: { headers: config.headers } } : undefined,
|
||||
);
|
||||
case "stdio":
|
||||
return new StdioClientTransport({
|
||||
command: config.command,
|
||||
...(config.args ? { args: config.args } : {}),
|
||||
...(config.env ? { env: config.env } : {}),
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
/** Default connection factory: a real MCP SDK client over the configured transport. */
|
||||
async function connectWithSdk(
|
||||
_serverName: string,
|
||||
config: McpServerConfig,
|
||||
): Promise<McpConnection> {
|
||||
const client = new Client(
|
||||
{ name: MCP_CLIENT_NAME, version: MCP_CLIENT_VERSION },
|
||||
{ capabilities: {} },
|
||||
);
|
||||
await client.connect(buildTransport(config));
|
||||
return {
|
||||
async listTools() {
|
||||
const result = await client.listTools();
|
||||
return result.tools.map((tool) => ({
|
||||
name: tool.name,
|
||||
description: tool.description,
|
||||
inputSchema: tool.inputSchema,
|
||||
}));
|
||||
},
|
||||
async callTool(toolName, args) {
|
||||
return (await client.callTool({ name: toolName, arguments: args })) as McpCallToolResult;
|
||||
},
|
||||
async close() {
|
||||
await client.close();
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
function mcpResultText(result: McpCallToolResult): string {
|
||||
const parts: string[] = [];
|
||||
for (const block of result.content ?? []) {
|
||||
if (block.type === "text" && typeof block.text === "string") {
|
||||
parts.push(block.text);
|
||||
} else if (block.type === "resource" && typeof block.resource?.text === "string") {
|
||||
parts.push(block.resource.text);
|
||||
}
|
||||
}
|
||||
return parts.join("\n");
|
||||
}
|
||||
|
||||
/**
|
||||
* Map an MCP `CallToolResult` content array into Pi tool-result content. Text and image
|
||||
* blocks map directly; other block kinds become a short text note so nothing is silently
|
||||
* dropped. Pure and exported for testing.
|
||||
*/
|
||||
export function mapMcpToolContent(result: McpCallToolResult): (TextContent | ImageContent)[] {
|
||||
const content: (TextContent | ImageContent)[] = [];
|
||||
for (const block of result.content ?? []) {
|
||||
switch (block.type) {
|
||||
case "text":
|
||||
content.push({ type: "text", text: block.text ?? "" });
|
||||
break;
|
||||
case "image":
|
||||
if (block.data && block.mimeType) {
|
||||
content.push({ type: "image", data: block.data, mimeType: block.mimeType });
|
||||
}
|
||||
break;
|
||||
case "audio":
|
||||
content.push({
|
||||
type: "text",
|
||||
text: `[audio${block.mimeType ? ` ${block.mimeType}` : ""}]`,
|
||||
});
|
||||
break;
|
||||
case "resource":
|
||||
if (typeof block.resource?.text === "string") {
|
||||
content.push({ type: "text", text: block.resource.text });
|
||||
} else if (block.resource?.uri) {
|
||||
content.push({ type: "text", text: `[resource ${block.resource.uri}]` });
|
||||
}
|
||||
break;
|
||||
case "resource_link":
|
||||
if (block.uri) {
|
||||
content.push({ type: "text", text: `[resource ${block.uri}]` });
|
||||
}
|
||||
break;
|
||||
default:
|
||||
content.push({ type: "text", text: JSON.stringify(block) });
|
||||
}
|
||||
}
|
||||
return content;
|
||||
}
|
||||
|
||||
function buildToolDefinition(
|
||||
serverName: string,
|
||||
info: McpToolInfo,
|
||||
connection: McpConnection,
|
||||
): ToolDefinition {
|
||||
const toolName = `${serverName}__${info.name}`;
|
||||
const description = info.description ?? info.name;
|
||||
const definition: ToolDefinition = {
|
||||
name: toolName,
|
||||
label: info.name,
|
||||
description,
|
||||
promptSnippet: description,
|
||||
parameters: mcpInputSchemaToTypeBox(info.inputSchema),
|
||||
async execute(_toolCallId, params) {
|
||||
const args = (params ?? {}) as Record<string, unknown>;
|
||||
const result = await connection.callTool(info.name, args);
|
||||
if (result.isError) {
|
||||
// Throwing makes Pi mark the tool call failed and surface the message.
|
||||
throw new Error(mcpResultText(result) || `MCP tool "${info.name}" reported an error`);
|
||||
}
|
||||
const toolResult: AgentToolResultLike = {
|
||||
content: mapMcpToolContent(result),
|
||||
details: result.structuredContent ?? null,
|
||||
};
|
||||
return toolResult;
|
||||
},
|
||||
};
|
||||
return definition;
|
||||
}
|
||||
|
||||
/**
|
||||
* Connect to every configured MCP server, list its tools, and produce Pi custom tools.
|
||||
* Servers that fail to connect or list are logged and skipped rather than failing the
|
||||
* whole session. Call `close()` on session teardown.
|
||||
*/
|
||||
export async function createMcpToolBridge(options: {
|
||||
mcpServers?: Record<string, McpServerConfig>;
|
||||
logger: Logger;
|
||||
connect?: McpConnectionFactory;
|
||||
}): Promise<McpToolBridge> {
|
||||
const connect = options.connect ?? connectWithSdk;
|
||||
const connections: McpConnection[] = [];
|
||||
const tools: ToolDefinition[] = [];
|
||||
|
||||
for (const [serverName, serverConfig] of Object.entries(options.mcpServers ?? {})) {
|
||||
let connection: McpConnection;
|
||||
try {
|
||||
connection = await connect(serverName, serverConfig);
|
||||
} catch (error) {
|
||||
options.logger.warn({ err: error, mcpServer: serverName }, "Paseo Agent: MCP connect failed");
|
||||
continue;
|
||||
}
|
||||
connections.push(connection);
|
||||
|
||||
try {
|
||||
const toolInfos = await connection.listTools();
|
||||
for (const info of toolInfos) {
|
||||
tools.push(buildToolDefinition(serverName, info, connection));
|
||||
}
|
||||
} catch (error) {
|
||||
options.logger.warn(
|
||||
{ err: error, mcpServer: serverName },
|
||||
"Paseo Agent: MCP listTools failed",
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
tools,
|
||||
async close() {
|
||||
await Promise.all(
|
||||
connections.map((connection) =>
|
||||
connection
|
||||
.close()
|
||||
.catch((error) =>
|
||||
options.logger.warn({ err: error }, "Paseo Agent: MCP connection close failed"),
|
||||
),
|
||||
),
|
||||
);
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -1,77 +0,0 @@
|
||||
import { Value } from "typebox/value";
|
||||
import { describe, expect, it } from "vitest";
|
||||
|
||||
import { mcpInputSchemaToTypeBox } from "./mcp-schema.js";
|
||||
|
||||
describe("mcpInputSchemaToTypeBox", () => {
|
||||
it("converts an object schema with required and optional properties", () => {
|
||||
const schema = mcpInputSchemaToTypeBox({
|
||||
type: "object",
|
||||
properties: { a: { type: "string" }, b: { type: "number" } },
|
||||
required: ["a"],
|
||||
});
|
||||
expect(Value.Check(schema, { a: "x" })).toBe(true);
|
||||
expect(Value.Check(schema, { a: "x", b: 1 })).toBe(true);
|
||||
expect(Value.Check(schema, { b: 1 })).toBe(false); // missing required "a"
|
||||
expect(Value.Check(schema, { a: 1 })).toBe(false); // wrong type for "a"
|
||||
});
|
||||
|
||||
it("converts enums to a closed set", () => {
|
||||
const schema = mcpInputSchemaToTypeBox({
|
||||
type: "object",
|
||||
properties: { mode: { type: "string", enum: ["read", "write"] } },
|
||||
required: ["mode"],
|
||||
});
|
||||
expect(Value.Check(schema, { mode: "read" })).toBe(true);
|
||||
expect(Value.Check(schema, { mode: "delete" })).toBe(false);
|
||||
});
|
||||
|
||||
it("converts arrays with typed items", () => {
|
||||
const schema = mcpInputSchemaToTypeBox({
|
||||
type: "object",
|
||||
properties: { tags: { type: "array", items: { type: "number" } } },
|
||||
required: ["tags"],
|
||||
});
|
||||
expect(Value.Check(schema, { tags: [1, 2] })).toBe(true);
|
||||
expect(Value.Check(schema, { tags: ["x"] })).toBe(false);
|
||||
});
|
||||
|
||||
it("converts nested objects", () => {
|
||||
const schema = mcpInputSchemaToTypeBox({
|
||||
type: "object",
|
||||
properties: {
|
||||
filter: {
|
||||
type: "object",
|
||||
properties: { name: { type: "string" } },
|
||||
required: ["name"],
|
||||
},
|
||||
},
|
||||
required: ["filter"],
|
||||
});
|
||||
expect(Value.Check(schema, { filter: { name: "a" } })).toBe(true);
|
||||
expect(Value.Check(schema, { filter: {} })).toBe(false);
|
||||
});
|
||||
|
||||
it("accepts any object when the schema is missing or empty", () => {
|
||||
expect(Value.Check(mcpInputSchemaToTypeBox(undefined), { anything: 1 })).toBe(true);
|
||||
expect(Value.Check(mcpInputSchemaToTypeBox({}), { anything: 1 })).toBe(true);
|
||||
});
|
||||
|
||||
it("treats a property-only schema (no declared type) as an object", () => {
|
||||
const schema = mcpInputSchemaToTypeBox({
|
||||
properties: { q: { type: "string" } },
|
||||
required: ["q"],
|
||||
});
|
||||
expect(Value.Check(schema, { q: "hi" })).toBe(true);
|
||||
expect(Value.Check(schema, {})).toBe(false);
|
||||
});
|
||||
|
||||
it("preserves descriptions", () => {
|
||||
const schema = mcpInputSchemaToTypeBox({
|
||||
type: "object",
|
||||
description: "the tool input",
|
||||
properties: {},
|
||||
}) as Record<string, unknown>;
|
||||
expect(schema.description).toBe("the tool input");
|
||||
});
|
||||
});
|
||||
@@ -1,161 +0,0 @@
|
||||
import { Type, type TSchema } from "@earendil-works/pi-ai";
|
||||
|
||||
// JSON-Schema → TypeBox conversion for MCP tool input schemas. MCP advertises tool
|
||||
// parameters as JSON Schema; Pi tool definitions expect a TypeBox `TSchema`. This
|
||||
// covers the shapes common MCP servers emit (objects, primitives, arrays, enums,
|
||||
// unions, required/optional, additionalProperties) and falls back to a permissive
|
||||
// `Type.Unknown()` for anything it doesn't recognise, so unusual schemas degrade to
|
||||
// "accept anything" rather than throwing.
|
||||
|
||||
type JsonSchemaRecord = Record<string, unknown>;
|
||||
|
||||
// Index signature lets these annotations satisfy TypeBox's option types directly.
|
||||
interface SchemaAnnotations {
|
||||
[key: PropertyKey]: unknown;
|
||||
description?: string;
|
||||
default?: unknown;
|
||||
title?: string;
|
||||
}
|
||||
|
||||
function isRecord(value: unknown): value is JsonSchemaRecord {
|
||||
return typeof value === "object" && value !== null && !Array.isArray(value);
|
||||
}
|
||||
|
||||
function annotationsOf(schema: JsonSchemaRecord): SchemaAnnotations {
|
||||
const annotations: SchemaAnnotations = {};
|
||||
if (typeof schema.description === "string") {
|
||||
annotations.description = schema.description;
|
||||
}
|
||||
if (typeof schema.title === "string") {
|
||||
annotations.title = schema.title;
|
||||
}
|
||||
if ("default" in schema) {
|
||||
annotations.default = schema.default;
|
||||
}
|
||||
return annotations;
|
||||
}
|
||||
|
||||
function literalOf(value: unknown): TSchema {
|
||||
if (value === null) {
|
||||
return Type.Null();
|
||||
}
|
||||
if (typeof value === "string" || typeof value === "number" || typeof value === "boolean") {
|
||||
return Type.Literal(value);
|
||||
}
|
||||
// Non-literal enum members (objects/arrays) can't be TypeBox literals.
|
||||
return Type.Unknown();
|
||||
}
|
||||
|
||||
function unionOf(members: TSchema[], annotations: SchemaAnnotations): TSchema {
|
||||
if (members.length === 0) {
|
||||
return Type.Unknown(annotations);
|
||||
}
|
||||
if (members.length === 1) {
|
||||
return members[0];
|
||||
}
|
||||
return Type.Union(members, annotations);
|
||||
}
|
||||
|
||||
function convertByType(
|
||||
type: string,
|
||||
schema: JsonSchemaRecord,
|
||||
annotations: SchemaAnnotations,
|
||||
): TSchema {
|
||||
switch (type) {
|
||||
case "string":
|
||||
return Type.String(annotations);
|
||||
case "number":
|
||||
return Type.Number(annotations);
|
||||
case "integer":
|
||||
return Type.Integer(annotations);
|
||||
case "boolean":
|
||||
return Type.Boolean(annotations);
|
||||
case "null":
|
||||
return Type.Null(annotations);
|
||||
case "array": {
|
||||
const items = isRecord(schema.items) ? convertSchema(schema.items) : Type.Unknown();
|
||||
return Type.Array(items, annotations);
|
||||
}
|
||||
case "object":
|
||||
return convertObject(schema, annotations);
|
||||
default:
|
||||
return Type.Unknown(annotations);
|
||||
}
|
||||
}
|
||||
|
||||
function convertObject(schema: JsonSchemaRecord, annotations: SchemaAnnotations): TSchema {
|
||||
const properties = isRecord(schema.properties) ? schema.properties : {};
|
||||
const required = new Set(
|
||||
Array.isArray(schema.required)
|
||||
? schema.required.filter((name): name is string => typeof name === "string")
|
||||
: [],
|
||||
);
|
||||
|
||||
const fields: Record<string, TSchema> = {};
|
||||
for (const [key, propSchema] of Object.entries(properties)) {
|
||||
const converted = isRecord(propSchema) ? convertSchema(propSchema) : Type.Unknown();
|
||||
fields[key] = required.has(key) ? converted : Type.Optional(converted);
|
||||
}
|
||||
|
||||
const objectOptions: Record<string, unknown> = { ...annotations };
|
||||
const additional = schema.additionalProperties;
|
||||
if (additional === false || additional === true) {
|
||||
objectOptions.additionalProperties = additional;
|
||||
} else if (isRecord(additional)) {
|
||||
objectOptions.additionalProperties = convertSchema(additional);
|
||||
}
|
||||
|
||||
return Type.Object(fields, objectOptions);
|
||||
}
|
||||
|
||||
function convertSchema(schema: JsonSchemaRecord): TSchema {
|
||||
const annotations = annotationsOf(schema);
|
||||
|
||||
if (Array.isArray(schema.enum)) {
|
||||
return unionOf(schema.enum.map(literalOf), annotations);
|
||||
}
|
||||
|
||||
const composite = schema.anyOf ?? schema.oneOf;
|
||||
if (Array.isArray(composite)) {
|
||||
const members = composite.filter(isRecord).map(convertSchema);
|
||||
return unionOf(members, annotations);
|
||||
}
|
||||
|
||||
if (Array.isArray(schema.type)) {
|
||||
const members = schema.type
|
||||
.filter((t): t is string => typeof t === "string")
|
||||
.map((t) => convertByType(t, schema, annotations));
|
||||
return unionOf(members, annotations);
|
||||
}
|
||||
|
||||
if (typeof schema.type === "string") {
|
||||
return convertByType(schema.type, schema, annotations);
|
||||
}
|
||||
|
||||
// No declared type: treat as an object when properties are present, else accept anything.
|
||||
if (isRecord(schema.properties)) {
|
||||
return convertObject(schema, annotations);
|
||||
}
|
||||
|
||||
return Type.Unknown(annotations);
|
||||
}
|
||||
|
||||
/**
|
||||
* Convert an MCP tool `inputSchema` (JSON Schema) into a TypeBox schema for a Pi tool
|
||||
* definition. Always returns an object schema at the top level so tool parameters are
|
||||
* a well-formed object, even when the server omits or malforms the schema.
|
||||
*/
|
||||
export function mcpInputSchemaToTypeBox(inputSchema: unknown): TSchema {
|
||||
if (!isRecord(inputSchema)) {
|
||||
return Type.Object({}, { additionalProperties: true });
|
||||
}
|
||||
const { type } = inputSchema;
|
||||
// MCP tool parameters are objects. Convert object schemas faithfully; for a bare
|
||||
// schema with neither a declared object type nor properties, accept any object.
|
||||
if (type === "object" || isRecord(inputSchema.properties)) {
|
||||
return convertObject(inputSchema, annotationsOf(inputSchema));
|
||||
}
|
||||
// No declared type, or a non-object top-level type (unusual for tool params):
|
||||
// accept any object so the tool stays callable.
|
||||
return Type.Object({}, { additionalProperties: true });
|
||||
}
|
||||
@@ -1,39 +0,0 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
|
||||
import {
|
||||
isRefreshTokenExpressionConfigured,
|
||||
resolveRefreshTokenExpression,
|
||||
} from "./oauth-credentials.js";
|
||||
|
||||
describe("resolveRefreshTokenExpression", () => {
|
||||
it("returns a literal value", async () => {
|
||||
await expect(resolveRefreshTokenExpression("rt-literal", {})).resolves.toBe("rt-literal");
|
||||
});
|
||||
|
||||
it("resolves an env reference and returns undefined when unset", async () => {
|
||||
await expect(resolveRefreshTokenExpression("$CODEX_RT", { CODEX_RT: "rt-env" })).resolves.toBe(
|
||||
"rt-env",
|
||||
);
|
||||
await expect(
|
||||
resolveRefreshTokenExpression("${CODEX_RT}", { CODEX_RT: "rt-env" }),
|
||||
).resolves.toBe("rt-env");
|
||||
await expect(resolveRefreshTokenExpression("$CODEX_RT", {})).resolves.toBeUndefined();
|
||||
});
|
||||
|
||||
it("runs a !command asynchronously and returns its trimmed output", async () => {
|
||||
await expect(resolveRefreshTokenExpression("!printf rt-cmd", {})).resolves.toBe("rt-cmd");
|
||||
await expect(resolveRefreshTokenExpression("!exit 1", {})).resolves.toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
describe("isRefreshTokenExpressionConfigured", () => {
|
||||
it("is true for a literal and a set env var, false for an unset env var", () => {
|
||||
expect(isRefreshTokenExpressionConfigured("rt", {})).toBe(true);
|
||||
expect(isRefreshTokenExpressionConfigured("$RT", { RT: "x" })).toBe(true);
|
||||
expect(isRefreshTokenExpressionConfigured("$RT", {})).toBe(false);
|
||||
});
|
||||
|
||||
it("assumes a !command is runnable without executing it", () => {
|
||||
expect(isRefreshTokenExpressionConfigured("!exit 1", {})).toBe(true);
|
||||
});
|
||||
});
|
||||
@@ -1,75 +0,0 @@
|
||||
import { exec } from "node:child_process";
|
||||
import { promisify } from "node:util";
|
||||
import { hasEnvReference, substituteEnvReferences } from "./env-references.js";
|
||||
|
||||
const execAsync = promisify(exec);
|
||||
const CREDENTIAL_COMMAND_TIMEOUT_MS = 30_000;
|
||||
|
||||
// Resolution of a *self-supplied* OAuth refresh token expression — a literal, an env
|
||||
// reference (`$VAR` / `${VAR}`), or a `!command` that prints the token. This is an
|
||||
// advanced/manual escape hatch for users who already hold their own refresh token;
|
||||
// the normal product path writes a Paseo-owned credential store (see oauth-store.ts).
|
||||
//
|
||||
// This module deliberately does NOT read any other tool's auth files and imports no
|
||||
// Pi runtime code. Token values are never logged.
|
||||
|
||||
/**
|
||||
* Resolve a refresh-token expression to its literal value (may run a `!command`).
|
||||
* Returns undefined when it can't be resolved.
|
||||
*/
|
||||
export function resolveRefreshTokenExpression(
|
||||
value: string,
|
||||
env: NodeJS.ProcessEnv = process.env,
|
||||
): Promise<string | undefined> {
|
||||
if (value.startsWith("!")) {
|
||||
const command = value.slice(1).trim();
|
||||
if (!command) {
|
||||
return Promise.resolve(undefined);
|
||||
}
|
||||
|
||||
return execAsync(command, {
|
||||
encoding: "utf8",
|
||||
env,
|
||||
timeout: CREDENTIAL_COMMAND_TIMEOUT_MS,
|
||||
})
|
||||
.then(({ stdout }) => {
|
||||
const output = stdout.trim();
|
||||
return output.length > 0 ? output : undefined;
|
||||
})
|
||||
.catch(() => undefined);
|
||||
}
|
||||
|
||||
return Promise.resolve(resolveStaticRefreshTokenExpression(value, env));
|
||||
}
|
||||
|
||||
function resolveStaticRefreshTokenExpression(
|
||||
value: string,
|
||||
env: NodeJS.ProcessEnv,
|
||||
): string | undefined {
|
||||
if (hasEnvReference(value)) {
|
||||
const output = substituteEnvReferences(value, env);
|
||||
if (output) {
|
||||
return output.length > 0 ? output : undefined;
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
|
||||
return value.length > 0 ? value : undefined;
|
||||
}
|
||||
|
||||
/**
|
||||
* Cheap check: could this refresh-token expression yield a value without running a
|
||||
* command? `!command` is assumed runnable; env refs require their vars to be set.
|
||||
*/
|
||||
export function isRefreshTokenExpressionConfigured(
|
||||
value: string,
|
||||
env: NodeJS.ProcessEnv = process.env,
|
||||
): boolean {
|
||||
if (value.startsWith("!")) {
|
||||
return true;
|
||||
}
|
||||
if (hasEnvReference(value)) {
|
||||
return substituteEnvReferences(value, env) !== undefined;
|
||||
}
|
||||
return value.length > 0;
|
||||
}
|
||||
@@ -1,48 +0,0 @@
|
||||
// POSIX-only: file mode bits are not represented the same way on Windows.
|
||||
import { mkdtempSync, rmSync, statSync } from "node:fs";
|
||||
import { tmpdir } from "node:os";
|
||||
import { join } from "node:path";
|
||||
import { afterEach, beforeEach, describe, expect, it } from "vitest";
|
||||
|
||||
import { isPlatform } from "../../../../test-utils/platform.js";
|
||||
import { loginAndStoreOAuth, storeOAuthCredential } from "./oauth-store.js";
|
||||
|
||||
describe.skipIf(isPlatform("win32"))("oauth-store POSIX-only", () => {
|
||||
let home: string;
|
||||
let env: NodeJS.ProcessEnv;
|
||||
|
||||
beforeEach(() => {
|
||||
home = mkdtempSync(join(tmpdir(), "paseo-oauth-store-"));
|
||||
env = { PASEO_HOME: home };
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
rmSync(home, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
it("stores login credentials in a private file", async () => {
|
||||
const login = async () => ({ refresh: "rt-from-login", access: "ac", expires: 123 });
|
||||
|
||||
const { path } = await loginAndStoreOAuth({
|
||||
flow: "paseo-test-oauth",
|
||||
baseUrl: "https://api.example.test/oauth",
|
||||
providerInstance: "chatgpt",
|
||||
env,
|
||||
onDeviceCode: () => {},
|
||||
login,
|
||||
});
|
||||
|
||||
expect(statSync(path).mode & 0o777).toBe(0o600);
|
||||
});
|
||||
|
||||
it("stores explicit credentials in a private file", () => {
|
||||
const { path } = storeOAuthCredential({
|
||||
providerInstance: "chatgpt",
|
||||
env,
|
||||
binding: { flow: "paseo-test-oauth", baseUrl: "https://api.example.test/oauth" },
|
||||
credential: { type: "oauth", access: "ac", refresh: "rt", expires: 123 },
|
||||
});
|
||||
|
||||
expect(statSync(path).mode & 0o777).toBe(0o600);
|
||||
});
|
||||
});
|
||||
@@ -1,205 +0,0 @@
|
||||
import { mkdtempSync, readFileSync, rmSync } from "node:fs";
|
||||
import { tmpdir } from "node:os";
|
||||
import { join } from "node:path";
|
||||
import type { OAuthCredentials, OAuthProviderInterface } from "@earendil-works/pi-ai";
|
||||
import { registerOAuthProvider, resetOAuthProviders } from "@earendil-works/pi-ai/oauth";
|
||||
import { afterEach, beforeEach, describe, expect, it } from "vitest";
|
||||
|
||||
import {
|
||||
createBoundPaseoAgentAuthStorage,
|
||||
getStoredOAuthCredentialState,
|
||||
hasStoredOAuthCredential,
|
||||
loginAndStoreOAuth,
|
||||
loginOAuthBrowser,
|
||||
paseoAgentAuthStoragePath,
|
||||
storeOAuthCredential,
|
||||
} from "./oauth-store.js";
|
||||
|
||||
const TEST_FLOW = "paseo-test-oauth-store";
|
||||
const TEST_BASE_URL = "https://api.example.test/oauth";
|
||||
|
||||
function registerTestOAuthProvider(): void {
|
||||
const provider: OAuthProviderInterface = {
|
||||
id: TEST_FLOW,
|
||||
name: "Paseo Test OAuth",
|
||||
async login(callbacks): Promise<OAuthCredentials> {
|
||||
callbacks.onDeviceCode({
|
||||
userCode: "ABCD-EFGH",
|
||||
verificationUri: "https://auth.example.test/device",
|
||||
intervalSeconds: 5,
|
||||
expiresInSeconds: 900,
|
||||
});
|
||||
return { refresh: "rt-from-registry", access: "ac", expires: 123, accountId: "acct" };
|
||||
},
|
||||
async refreshToken(credentials): Promise<OAuthCredentials> {
|
||||
return credentials;
|
||||
},
|
||||
getApiKey(credentials): string {
|
||||
return credentials.access;
|
||||
},
|
||||
};
|
||||
registerOAuthProvider(provider);
|
||||
}
|
||||
|
||||
describe("oauth-store", () => {
|
||||
let home: string;
|
||||
let env: NodeJS.ProcessEnv;
|
||||
|
||||
beforeEach(() => {
|
||||
home = mkdtempSync(join(tmpdir(), "paseo-oauth-store-"));
|
||||
env = { PASEO_HOME: home };
|
||||
});
|
||||
afterEach(() => {
|
||||
resetOAuthProviders();
|
||||
rmSync(home, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
it("derives the store path from PASEO_HOME", () => {
|
||||
expect(paseoAgentAuthStoragePath(env)).toBe(join(home, "paseo-agent", "auth.json"));
|
||||
});
|
||||
|
||||
it("reports no stored credential before login", () => {
|
||||
expect(hasStoredOAuthCredential("chatgpt", env)).toBe(false);
|
||||
});
|
||||
|
||||
it("stores a protocol credential with future fields intact", () => {
|
||||
const { path } = storeOAuthCredential({
|
||||
providerInstance: "chatgpt",
|
||||
env,
|
||||
binding: { flow: TEST_FLOW, baseUrl: TEST_BASE_URL },
|
||||
credential: {
|
||||
type: "oauth",
|
||||
access: "access-token",
|
||||
refresh: "refresh-token",
|
||||
expires: 123,
|
||||
futureField: { keep: true },
|
||||
},
|
||||
});
|
||||
|
||||
expect(hasStoredOAuthCredential("chatgpt", env)).toBe(true);
|
||||
expect(
|
||||
hasStoredOAuthCredential("chatgpt", env, { flow: TEST_FLOW, baseUrl: TEST_BASE_URL }),
|
||||
).toBe(true);
|
||||
expect(
|
||||
getStoredOAuthCredentialState("chatgpt", env, {
|
||||
flow: TEST_FLOW,
|
||||
baseUrl: "https://api.example.test/changed",
|
||||
}),
|
||||
).toEqual({ present: true, bindingMatches: false });
|
||||
const stored = JSON.parse(readFileSync(path, "utf8"));
|
||||
expect(stored.chatgpt).toMatchObject({
|
||||
type: "oauth",
|
||||
refresh: "refresh-token",
|
||||
binding: { flow: TEST_FLOW, baseUrl: TEST_BASE_URL },
|
||||
futureField: { keep: true },
|
||||
});
|
||||
});
|
||||
|
||||
it("hides mismatched credentials from runtime auth storage without deleting them", async () => {
|
||||
const { path } = storeOAuthCredential({
|
||||
providerInstance: "chatgpt",
|
||||
env,
|
||||
binding: { flow: TEST_FLOW, baseUrl: TEST_BASE_URL },
|
||||
credential: {
|
||||
type: "oauth",
|
||||
access: "access-token",
|
||||
refresh: "refresh-token",
|
||||
expires: 123,
|
||||
},
|
||||
});
|
||||
|
||||
const authStorage = createBoundPaseoAgentAuthStorage(
|
||||
{
|
||||
chatgpt: { flow: TEST_FLOW, baseUrl: "https://api.example.test/changed" },
|
||||
},
|
||||
env,
|
||||
);
|
||||
|
||||
expect(authStorage.has("chatgpt")).toBe(false);
|
||||
await expect(authStorage.getApiKey("chatgpt", { includeFallback: false })).resolves.toBe(
|
||||
undefined,
|
||||
);
|
||||
expect(JSON.parse(readFileSync(path, "utf8")).chatgpt).toMatchObject({
|
||||
type: "oauth",
|
||||
refresh: "refresh-token",
|
||||
binding: { flow: TEST_FLOW, baseUrl: TEST_BASE_URL },
|
||||
});
|
||||
});
|
||||
|
||||
it("runs the Pi registry login flow and persists a Paseo-owned credential", async () => {
|
||||
registerTestOAuthProvider();
|
||||
const deviceCodes: unknown[] = [];
|
||||
|
||||
const { path } = await loginAndStoreOAuth({
|
||||
flow: TEST_FLOW,
|
||||
baseUrl: TEST_BASE_URL,
|
||||
providerInstance: "chatgpt",
|
||||
env,
|
||||
onDeviceCode: (info) => deviceCodes.push(info),
|
||||
});
|
||||
|
||||
expect(deviceCodes).toEqual([expect.objectContaining({ userCode: "ABCD-EFGH" })]);
|
||||
expect(path).toBe(join(home, "paseo-agent", "auth.json"));
|
||||
expect(hasStoredOAuthCredential("chatgpt", env)).toBe(true);
|
||||
const stored = JSON.parse(readFileSync(path, "utf8"));
|
||||
expect(stored.chatgpt).toMatchObject({ type: "oauth", refresh: "rt-from-registry" });
|
||||
});
|
||||
|
||||
it("keys the credential by provider instance name", async () => {
|
||||
const login = async () => ({ refresh: "rt", access: "", expires: 0 });
|
||||
await loginAndStoreOAuth({
|
||||
flow: TEST_FLOW,
|
||||
baseUrl: TEST_BASE_URL,
|
||||
providerInstance: "work-chatgpt",
|
||||
env,
|
||||
onDeviceCode: () => {},
|
||||
login,
|
||||
});
|
||||
expect(hasStoredOAuthCredential("work-chatgpt", env)).toBe(true);
|
||||
expect(hasStoredOAuthCredential("chatgpt", env)).toBe(false);
|
||||
});
|
||||
|
||||
it("browser login surfaces the auth URL and returns a credential without storing it", async () => {
|
||||
const authUrls: Array<[string, string | undefined]> = [];
|
||||
const loginCalls: string[] = [];
|
||||
const login = async (opts: { onAuth: (info: { url: string }) => void }) => {
|
||||
loginCalls.push("called");
|
||||
opts.onAuth({ url: "https://auth.example.test/oauth/authorize?x=1" });
|
||||
return { refresh: "rt-browser", access: "ac", expires: 456, accountId: "acct" };
|
||||
};
|
||||
|
||||
const credential = await loginOAuthBrowser({
|
||||
flow: TEST_FLOW,
|
||||
onAuthUrl: (url, instructions) => authUrls.push([url, instructions]),
|
||||
login,
|
||||
});
|
||||
|
||||
expect(loginCalls).toEqual(["called"]);
|
||||
expect(authUrls).toEqual([["https://auth.example.test/oauth/authorize?x=1", undefined]]);
|
||||
expect(credential).toMatchObject({ type: "oauth", refresh: "rt-browser" });
|
||||
expect(hasStoredOAuthCredential("chatgpt", env)).toBe(false);
|
||||
});
|
||||
|
||||
it("browser login falls back to manual code entry only when the callback can't complete", async () => {
|
||||
const prompts: string[] = [];
|
||||
const promptForCode = async (message: string) => {
|
||||
prompts.push(message);
|
||||
return "pasted-code";
|
||||
};
|
||||
const login = async (opts: { onPrompt: (p: { message: string }) => Promise<string> }) => {
|
||||
const code = await opts.onPrompt({ message: "Paste the code:" });
|
||||
expect(code).toBe("pasted-code");
|
||||
return { refresh: "rt-manual", access: "", expires: 0 };
|
||||
};
|
||||
|
||||
const credential = await loginOAuthBrowser({
|
||||
flow: TEST_FLOW,
|
||||
onAuthUrl: () => {},
|
||||
promptForCode,
|
||||
login,
|
||||
});
|
||||
|
||||
expect(prompts).toEqual(["Paste the code:"]);
|
||||
expect(credential).toMatchObject({ type: "oauth", refresh: "rt-manual" });
|
||||
});
|
||||
});
|
||||
@@ -1,334 +0,0 @@
|
||||
import { existsSync, readFileSync } from "node:fs";
|
||||
import { homedir } from "node:os";
|
||||
import { join } from "node:path";
|
||||
import {
|
||||
AuthStorage,
|
||||
FileAuthStorageBackend,
|
||||
type AuthCredential,
|
||||
type AuthStorageBackend,
|
||||
} from "@earendil-works/pi-coding-agent";
|
||||
import type {
|
||||
OAuthCredentials,
|
||||
OAuthDeviceCodeInfo as PiOAuthDeviceCodeInfo,
|
||||
OAuthLoginCallbacks,
|
||||
OAuthSelectPrompt,
|
||||
} from "@earendil-works/pi-ai";
|
||||
import { getOAuthProvider } from "@earendil-works/pi-ai/oauth";
|
||||
import type { PaseoAgentOAuthCredential } from "@getpaseo/protocol/messages";
|
||||
|
||||
// Paseo-owned OAuth credential store for the Paseo Agent provider. Credentials live
|
||||
// in a Paseo-controlled file and are managed through Pi's own AuthStorage, so Pi
|
||||
// refreshes tokens and persists rotation back into Paseo's file. Login flows reuse
|
||||
// Pi's OAuth registry; Paseo does not reimplement OAuth protocols.
|
||||
|
||||
export type OAuthDeviceCodeInfo = PiOAuthDeviceCodeInfo;
|
||||
export type OAuthLogin = (callbacks: OAuthLoginCallbacks) => Promise<OAuthCredentials>;
|
||||
type OAuthLoginPreference = "browser" | "device";
|
||||
|
||||
export interface OAuthCredentialBinding {
|
||||
flow: string;
|
||||
baseUrl: string;
|
||||
}
|
||||
|
||||
export interface StoredOAuthCredentialState {
|
||||
present: boolean;
|
||||
bindingMatches: boolean;
|
||||
}
|
||||
|
||||
interface BoundOAuthCredential extends PaseoAgentOAuthCredential {
|
||||
binding?: OAuthCredentialBinding;
|
||||
}
|
||||
|
||||
interface StorageLockResult<T> {
|
||||
result: T;
|
||||
next?: string;
|
||||
}
|
||||
|
||||
/** Path to the Paseo-owned auth store. Uses PASEO_HOME; falls back to ~/.paseo. */
|
||||
export function paseoAgentAuthStoragePath(env: NodeJS.ProcessEnv = process.env): string {
|
||||
const base = env.PASEO_HOME ?? join(homedir(), ".paseo");
|
||||
return join(base, "paseo-agent", "auth.json");
|
||||
}
|
||||
|
||||
/**
|
||||
* Pi AuthStorage backed by the Paseo-owned file. Pi creates the parent dir (0700) and
|
||||
* the file (0600) and re-chmods on every write, so refreshed tokens stay private.
|
||||
*/
|
||||
export function createPaseoAgentAuthStorage(env: NodeJS.ProcessEnv = process.env): AuthStorage {
|
||||
return AuthStorage.create(paseoAgentAuthStoragePath(env));
|
||||
}
|
||||
|
||||
export function createBoundPaseoAgentAuthStorage(
|
||||
bindings: Record<string, OAuthCredentialBinding>,
|
||||
env: NodeJS.ProcessEnv = process.env,
|
||||
): AuthStorage {
|
||||
return AuthStorage.fromStorage(
|
||||
new BindingAwareAuthStorageBackend(paseoAgentAuthStoragePath(env), bindings),
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Read-only check (no file creation) for whether a Paseo-owned OAuth credential exists
|
||||
* for a provider instance. Used for availability without constructing AuthStorage.
|
||||
*/
|
||||
export function hasStoredOAuthCredential(
|
||||
providerInstance: string,
|
||||
env: NodeJS.ProcessEnv = process.env,
|
||||
binding?: OAuthCredentialBinding,
|
||||
): boolean {
|
||||
const state = getStoredOAuthCredentialState(providerInstance, env, binding);
|
||||
return state.present && state.bindingMatches;
|
||||
}
|
||||
|
||||
export function getStoredOAuthCredentialState(
|
||||
providerInstance: string,
|
||||
env: NodeJS.ProcessEnv = process.env,
|
||||
binding?: OAuthCredentialBinding,
|
||||
): StoredOAuthCredentialState {
|
||||
const path = paseoAgentAuthStoragePath(env);
|
||||
if (!existsSync(path)) {
|
||||
return { present: false, bindingMatches: false };
|
||||
}
|
||||
try {
|
||||
const parsed: unknown = JSON.parse(readFileSync(path, "utf8"));
|
||||
if (typeof parsed !== "object" || parsed === null) {
|
||||
return { present: false, bindingMatches: false };
|
||||
}
|
||||
const entry = (parsed as Record<string, unknown>)[providerInstance];
|
||||
if (!isOAuthCredentialRecord(entry)) {
|
||||
return { present: false, bindingMatches: false };
|
||||
}
|
||||
return { present: true, bindingMatches: !binding || bindingsEqual(entry.binding, binding) };
|
||||
} catch {
|
||||
return { present: false, bindingMatches: false };
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Store a credential obtained by a remote-safe client-side OAuth flow into the
|
||||
* daemon's Paseo-owned AuthStorage. The caller supplies the protocol credential
|
||||
* shape, and this helper never reads or writes foreign auth files.
|
||||
*/
|
||||
export function storeOAuthCredential(options: {
|
||||
providerInstance: string;
|
||||
credential: PaseoAgentOAuthCredential;
|
||||
binding: OAuthCredentialBinding;
|
||||
env?: NodeJS.ProcessEnv;
|
||||
}): { path: string } {
|
||||
const path = paseoAgentAuthStoragePath(options.env);
|
||||
const authStorage = AuthStorage.create(path);
|
||||
authStorage.set(options.providerInstance, {
|
||||
...options.credential,
|
||||
binding: { ...options.binding },
|
||||
});
|
||||
return { path };
|
||||
}
|
||||
|
||||
export async function loginAndStoreOAuth(options: {
|
||||
flow: string;
|
||||
baseUrl: string;
|
||||
providerInstance: string;
|
||||
onDeviceCode: (info: OAuthDeviceCodeInfo) => void;
|
||||
env?: NodeJS.ProcessEnv;
|
||||
signal?: AbortSignal;
|
||||
login?: OAuthLogin;
|
||||
}): Promise<{ path: string }> {
|
||||
const credential = await loginOAuthDevice({
|
||||
flow: options.flow,
|
||||
onDeviceCode: options.onDeviceCode,
|
||||
signal: options.signal,
|
||||
login: options.login,
|
||||
});
|
||||
return storeOAuthCredential({
|
||||
providerInstance: options.providerInstance,
|
||||
credential,
|
||||
binding: { flow: options.flow, baseUrl: options.baseUrl },
|
||||
env: options.env,
|
||||
});
|
||||
}
|
||||
|
||||
export async function loginOAuthDevice(options: {
|
||||
flow: string;
|
||||
onDeviceCode: (info: OAuthDeviceCodeInfo) => void;
|
||||
signal?: AbortSignal;
|
||||
login?: OAuthLogin;
|
||||
}): Promise<PaseoAgentOAuthCredential> {
|
||||
const login = resolveOAuthLogin(options.flow, options.login);
|
||||
const credentials = await login({
|
||||
onAuth: () => {},
|
||||
onDeviceCode: options.onDeviceCode,
|
||||
onPrompt: async () => {
|
||||
throw new Error("OAuth login requested manual input, but no prompt handler is available.");
|
||||
},
|
||||
onSelect: (prompt) => selectOAuthOption(prompt, "device"),
|
||||
signal: options.signal,
|
||||
});
|
||||
return { type: "oauth", ...credentials };
|
||||
}
|
||||
|
||||
export async function loginOAuthBrowser(options: {
|
||||
flow: string;
|
||||
onAuthUrl: (url: string, instructions?: string) => void;
|
||||
promptForCode?: (message: string) => Promise<string>;
|
||||
onProgress?: (message: string) => void;
|
||||
signal?: AbortSignal;
|
||||
login?: OAuthLogin;
|
||||
}): Promise<PaseoAgentOAuthCredential> {
|
||||
const login = resolveOAuthLogin(options.flow, options.login);
|
||||
const credentials = await login({
|
||||
onAuth: (info) => options.onAuthUrl(info.url, info.instructions),
|
||||
onDeviceCode: () => {},
|
||||
onProgress: options.onProgress,
|
||||
onPrompt: async (prompt) => {
|
||||
if (!options.promptForCode) {
|
||||
throw new Error("Browser login did not complete and no manual code entry was available.");
|
||||
}
|
||||
return options.promptForCode(prompt.message);
|
||||
},
|
||||
onSelect: (prompt) => selectOAuthOption(prompt, "browser"),
|
||||
signal: options.signal,
|
||||
});
|
||||
return { type: "oauth", ...credentials };
|
||||
}
|
||||
|
||||
function isRecord(value: unknown): value is Record<string, unknown> {
|
||||
return typeof value === "object" && value !== null && !Array.isArray(value);
|
||||
}
|
||||
|
||||
function isOAuthCredentialRecord(value: unknown): value is BoundOAuthCredential {
|
||||
return isRecord(value) && value.type === "oauth";
|
||||
}
|
||||
|
||||
function bindingsEqual(
|
||||
actual: OAuthCredentialBinding | undefined,
|
||||
expected: OAuthCredentialBinding,
|
||||
): boolean {
|
||||
return actual?.flow === expected.flow && actual.baseUrl === expected.baseUrl;
|
||||
}
|
||||
|
||||
function credentialBinding(
|
||||
credential: AuthCredential | undefined,
|
||||
): OAuthCredentialBinding | undefined {
|
||||
if (!credential || credential.type !== "oauth") {
|
||||
return undefined;
|
||||
}
|
||||
const binding = credential.binding;
|
||||
if (!isRecord(binding)) {
|
||||
return undefined;
|
||||
}
|
||||
return typeof binding.flow === "string" && typeof binding.baseUrl === "string"
|
||||
? { flow: binding.flow, baseUrl: binding.baseUrl }
|
||||
: undefined;
|
||||
}
|
||||
|
||||
function parseAuthStorageData(current: string | undefined): Record<string, AuthCredential> {
|
||||
if (!current) {
|
||||
return {};
|
||||
}
|
||||
const parsed: unknown = JSON.parse(current);
|
||||
return isRecord(parsed) ? (parsed as Record<string, AuthCredential>) : {};
|
||||
}
|
||||
|
||||
function serializeAuthStorageData(data: Record<string, AuthCredential>): string {
|
||||
return JSON.stringify(data, null, 2);
|
||||
}
|
||||
|
||||
function filterBoundCredentials(
|
||||
data: Record<string, AuthCredential>,
|
||||
bindings: Record<string, OAuthCredentialBinding>,
|
||||
): Record<string, AuthCredential> {
|
||||
const filtered: Record<string, AuthCredential> = {};
|
||||
for (const [provider, credential] of Object.entries(data)) {
|
||||
const binding = bindings[provider];
|
||||
if (
|
||||
!binding ||
|
||||
credential.type !== "oauth" ||
|
||||
bindingsEqual(credentialBinding(credential), binding)
|
||||
) {
|
||||
filtered[provider] = credential;
|
||||
}
|
||||
}
|
||||
return filtered;
|
||||
}
|
||||
|
||||
function mergeBindingAwareWrite(
|
||||
original: Record<string, AuthCredential>,
|
||||
visible: Record<string, AuthCredential>,
|
||||
next: string,
|
||||
): string {
|
||||
const nextData = parseAuthStorageData(next);
|
||||
const merged: Record<string, AuthCredential> = { ...original };
|
||||
for (const provider of Object.keys(visible)) {
|
||||
if (!Object.prototype.hasOwnProperty.call(nextData, provider)) {
|
||||
delete merged[provider];
|
||||
}
|
||||
}
|
||||
for (const [provider, credential] of Object.entries(nextData)) {
|
||||
const originalBinding = credentialBinding(original[provider]);
|
||||
if (credential.type === "oauth" && !credential.binding && originalBinding) {
|
||||
merged[provider] = { ...credential, binding: originalBinding };
|
||||
} else {
|
||||
merged[provider] = credential;
|
||||
}
|
||||
}
|
||||
return serializeAuthStorageData(merged);
|
||||
}
|
||||
|
||||
class BindingAwareAuthStorageBackend implements AuthStorageBackend {
|
||||
private readonly delegate: FileAuthStorageBackend;
|
||||
private readonly bindings: Record<string, OAuthCredentialBinding>;
|
||||
|
||||
constructor(path: string, bindings: Record<string, OAuthCredentialBinding>) {
|
||||
this.delegate = new FileAuthStorageBackend(path);
|
||||
this.bindings = bindings;
|
||||
}
|
||||
|
||||
withLock<T>(fn: (current: string | undefined) => StorageLockResult<T>): T {
|
||||
return this.delegate.withLock((current) => {
|
||||
const original = parseAuthStorageData(current);
|
||||
const visible = filterBoundCredentials(original, this.bindings);
|
||||
const result = fn(serializeAuthStorageData(visible));
|
||||
return {
|
||||
result: result.result,
|
||||
...(result.next !== undefined
|
||||
? { next: mergeBindingAwareWrite(original, visible, result.next) }
|
||||
: {}),
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
withLockAsync<T>(fn: (current: string | undefined) => Promise<StorageLockResult<T>>): Promise<T> {
|
||||
return this.delegate.withLockAsync(async (current) => {
|
||||
const original = parseAuthStorageData(current);
|
||||
const visible = filterBoundCredentials(original, this.bindings);
|
||||
const result = await fn(serializeAuthStorageData(visible));
|
||||
return {
|
||||
result: result.result,
|
||||
...(result.next !== undefined
|
||||
? { next: mergeBindingAwareWrite(original, visible, result.next) }
|
||||
: {}),
|
||||
};
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
function resolveOAuthLogin(flow: string, login: OAuthLogin | undefined): OAuthLogin {
|
||||
if (login) {
|
||||
return login;
|
||||
}
|
||||
const provider = getOAuthProvider(flow);
|
||||
if (!provider) {
|
||||
throw new Error(`Paseo Agent: OAuth flow "${flow}" is not registered by Pi.`);
|
||||
}
|
||||
return (callbacks) => provider.login(callbacks);
|
||||
}
|
||||
|
||||
function selectOAuthOption(
|
||||
prompt: OAuthSelectPrompt,
|
||||
preference: OAuthLoginPreference,
|
||||
): Promise<string | undefined> {
|
||||
const preferred = prompt.options.find((option) =>
|
||||
option.label.toLowerCase().includes(preference),
|
||||
);
|
||||
return Promise.resolve((preferred ?? prompt.options[0])?.id);
|
||||
}
|
||||
@@ -1,345 +0,0 @@
|
||||
import { existsSync, mkdtempSync, readdirSync, rmSync } from "node:fs";
|
||||
import { tmpdir } from "node:os";
|
||||
import { join } from "node:path";
|
||||
import { AuthStorage } from "@earendil-works/pi-coding-agent";
|
||||
import type { BeforeToolCallContext } from "@earendil-works/pi-agent-core";
|
||||
import type { OAuthCredentials, OAuthProviderInterface } from "@earendil-works/pi-ai";
|
||||
import { registerOAuthProvider, resetOAuthProviders } from "@earendil-works/pi-ai/oauth";
|
||||
import { afterEach, beforeEach, describe, expect, it } from "vitest";
|
||||
import { createToolPermissionPolicy } from "./agent-permissions.js";
|
||||
|
||||
import {
|
||||
type CreatePaseoAgentSessionOptions,
|
||||
type PaseoAgentModelProvider,
|
||||
createPaseoAgentSession,
|
||||
} from "./pi-services.js";
|
||||
|
||||
const TEST_OAUTH_FLOW = "paseo-test-oauth";
|
||||
|
||||
function oauthModelProvider(): PaseoAgentModelProvider {
|
||||
return {
|
||||
name: "subscription",
|
||||
config: {
|
||||
baseUrl: "https://example.invalid/oauth",
|
||||
api: "openai-completions",
|
||||
models: [
|
||||
{
|
||||
id: "oauth-model",
|
||||
name: "OAuth Model",
|
||||
api: "openai-completions",
|
||||
reasoning: true,
|
||||
input: ["text"],
|
||||
cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 },
|
||||
contextWindow: 128000,
|
||||
maxTokens: 16384,
|
||||
},
|
||||
],
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
function registerTestOAuthProvider(): void {
|
||||
const provider: OAuthProviderInterface = {
|
||||
id: TEST_OAUTH_FLOW,
|
||||
name: "Paseo Test OAuth",
|
||||
async login(): Promise<OAuthCredentials> {
|
||||
return { access: "access-from-login", refresh: "refresh-from-login", expires: Date.now() };
|
||||
},
|
||||
async refreshToken(credentials): Promise<OAuthCredentials> {
|
||||
return {
|
||||
...credentials,
|
||||
access: "access-from-refresh",
|
||||
expires: Date.now() + 60_000,
|
||||
};
|
||||
},
|
||||
getApiKey(credentials): string {
|
||||
return credentials.access;
|
||||
},
|
||||
};
|
||||
registerOAuthProvider(provider);
|
||||
}
|
||||
|
||||
const FAKE_PROVIDER = "paseo-test-openrouter";
|
||||
const FAKE_MODEL_ID = "test-model";
|
||||
|
||||
function toolCallContext(toolName: string): BeforeToolCallContext {
|
||||
return {
|
||||
assistantMessage: { role: "assistant", content: [] },
|
||||
toolCall: { type: "toolCall", id: "call-1", name: toolName, arguments: {} },
|
||||
args: {},
|
||||
context: {},
|
||||
} as BeforeToolCallContext;
|
||||
}
|
||||
|
||||
function fakeModelProvider(): PaseoAgentModelProvider {
|
||||
return {
|
||||
name: FAKE_PROVIDER,
|
||||
config: {
|
||||
baseUrl: "https://example.invalid/v1",
|
||||
apiKey: "sk-in-memory-only",
|
||||
api: "openai-completions",
|
||||
models: [
|
||||
{
|
||||
id: FAKE_MODEL_ID,
|
||||
name: "Paseo Test Model",
|
||||
api: "openai-completions",
|
||||
reasoning: false,
|
||||
input: ["text"],
|
||||
cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 },
|
||||
contextWindow: 128000,
|
||||
maxTokens: 16384,
|
||||
},
|
||||
],
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
describe("createPaseoAgentSession (no-discovery spike)", () => {
|
||||
let cwd: string;
|
||||
let agentDir: string;
|
||||
let fakeHome: string;
|
||||
let originalHome: string | undefined;
|
||||
let originalUserProfile: string | undefined;
|
||||
|
||||
beforeEach(() => {
|
||||
cwd = mkdtempSync(join(tmpdir(), "paseo-agent-cwd-"));
|
||||
agentDir = join(mkdtempSync(join(tmpdir(), "paseo-agent-dir-")), "agent");
|
||||
fakeHome = mkdtempSync(join(tmpdir(), "paseo-agent-home-"));
|
||||
// Redirect HOME so any accidental ~/.pi discovery would land in fakeHome and be detectable.
|
||||
originalHome = process.env.HOME;
|
||||
originalUserProfile = process.env.USERPROFILE;
|
||||
process.env.HOME = fakeHome;
|
||||
process.env.USERPROFILE = fakeHome;
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
resetOAuthProviders();
|
||||
if (originalHome === undefined) delete process.env.HOME;
|
||||
else process.env.HOME = originalHome;
|
||||
if (originalUserProfile === undefined) delete process.env.USERPROFILE;
|
||||
else process.env.USERPROFILE = originalUserProfile;
|
||||
for (const dir of [cwd, fakeHome, agentDir]) {
|
||||
rmSync(dir, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
function baseOptions(): CreatePaseoAgentSessionOptions {
|
||||
return {
|
||||
cwd,
|
||||
agentDir,
|
||||
modelProviders: [fakeModelProvider()],
|
||||
model: { provider: FAKE_PROVIDER, id: FAKE_MODEL_ID },
|
||||
};
|
||||
}
|
||||
|
||||
it("creates a session from an in-memory model provider and selects its model", async () => {
|
||||
const { session, modelRegistry } = await createPaseoAgentSession(baseOptions());
|
||||
|
||||
expect(session).toBeDefined();
|
||||
expect(session.model?.provider).toBe(FAKE_PROVIDER);
|
||||
expect(session.model?.id).toBe(FAKE_MODEL_ID);
|
||||
// The in-memory model is the only one reachable with configured auth.
|
||||
const available = modelRegistry.getAvailable();
|
||||
expect(available.some((m) => m.provider === FAKE_PROVIDER && m.id === FAKE_MODEL_ID)).toBe(
|
||||
true,
|
||||
);
|
||||
});
|
||||
|
||||
it("performs no Pi resource discovery", async () => {
|
||||
const { resourceLoader } = await createPaseoAgentSession(baseOptions());
|
||||
|
||||
expect(resourceLoader.getSkills().skills).toHaveLength(0);
|
||||
expect(resourceLoader.getExtensions().extensions).toHaveLength(0);
|
||||
expect(resourceLoader.getPrompts().prompts).toHaveLength(0);
|
||||
});
|
||||
|
||||
it("exposes composed prompts through the resource loader without discovery", async () => {
|
||||
const { resourceLoader } = await createPaseoAgentSession({
|
||||
...baseOptions(),
|
||||
composedPrompt: {
|
||||
customPrompt: "Custom Paseo base prompt.",
|
||||
appendSystemPrompt: ["Profile append.", "Daemon append."],
|
||||
},
|
||||
});
|
||||
|
||||
expect(resourceLoader.getSystemPrompt()).toBe("Custom Paseo base prompt.");
|
||||
expect(resourceLoader.getAppendSystemPrompt()).toEqual(["Profile append.", "Daemon append."]);
|
||||
expect(resourceLoader.getAgentsFiles().agentsFiles).toHaveLength(0);
|
||||
});
|
||||
|
||||
it("uses an in-memory session manager with no on-disk session file", async () => {
|
||||
const { sessionManager } = await createPaseoAgentSession(baseOptions());
|
||||
|
||||
expect(sessionManager.getSessionFile()).toBeUndefined();
|
||||
});
|
||||
|
||||
it("touches no ~/.pi config and writes nothing to the isolated agentDir", async () => {
|
||||
await createPaseoAgentSession(baseOptions());
|
||||
|
||||
// No discovery against the redirected home directory: if Pi resolved its
|
||||
// default agentDir (~/.pi/agent) it would create or read it under fakeHome.
|
||||
expect(existsSync(join(fakeHome, ".pi"))).toBe(false);
|
||||
// Nothing persisted to the Paseo-owned isolated agentDir.
|
||||
const agentDirContents = existsSync(agentDir) ? readdirSync(agentDir) : [];
|
||||
expect(agentDirContents).toHaveLength(0);
|
||||
// No session/auth/model files leaked into the cwd either.
|
||||
expect(existsSync(join(cwd, ".pi"))).toBe(false);
|
||||
});
|
||||
|
||||
it("rejects a model that no model provider registered", async () => {
|
||||
await expect(
|
||||
createPaseoAgentSession({
|
||||
...baseOptions(),
|
||||
modelProviders: [],
|
||||
}),
|
||||
).rejects.toThrow(/not registered/);
|
||||
});
|
||||
|
||||
it("activates supplied custom tools alongside the built-in tools", async () => {
|
||||
const { session } = await createPaseoAgentSession({
|
||||
...baseOptions(),
|
||||
customTools: [
|
||||
{
|
||||
name: "paseo__demo",
|
||||
label: "demo",
|
||||
description: "demo tool",
|
||||
parameters: { type: "object" } as never,
|
||||
async execute() {
|
||||
return { content: [{ type: "text", text: "ok" }], details: null };
|
||||
},
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
const active = session.getActiveToolNames();
|
||||
expect(active).toContain("paseo__demo");
|
||||
// Built-in tools remain active too.
|
||||
expect(active).toContain("bash");
|
||||
});
|
||||
|
||||
it("honors an explicit agent tool allowlist", async () => {
|
||||
const { session } = await createPaseoAgentSession({
|
||||
...baseOptions(),
|
||||
tools: ["read", "paseo__demo"],
|
||||
customTools: [
|
||||
{
|
||||
name: "paseo__demo",
|
||||
label: "demo",
|
||||
description: "demo tool",
|
||||
parameters: { type: "object" } as never,
|
||||
async execute() {
|
||||
return { content: [{ type: "text", text: "ok" }], details: null };
|
||||
},
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
expect(session.getActiveToolNames().sort()).toEqual(["paseo__demo", "read"]);
|
||||
});
|
||||
|
||||
it("blocks a denied built-in tool through Pi's preflight hook", async () => {
|
||||
const { session } = await createPaseoAgentSession({
|
||||
...baseOptions(),
|
||||
tools: ["bash"],
|
||||
permissionPolicy: createToolPermissionPolicy([{ tool: "bash", action: "deny" }]),
|
||||
});
|
||||
|
||||
expect(session.getActiveToolNames()).toEqual(["bash"]);
|
||||
await expect(session.agent.beforeToolCall?.(toolCallContext("bash"))).resolves.toEqual({
|
||||
block: true,
|
||||
reason: 'Paseo Agent denied tool "bash" by agent permissions.',
|
||||
});
|
||||
});
|
||||
|
||||
it("allows unmatched built-in tools to fall through the existing Pi hook", async () => {
|
||||
const { session } = await createPaseoAgentSession({
|
||||
...baseOptions(),
|
||||
tools: ["bash"],
|
||||
permissionPolicy: createToolPermissionPolicy([{ tool: "read", action: "deny" }]),
|
||||
});
|
||||
|
||||
await expect(session.agent.beforeToolCall?.(toolCallContext("bash"))).resolves.toBeUndefined();
|
||||
});
|
||||
|
||||
it("blocks a denied custom tool through the same Pi preflight hook", async () => {
|
||||
const { session } = await createPaseoAgentSession({
|
||||
...baseOptions(),
|
||||
tools: ["paseo__demo"],
|
||||
customTools: [
|
||||
{
|
||||
name: "paseo__demo",
|
||||
label: "demo",
|
||||
description: "demo tool",
|
||||
parameters: { type: "object" } as never,
|
||||
async execute() {
|
||||
return { content: [{ type: "text", text: "ok" }], details: null };
|
||||
},
|
||||
},
|
||||
],
|
||||
permissionPolicy: createToolPermissionPolicy([{ tool: "paseo__*", action: "deny" }]),
|
||||
});
|
||||
|
||||
expect(session.getActiveToolNames()).toEqual(["paseo__demo"]);
|
||||
await expect(session.agent.beforeToolCall?.(toolCallContext("paseo__demo"))).resolves.toEqual({
|
||||
block: true,
|
||||
reason: 'Paseo Agent denied tool "paseo__demo" by agent permissions.',
|
||||
});
|
||||
});
|
||||
|
||||
it("registers an OAuth provider by flow and seeds the advanced refresh-token override", async () => {
|
||||
registerTestOAuthProvider();
|
||||
const oauthProvider = oauthModelProvider();
|
||||
const { session, modelRegistry } = await createPaseoAgentSession({
|
||||
cwd,
|
||||
agentDir,
|
||||
model: { provider: "subscription", id: "oauth-model" },
|
||||
modelProviders: [
|
||||
{ ...oauthProvider, oauth: { flow: TEST_OAUTH_FLOW, refreshToken: "rt-test-only" } },
|
||||
],
|
||||
});
|
||||
|
||||
expect(session.model?.provider).toBe("subscription");
|
||||
expect(modelRegistry.find("subscription", "oauth-model")?.api).toBe("openai-completions");
|
||||
const available = modelRegistry.getAvailable();
|
||||
expect(available.some((m) => m.provider === "subscription" && m.id === "oauth-model")).toBe(
|
||||
true,
|
||||
);
|
||||
});
|
||||
|
||||
it("rejects an OAuth flow that Pi has not registered", async () => {
|
||||
await expect(
|
||||
createPaseoAgentSession({
|
||||
cwd,
|
||||
agentDir,
|
||||
model: { provider: "subscription", id: "oauth-model" },
|
||||
modelProviders: [{ ...oauthModelProvider(), oauth: { flow: "missing-flow" } }],
|
||||
}),
|
||||
).rejects.toThrow(/OAuth flow "missing-flow" is not registered/);
|
||||
});
|
||||
|
||||
it("loads an OAuth credential from a Paseo-owned AuthStorage", async () => {
|
||||
registerTestOAuthProvider();
|
||||
const authPath = join(mkdtempSync(join(tmpdir(), "paseo-agent-auth-")), "auth.json");
|
||||
const authStorage = AuthStorage.create(authPath);
|
||||
authStorage.set("subscription", {
|
||||
type: "oauth",
|
||||
access: "access-stored",
|
||||
refresh: "rt-stored",
|
||||
expires: Date.now() + 60_000,
|
||||
});
|
||||
|
||||
const { modelRegistry } = await createPaseoAgentSession({
|
||||
cwd,
|
||||
agentDir,
|
||||
authStorage,
|
||||
model: { provider: "subscription", id: "oauth-model" },
|
||||
modelProviders: [{ ...oauthModelProvider(), oauth: { flow: TEST_OAUTH_FLOW } }],
|
||||
});
|
||||
|
||||
const available = modelRegistry.getAvailable();
|
||||
expect(available.some((m) => m.provider === "subscription" && m.id === "oauth-model")).toBe(
|
||||
true,
|
||||
);
|
||||
rmSync(authPath, { force: true });
|
||||
});
|
||||
});
|
||||
@@ -1,254 +0,0 @@
|
||||
import {
|
||||
AuthStorage,
|
||||
type AgentSession as PiAgentSession,
|
||||
DefaultResourceLoader,
|
||||
ModelRegistry,
|
||||
type ResourceLoader,
|
||||
SessionManager,
|
||||
SettingsManager,
|
||||
type ToolDefinition,
|
||||
createAgentSession,
|
||||
} from "@earendil-works/pi-coding-agent";
|
||||
import type { BeforeToolCallResult, ThinkingLevel } from "@earendil-works/pi-agent-core";
|
||||
import type { ImageContent, OAuthProviderInterface, TextContent } from "@earendil-works/pi-ai";
|
||||
import { getOAuthProvider } from "@earendil-works/pi-ai/oauth";
|
||||
import { evaluateToolPermission, type ToolPermissionPolicy } from "./agent-permissions.js";
|
||||
import type { PaseoComposedPrompt } from "./prompt-profiles.js";
|
||||
|
||||
// Re-export the Pi tool contract so the MCP bridge can build custom tools without
|
||||
// importing the Pi SDK type names itself.
|
||||
export type { ToolDefinition };
|
||||
|
||||
/** Shape a Pi custom tool's `execute` must return (subset of Pi's AgentToolResult). */
|
||||
export interface AgentToolResultLike {
|
||||
content: (TextContent | ImageContent)[];
|
||||
details: unknown;
|
||||
terminate?: boolean;
|
||||
}
|
||||
|
||||
// The single seam between Paseo and Pi's in-process harness. Every `@earendil-works/*`
|
||||
// import and all no-discovery service construction lives here so the rest of the
|
||||
// Paseo Agent provider never touches Pi's disk-backed config, auth, or sessions.
|
||||
|
||||
// ProviderConfigInput is not re-exported from the package index, so derive it from
|
||||
// the public `registerProvider` signature.
|
||||
export type PiProviderConfig = Parameters<ModelRegistry["registerProvider"]>[1];
|
||||
type PiAuthData = Parameters<typeof AuthStorage.inMemory>[0];
|
||||
type PiSettings = Parameters<typeof SettingsManager.inMemory>[0];
|
||||
|
||||
export interface PaseoAgentOAuth {
|
||||
flow: string;
|
||||
refreshToken?: string;
|
||||
}
|
||||
|
||||
export interface PaseoAgentModelProvider {
|
||||
/** Instance name, e.g. "openrouter-main". Used as the Pi provider key. */
|
||||
name: string;
|
||||
/** Typed Pi provider config: baseUrl, apiKey, models, api, etc. */
|
||||
config: PiProviderConfig;
|
||||
/** When present, register an OAuth provider and seed an in-memory credential. */
|
||||
oauth?: PaseoAgentOAuth;
|
||||
}
|
||||
|
||||
export interface PaseoAgentModelReference {
|
||||
provider: string;
|
||||
id: string;
|
||||
}
|
||||
|
||||
export interface CreatePaseoAgentSessionOptions {
|
||||
/** Working directory for the agent. */
|
||||
cwd: string;
|
||||
/**
|
||||
* Isolated, Paseo-owned global config directory. Never `~/.pi`. Used only to
|
||||
* satisfy Pi's path math; all services below are in-memory so nothing is read
|
||||
* from or written to it during creation.
|
||||
*/
|
||||
agentDir: string;
|
||||
/** Model providers (model backends) registered entirely in memory. */
|
||||
modelProviders: PaseoAgentModelProvider[];
|
||||
/** Explicit model selection. When omitted, Pi falls back to its own resolution. */
|
||||
model?: PaseoAgentModelReference;
|
||||
thinkingLevel?: ThinkingLevel;
|
||||
/** In-memory credential seed, if any provider auth is keyed by AuthStorage. */
|
||||
auth?: PiAuthData;
|
||||
/**
|
||||
* Pi AuthStorage to use. Defaults to a fresh in-memory store. The Paseo Agent
|
||||
* provider passes a file-backed, Paseo-owned store for OAuth providers so Pi can
|
||||
* refresh tokens and persist rotation. Any oauth markers carrying a refresh token
|
||||
* are still seeded into whichever store is used.
|
||||
*/
|
||||
authStorage?: AuthStorage;
|
||||
/** In-memory settings overrides. Empty by default. */
|
||||
settings?: PiSettings;
|
||||
/** Paseo-bridged tools (e.g. MCP) to register alongside built-in tools. */
|
||||
customTools?: ToolDefinition[];
|
||||
/** Optional allowlist of active Pi tool names for this agent definition. */
|
||||
tools?: string[];
|
||||
/** Runtime allow/deny policy for every Pi tool call. */
|
||||
permissionPolicy?: ToolPermissionPolicy;
|
||||
/** Paseo-composed agent/session/daemon instructions. */
|
||||
composedPrompt?: PaseoComposedPrompt;
|
||||
}
|
||||
|
||||
export interface PaseoAgentSessionHandle {
|
||||
session: PiAgentSession;
|
||||
modelRegistry: ModelRegistry;
|
||||
resourceLoader: ResourceLoader;
|
||||
sessionManager: SessionManager;
|
||||
}
|
||||
|
||||
/**
|
||||
* Build a fully Paseo-controlled Pi `ResourceLoader` that performs no discovery.
|
||||
*
|
||||
* Discovery only happens inside `reload()`; the constructor initialises valid empty
|
||||
* state. We never call `reload()`, and the `no*` flags ensure that even an accidental
|
||||
* reload would not scan `~/.pi`, the project, or the cwd.
|
||||
*/
|
||||
function createNoDiscoveryResourceLoader(options: {
|
||||
cwd: string;
|
||||
agentDir: string;
|
||||
settingsManager: SettingsManager;
|
||||
composedPrompt?: PaseoComposedPrompt;
|
||||
}): ResourceLoader {
|
||||
const base = new DefaultResourceLoader({
|
||||
cwd: options.cwd,
|
||||
agentDir: options.agentDir,
|
||||
settingsManager: options.settingsManager,
|
||||
noExtensions: true,
|
||||
noSkills: true,
|
||||
noPromptTemplates: true,
|
||||
noThemes: true,
|
||||
noContextFiles: true,
|
||||
});
|
||||
return options.composedPrompt ? wrapPromptResourceLoader(base, options.composedPrompt) : base;
|
||||
}
|
||||
|
||||
function wrapPromptResourceLoader(
|
||||
delegate: ResourceLoader,
|
||||
composedPrompt: PaseoComposedPrompt,
|
||||
): ResourceLoader {
|
||||
return {
|
||||
getExtensions: () => delegate.getExtensions(),
|
||||
getSkills: () => delegate.getSkills(),
|
||||
getPrompts: () => delegate.getPrompts(),
|
||||
getThemes: () => delegate.getThemes(),
|
||||
getAgentsFiles: () => delegate.getAgentsFiles(),
|
||||
getSystemPrompt: () => composedPrompt.customPrompt ?? delegate.getSystemPrompt(),
|
||||
getAppendSystemPrompt: () => [
|
||||
...delegate.getAppendSystemPrompt(),
|
||||
...composedPrompt.appendSystemPrompt,
|
||||
],
|
||||
extendResources: (paths) => delegate.extendResources(paths),
|
||||
reload: () => delegate.reload(),
|
||||
};
|
||||
}
|
||||
|
||||
function installPermissionPolicy(
|
||||
session: PiAgentSession,
|
||||
permissionPolicy: ToolPermissionPolicy | undefined,
|
||||
): void {
|
||||
if (!permissionPolicy || permissionPolicy.rules.length === 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
const previousBeforeToolCall = session.agent.beforeToolCall;
|
||||
session.agent.beforeToolCall = async (
|
||||
context,
|
||||
signal,
|
||||
): Promise<BeforeToolCallResult | undefined> => {
|
||||
const toolName = context.toolCall.name;
|
||||
if (evaluateToolPermission(permissionPolicy, toolName) === "deny") {
|
||||
return {
|
||||
block: true,
|
||||
reason: `Paseo Agent denied tool "${toolName}" by agent permissions.`,
|
||||
};
|
||||
}
|
||||
return previousBeforeToolCall?.(context, signal);
|
||||
};
|
||||
}
|
||||
|
||||
function resolveOAuthProvider(flow: string): OAuthProviderInterface {
|
||||
const provider = getOAuthProvider(flow);
|
||||
if (!provider) {
|
||||
throw new Error(`Paseo Agent: OAuth flow "${flow}" is not registered by Pi.`);
|
||||
}
|
||||
return provider;
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a Pi agent session through the high-level `createAgentSession` API with
|
||||
* every service supplied in-memory and no Pi config discovery.
|
||||
*/
|
||||
export async function createPaseoAgentSession(
|
||||
options: CreatePaseoAgentSessionOptions,
|
||||
): Promise<PaseoAgentSessionHandle> {
|
||||
// Use the caller's Paseo-owned store when provided (so Pi refreshes + persists token
|
||||
// rotation there), else a fresh in-memory store.
|
||||
const authStorage = options.authStorage ?? AuthStorage.inMemory({ ...options.auth });
|
||||
|
||||
// Seed any oauth marker that carries a refresh token (the advanced/manual override).
|
||||
// The product path leaves this empty — the credential is already in the Paseo store.
|
||||
// Empty `access` + `expires: 0` forces a refresh on the first request.
|
||||
for (const provider of options.modelProviders) {
|
||||
if (provider.oauth?.refreshToken) {
|
||||
authStorage.set(provider.name, {
|
||||
type: "oauth",
|
||||
access: "",
|
||||
refresh: provider.oauth.refreshToken,
|
||||
expires: 0,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
const modelRegistry = ModelRegistry.inMemory(authStorage);
|
||||
|
||||
for (const provider of options.modelProviders) {
|
||||
const config = provider.oauth
|
||||
? { ...provider.config, oauth: resolveOAuthProvider(provider.oauth.flow) }
|
||||
: provider.config;
|
||||
modelRegistry.registerProvider(provider.name, config);
|
||||
}
|
||||
|
||||
const settingsManager = SettingsManager.inMemory(options.settings ?? {});
|
||||
const sessionManager = SessionManager.inMemory(options.cwd);
|
||||
const resourceLoader = createNoDiscoveryResourceLoader({
|
||||
cwd: options.cwd,
|
||||
agentDir: options.agentDir,
|
||||
settingsManager,
|
||||
composedPrompt: options.composedPrompt,
|
||||
});
|
||||
|
||||
const model = options.model
|
||||
? modelRegistry.find(options.model.provider, options.model.id)
|
||||
: undefined;
|
||||
if (options.model && !model) {
|
||||
throw new Error(
|
||||
`Paseo Agent: model ${options.model.provider}/${options.model.id} is not registered by any model provider`,
|
||||
);
|
||||
}
|
||||
|
||||
const { session } = await createAgentSession({
|
||||
cwd: options.cwd,
|
||||
agentDir: options.agentDir,
|
||||
authStorage,
|
||||
modelRegistry,
|
||||
settingsManager,
|
||||
sessionManager,
|
||||
resourceLoader,
|
||||
...(model ? { model } : {}),
|
||||
...(options.thinkingLevel ? { thinkingLevel: options.thinkingLevel } : {}),
|
||||
...(options.customTools ? { customTools: options.customTools } : {}),
|
||||
...(options.tools ? { tools: options.tools } : {}),
|
||||
});
|
||||
|
||||
// Custom (MCP) tools are registered but not active by default — only the built-in
|
||||
// tool set is. Activate them unless an agent definition supplied an explicit tool allowlist.
|
||||
if (!options.tools && options.customTools && options.customTools.length > 0) {
|
||||
const customToolNames = options.customTools.map((tool) => tool.name);
|
||||
session.setActiveToolsByName([...session.getActiveToolNames(), ...customToolNames]);
|
||||
}
|
||||
|
||||
installPermissionPolicy(session, options.permissionPolicy);
|
||||
|
||||
return { session, modelRegistry, resourceLoader, sessionManager };
|
||||
}
|
||||
@@ -1,175 +0,0 @@
|
||||
import { mkdirSync, mkdtempSync, rmSync, symlinkSync, writeFileSync } from "node:fs";
|
||||
import { tmpdir } from "node:os";
|
||||
import { join } from "node:path";
|
||||
import { afterEach, beforeEach, describe, expect, it } from "vitest";
|
||||
|
||||
import {
|
||||
composePromptParts,
|
||||
listAgentDefinitionIds,
|
||||
loadAgentDefinition,
|
||||
} from "./prompt-profiles.js";
|
||||
|
||||
describe("Paseo Agent definitions", () => {
|
||||
let paseoHome: string;
|
||||
let agentsDir: string;
|
||||
const tempDirs: string[] = [];
|
||||
|
||||
beforeEach(() => {
|
||||
paseoHome = mkdtempSync(join(tmpdir(), "paseo-agent-profiles-"));
|
||||
agentsDir = join(paseoHome, "agents");
|
||||
mkdirSync(join(agentsDir, "fragments"), { recursive: true });
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
rmSync(paseoHome, { recursive: true, force: true });
|
||||
for (const dir of tempDirs.splice(0)) {
|
||||
rmSync(dir, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
function writeAgent(name: string, content: string): void {
|
||||
writeFileSync(join(agentsDir, name), content);
|
||||
}
|
||||
|
||||
it("parses frontmatter and lists only top-level markdown agents", () => {
|
||||
writeAgent(
|
||||
"orchestrator.md",
|
||||
`---
|
||||
name: Orchestrator
|
||||
description: Routes work
|
||||
prompt: override
|
||||
mcp: [paseo]
|
||||
model: openrouter-main/test-model
|
||||
tools: [read, paseo__list_agents]
|
||||
permissions:
|
||||
- tool: paseo__archive_agent
|
||||
action: deny
|
||||
projectContext: true
|
||||
---
|
||||
Agent body.
|
||||
`,
|
||||
);
|
||||
writeAgent("notes.txt", "ignored");
|
||||
writeFileSync(join(agentsDir, "fragments", "piece.md"), "fragment");
|
||||
|
||||
const agent = loadAgentDefinition(paseoHome, "orchestrator");
|
||||
|
||||
expect(listAgentDefinitionIds(paseoHome)).toEqual(["orchestrator"]);
|
||||
expect(agent?.frontmatter).toMatchObject({
|
||||
name: "Orchestrator",
|
||||
description: "Routes work",
|
||||
prompt: "override",
|
||||
mcp: ["paseo"],
|
||||
model: "openrouter-main/test-model",
|
||||
tools: ["read", "paseo__list_agents"],
|
||||
permissions: [{ tool: "paseo__archive_agent", action: "deny" }],
|
||||
projectContext: true,
|
||||
});
|
||||
expect(agent?.composedPrompt.customPrompt).toBe("Agent body.");
|
||||
});
|
||||
|
||||
it("defaults to extend prompt mode", () => {
|
||||
writeAgent("worker.md", "Body.");
|
||||
|
||||
const agent = loadAgentDefinition(paseoHome, "worker.md");
|
||||
|
||||
expect(agent?.frontmatter.prompt).toBe("extend");
|
||||
expect(agent?.body).toBe("Body.");
|
||||
expect(agent?.composedPrompt.appendSystemPrompt).toEqual(["Body."]);
|
||||
});
|
||||
|
||||
it("resolves bang-brace partials in place relative to the current file", () => {
|
||||
mkdirSync(join(agentsDir, "team", "partials"), { recursive: true });
|
||||
writeFileSync(join(agentsDir, "team", "partials", "style.md"), "Use short answers.");
|
||||
writeFileSync(join(agentsDir, "team", "nested.md"), "nested !{{./partials/style.md}}");
|
||||
writeAgent("inline.md", "Before\n!{{./team/nested.md}}\nAfter");
|
||||
|
||||
expect(loadAgentDefinition(paseoHome, "inline")?.body).toBe(
|
||||
"Before\nnested Use short answers.\nAfter",
|
||||
);
|
||||
});
|
||||
|
||||
it("rejects frontmatter inside partials", () => {
|
||||
writeFileSync(join(agentsDir, "fragments", "bad.md"), "---\nname: Nope\n---\nfragment");
|
||||
writeAgent("agent.md", "!{{./fragments/bad.md}}");
|
||||
|
||||
expect(() => loadAgentDefinition(paseoHome, "agent")).toThrow(/partials cannot declare/i);
|
||||
});
|
||||
|
||||
it("detects partial cycles", () => {
|
||||
writeFileSync(join(agentsDir, "fragments", "a.md"), "!{{./b.md}}");
|
||||
writeFileSync(join(agentsDir, "fragments", "b.md"), "!{{./a.md}}");
|
||||
writeAgent("cycle.md", "!{{./fragments/a.md}}");
|
||||
|
||||
expect(() => loadAgentDefinition(paseoHome, "cycle")).toThrow(/cycle/i);
|
||||
});
|
||||
|
||||
it("rejects missing partials and path escapes", () => {
|
||||
writeAgent("missing.md", "!{{./fragments/nope.md}}");
|
||||
writeAgent("escape.md", "!{{../secret.md}}");
|
||||
|
||||
expect(() => loadAgentDefinition(paseoHome, "missing")).toThrow(/not found/i);
|
||||
expect(() => loadAgentDefinition(paseoHome, "escape")).toThrow(/escape|invalid/i);
|
||||
expect(() => loadAgentDefinition(paseoHome, "../escape")).toThrow(/invalid/i);
|
||||
});
|
||||
|
||||
it("rejects symlink escapes for agents and partials", () => {
|
||||
const outsideDir = mkdtempSync(join(tmpdir(), "paseo-agent-profile-outside-"));
|
||||
tempDirs.push(outsideDir);
|
||||
writeFileSync(join(outsideDir, "secret.md"), "outside secret");
|
||||
symlinkSync(join(outsideDir, "secret.md"), join(agentsDir, "linked-profile.md"));
|
||||
symlinkSync(join(outsideDir, "secret.md"), join(agentsDir, "fragments", "linked.md"));
|
||||
writeAgent("include-link.md", "!{{./fragments/linked.md}}");
|
||||
|
||||
expect(() => loadAgentDefinition(paseoHome, "linked-profile")).toThrow(/escapes/i);
|
||||
expect(() => loadAgentDefinition(paseoHome, "include-link")).toThrow(/escapes/i);
|
||||
});
|
||||
|
||||
it("enforces depth and total size caps", () => {
|
||||
writeFileSync(join(agentsDir, "fragments", "deep.md"), "!{{./deeper.md}}");
|
||||
writeFileSync(join(agentsDir, "fragments", "deeper.md"), "done");
|
||||
writeAgent("depth.md", "!{{./fragments/deep.md}}");
|
||||
writeAgent("large.md", "0123456789");
|
||||
|
||||
expect(() => loadAgentDefinition(paseoHome, "depth", { maxDepth: 1 })).toThrow(/depth/i);
|
||||
expect(() => loadAgentDefinition(paseoHome, "large", { maxTotalBytes: 4 })).toThrow(/bytes/i);
|
||||
});
|
||||
|
||||
it("orders agent append, session prompt, and daemon append with daemon last", () => {
|
||||
writeAgent("extend.md", "Agent prompt.");
|
||||
const agent = loadAgentDefinition(paseoHome, "extend");
|
||||
|
||||
expect(
|
||||
composePromptParts({
|
||||
agent,
|
||||
systemPrompt: " Session prompt. ",
|
||||
daemonAppendSystemPrompt: "Daemon prompt.",
|
||||
}),
|
||||
).toEqual({
|
||||
appendSystemPrompt: ["Agent prompt.", "Session prompt.", "Daemon prompt."],
|
||||
});
|
||||
});
|
||||
|
||||
it("uses override agent body as custom prompt while appending session and daemon prompts", () => {
|
||||
writeAgent(
|
||||
"override.md",
|
||||
`---
|
||||
prompt: override
|
||||
---
|
||||
Replacement base.
|
||||
`,
|
||||
);
|
||||
const agent = loadAgentDefinition(paseoHome, "override");
|
||||
|
||||
expect(
|
||||
composePromptParts({
|
||||
agent,
|
||||
systemPrompt: "Session prompt.",
|
||||
daemonAppendSystemPrompt: "Daemon prompt.",
|
||||
}),
|
||||
).toEqual({
|
||||
customPrompt: "Replacement base.",
|
||||
appendSystemPrompt: ["Session prompt.", "Daemon prompt."],
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -1,279 +0,0 @@
|
||||
import { existsSync, readdirSync, readFileSync, realpathSync, statSync } from "node:fs";
|
||||
import { basename, dirname, extname, isAbsolute, relative, resolve } from "node:path";
|
||||
import { parse as parseYaml } from "yaml";
|
||||
import { z } from "zod";
|
||||
import { ToolPermissionRuleSchema, type ToolPermissionRule } from "./agent-permissions.js";
|
||||
|
||||
const DEFAULT_MAX_DEPTH = 8;
|
||||
const DEFAULT_MAX_TOTAL_BYTES = 256 * 1024;
|
||||
const PARTIAL_PATTERN = /!\{\{\s*([^}]+?)\s*\}\}/g;
|
||||
|
||||
const AgentDefinitionFrontmatterSchema = z
|
||||
.object({
|
||||
name: z.string().min(1).optional(),
|
||||
description: z.string().min(1).optional(),
|
||||
prompt: z.enum(["extend", "override"]).default("extend"),
|
||||
mcp: z.array(z.string().min(1)).optional(),
|
||||
model: z.string().min(1).optional(),
|
||||
tools: z.array(z.string().min(1)).optional(),
|
||||
permissions: z.array(ToolPermissionRuleSchema).optional(),
|
||||
// Parsed for the future explicit project-context model. It is intentionally
|
||||
// inactive here; Paseo Agent still keeps implicit AGENTS.md discovery off.
|
||||
projectContext: z.boolean().optional(),
|
||||
})
|
||||
.passthrough();
|
||||
|
||||
export type AgentDefinitionFrontmatter = z.infer<typeof AgentDefinitionFrontmatterSchema>;
|
||||
export type PromptProfileFrontmatter = AgentDefinitionFrontmatter;
|
||||
|
||||
export interface PaseoComposedPrompt {
|
||||
customPrompt?: string;
|
||||
appendSystemPrompt: string[];
|
||||
}
|
||||
|
||||
export interface ResolvedAgentDefinition {
|
||||
id: string;
|
||||
path: string;
|
||||
frontmatter: AgentDefinitionFrontmatter;
|
||||
body: string;
|
||||
composedPrompt: PaseoComposedPrompt;
|
||||
expectedMcpServers: string[];
|
||||
model?: string;
|
||||
tools?: string[];
|
||||
permissions: ToolPermissionRule[];
|
||||
}
|
||||
|
||||
export type ResolvedPromptProfile = ResolvedAgentDefinition;
|
||||
|
||||
interface LoadAgentDefinitionOptions {
|
||||
maxDepth?: number;
|
||||
maxTotalBytes?: number;
|
||||
}
|
||||
|
||||
interface LoadState {
|
||||
totalBytes: number;
|
||||
}
|
||||
|
||||
interface ParsedMarkdown {
|
||||
frontmatter: AgentDefinitionFrontmatter;
|
||||
body: string;
|
||||
}
|
||||
|
||||
export function loadAgentDefinition(
|
||||
paseoHome: string,
|
||||
agentName: string | undefined,
|
||||
options: LoadAgentDefinitionOptions = {},
|
||||
): ResolvedAgentDefinition | null {
|
||||
if (!agentName) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const agentsDir = resolve(paseoHome, "agents");
|
||||
const agentPath = resolveAgentPath(agentsDir, agentName);
|
||||
if (!existsSync(agentPath)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const state: LoadState = { totalBytes: 0 };
|
||||
const parsed = loadMarkdownWithPartials({
|
||||
agentsDir,
|
||||
path: agentPath,
|
||||
depth: 0,
|
||||
stack: [],
|
||||
state,
|
||||
options,
|
||||
allowFrontmatter: true,
|
||||
});
|
||||
const body = trimPrompt(parsed.body);
|
||||
const promptMode = parsed.frontmatter.prompt;
|
||||
const composedPrompt =
|
||||
promptMode === "override"
|
||||
? { customPrompt: body, appendSystemPrompt: [] }
|
||||
: { appendSystemPrompt: body ? [body] : [] };
|
||||
|
||||
return {
|
||||
id: basename(agentPath, ".md"),
|
||||
path: agentPath,
|
||||
frontmatter: parsed.frontmatter,
|
||||
body,
|
||||
composedPrompt,
|
||||
expectedMcpServers: parsed.frontmatter.mcp ?? [],
|
||||
...(parsed.frontmatter.model ? { model: parsed.frontmatter.model } : {}),
|
||||
...(parsed.frontmatter.tools ? { tools: parsed.frontmatter.tools } : {}),
|
||||
permissions: parsed.frontmatter.permissions ?? [],
|
||||
};
|
||||
}
|
||||
|
||||
export function loadPromptProfile(
|
||||
paseoHome: string,
|
||||
profileName: string | undefined,
|
||||
options: LoadAgentDefinitionOptions = {},
|
||||
): ResolvedPromptProfile | null {
|
||||
return loadAgentDefinition(paseoHome, profileName, options);
|
||||
}
|
||||
|
||||
export function listAgentDefinitionIds(paseoHome: string): string[] {
|
||||
const agentsDir = resolve(paseoHome, "agents");
|
||||
if (!existsSync(agentsDir)) {
|
||||
return [];
|
||||
}
|
||||
return readdirSync(agentsDir, { withFileTypes: true })
|
||||
.filter((entry) => entry.isFile() && extname(entry.name) === ".md")
|
||||
.map((entry) => basename(entry.name, ".md"))
|
||||
.sort();
|
||||
}
|
||||
|
||||
export function listPromptProfileIds(paseoHome: string): string[] {
|
||||
return listAgentDefinitionIds(paseoHome);
|
||||
}
|
||||
|
||||
function resolveAgentPath(agentsDir: string, agentName: string): string {
|
||||
if (!isSafeRelativePath(agentName) || agentName.includes("/") || agentName.includes("\\")) {
|
||||
throw new Error(`Invalid Paseo Agent definition path: ${agentName}`);
|
||||
}
|
||||
const filename = agentName.endsWith(".md") ? agentName : `${agentName}.md`;
|
||||
return resolveConfinedPath(agentsDir, filename);
|
||||
}
|
||||
|
||||
function resolvePartialPath(agentsDir: string, currentPath: string, partialPath: string): string {
|
||||
if (!isSafeRelativePath(partialPath)) {
|
||||
throw new Error(`Invalid Paseo Agent partial path: ${partialPath}`);
|
||||
}
|
||||
return resolveConfinedPath(agentsDir, resolve(dirname(currentPath), partialPath));
|
||||
}
|
||||
|
||||
function isSafeRelativePath(input: string): boolean {
|
||||
return input.trim() === input && input.length > 0 && !isAbsolute(input) && !input.includes("\0");
|
||||
}
|
||||
|
||||
function resolveConfinedPath(agentsDir: string, input: string): string {
|
||||
const realAgentsDir = existsSync(agentsDir) ? realpathSync(agentsDir) : agentsDir;
|
||||
const resolved = isAbsolute(input) ? input : resolve(agentsDir, input);
|
||||
const comparablePath = existsSync(resolved) ? realpathSync(resolved) : resolved;
|
||||
const rel = relative(realAgentsDir, comparablePath);
|
||||
if (rel === "" || rel.startsWith("..") || isAbsolute(rel)) {
|
||||
throw new Error(`Paseo Agent path escapes agents directory: ${input}`);
|
||||
}
|
||||
return resolved;
|
||||
}
|
||||
|
||||
function loadMarkdownWithPartials(input: {
|
||||
agentsDir: string;
|
||||
path: string;
|
||||
depth: number;
|
||||
stack: string[];
|
||||
state: LoadState;
|
||||
options: LoadAgentDefinitionOptions;
|
||||
allowFrontmatter: boolean;
|
||||
}): ParsedMarkdown {
|
||||
const maxDepth = input.options.maxDepth ?? DEFAULT_MAX_DEPTH;
|
||||
if (input.depth > maxDepth) {
|
||||
throw new Error(`Paseo Agent prompt include depth exceeds ${maxDepth}`);
|
||||
}
|
||||
if (!existsSync(input.path) || !statSync(input.path).isFile()) {
|
||||
throw new Error(`Paseo Agent partial not found: ${relative(input.agentsDir, input.path)}`);
|
||||
}
|
||||
|
||||
const path = realpathConfined(input.agentsDir, input.path);
|
||||
if (input.stack.includes(path)) {
|
||||
const cycle = [...input.stack, path].map((entry) => relative(input.agentsDir, entry));
|
||||
throw new Error(`Paseo Agent partial cycle: ${cycle.join(" -> ")}`);
|
||||
}
|
||||
|
||||
const raw = readFileSync(path, "utf8");
|
||||
input.state.totalBytes += Buffer.byteLength(raw, "utf8");
|
||||
const maxTotalBytes = input.options.maxTotalBytes ?? DEFAULT_MAX_TOTAL_BYTES;
|
||||
if (input.state.totalBytes > maxTotalBytes) {
|
||||
throw new Error(`Paseo Agent definition exceeds ${maxTotalBytes} bytes`);
|
||||
}
|
||||
|
||||
const parsed = parseMarkdown(raw, input.allowFrontmatter);
|
||||
const stack = [...input.stack, path];
|
||||
const bodyWithPartials = parsed.body.replace(
|
||||
PARTIAL_PATTERN,
|
||||
(_match, partialPath: string) =>
|
||||
loadMarkdownWithPartials({
|
||||
...input,
|
||||
path: resolvePartialPath(input.agentsDir, path, partialPath.trim()),
|
||||
depth: input.depth + 1,
|
||||
stack,
|
||||
allowFrontmatter: false,
|
||||
}).body,
|
||||
);
|
||||
|
||||
return {
|
||||
frontmatter: parsed.frontmatter,
|
||||
body: bodyWithPartials,
|
||||
};
|
||||
}
|
||||
|
||||
function realpathConfined(agentsDir: string, path: string): string {
|
||||
const realAgentsDir = realpathSync(agentsDir);
|
||||
const realPath = realpathSync(path);
|
||||
const rel = relative(realAgentsDir, realPath);
|
||||
if (rel === "" || rel.startsWith("..") || isAbsolute(rel)) {
|
||||
throw new Error(`Paseo Agent path escapes agents directory: ${relative(agentsDir, path)}`);
|
||||
}
|
||||
return realPath;
|
||||
}
|
||||
|
||||
function parseMarkdown(raw: string, allowFrontmatter: boolean): ParsedMarkdown {
|
||||
if (!raw.startsWith("---\n") && !raw.startsWith("---\r\n")) {
|
||||
return {
|
||||
frontmatter: AgentDefinitionFrontmatterSchema.parse({}),
|
||||
body: raw,
|
||||
};
|
||||
}
|
||||
|
||||
if (!allowFrontmatter) {
|
||||
throw new Error("Paseo Agent partials cannot declare frontmatter");
|
||||
}
|
||||
|
||||
const newline = raw.startsWith("---\r\n") ? "\r\n" : "\n";
|
||||
const closeMarker = `${newline}---${newline}`;
|
||||
const closeIndex = raw.indexOf(closeMarker, 4);
|
||||
if (closeIndex === -1) {
|
||||
throw new Error("Paseo Agent definition has unterminated frontmatter");
|
||||
}
|
||||
|
||||
const yaml = raw.slice(4, closeIndex);
|
||||
const body = raw.slice(closeIndex + closeMarker.length);
|
||||
const value = yaml.trim() ? parseYaml(yaml) : {};
|
||||
return {
|
||||
frontmatter: AgentDefinitionFrontmatterSchema.parse(value ?? {}),
|
||||
body,
|
||||
};
|
||||
}
|
||||
|
||||
export function composePromptParts(input: {
|
||||
agent?: ResolvedAgentDefinition | null;
|
||||
systemPrompt?: string;
|
||||
daemonAppendSystemPrompt?: string;
|
||||
}): PaseoComposedPrompt | undefined {
|
||||
const agentPrompt = input.agent?.composedPrompt;
|
||||
const appendSystemPrompt = [
|
||||
...(agentPrompt?.appendSystemPrompt ?? []),
|
||||
input.systemPrompt,
|
||||
input.daemonAppendSystemPrompt,
|
||||
].flatMap((part) => {
|
||||
const trimmed = trimPrompt(part);
|
||||
return trimmed ? [trimmed] : [];
|
||||
});
|
||||
const hasCustomPrompt = Boolean(
|
||||
agentPrompt && Object.prototype.hasOwnProperty.call(agentPrompt, "customPrompt"),
|
||||
);
|
||||
const customPrompt = trimPrompt(agentPrompt?.customPrompt);
|
||||
|
||||
if (!hasCustomPrompt && appendSystemPrompt.length === 0) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
return {
|
||||
...(hasCustomPrompt ? { customPrompt } : {}),
|
||||
appendSystemPrompt,
|
||||
};
|
||||
}
|
||||
|
||||
function trimPrompt(value: string | undefined): string {
|
||||
return value?.trim() ?? "";
|
||||
}
|
||||
@@ -2009,7 +2009,7 @@ export function createPaseoToolCatalog(options: PaseoToolHostDependencies): Pase
|
||||
}
|
||||
|
||||
const expiresAt = buildScheduleExpiry(expiresIn);
|
||||
const schedule = await scheduleService.create({
|
||||
const schedule = await scheduleService.createOrReplace({
|
||||
prompt: prompt.trim(),
|
||||
cadence: buildCronScheduleCadence({
|
||||
cron,
|
||||
@@ -2058,7 +2058,7 @@ export function createPaseoToolCatalog(options: PaseoToolHostDependencies): Pase
|
||||
resolveCallerAgent();
|
||||
|
||||
const expiresAt = buildScheduleExpiry(expiresIn);
|
||||
const schedule = await scheduleService.create({
|
||||
const schedule = await scheduleService.createOrReplace({
|
||||
prompt: prompt.trim(),
|
||||
cadence: buildCronScheduleCadence({
|
||||
cron,
|
||||
|
||||
@@ -144,7 +144,6 @@ import type {
|
||||
AgentProviderRuntimeSettingsMap,
|
||||
ProviderOverride,
|
||||
} from "./agent/provider-launch-config.js";
|
||||
import type { PaseoAgentConfig } from "./agent/providers/paseo-agent/config.js";
|
||||
import type { PersistedConfig } from "./persisted-config.js";
|
||||
import { createServiceProxySubsystem, type ServiceProxySubsystem } from "./service-proxy.js";
|
||||
import { ScriptHealthMonitor } from "./script-health-monitor.js";
|
||||
@@ -374,7 +373,6 @@ export interface PaseoDaemonConfig {
|
||||
}>;
|
||||
};
|
||||
providerOverrides?: Record<string, ProviderOverride>;
|
||||
paseoAgentConfig?: PaseoAgentConfig;
|
||||
log?: PersistedConfig["log"];
|
||||
onLifecycleIntent?: (intent: DaemonLifecycleIntent) => void;
|
||||
pushNotificationSender?: PushNotificationSender;
|
||||
@@ -741,8 +739,6 @@ export async function createPaseoDaemon(
|
||||
logger: providerSnapshotLogger,
|
||||
runtimeSettings: config.agentProviderSettings,
|
||||
providerOverrides: config.providerOverrides,
|
||||
paseoAgentConfig: config.paseoAgentConfig,
|
||||
paseoHome: config.paseoHome,
|
||||
workspaceGitService,
|
||||
managedProcesses,
|
||||
isDev: config.isDev === true,
|
||||
@@ -822,9 +818,9 @@ export async function createPaseoDaemon(
|
||||
await scheduleService.start();
|
||||
agentManager.setAgentArchivedCallback(async (agentId) => {
|
||||
try {
|
||||
await scheduleService.deleteForAgent(agentId);
|
||||
await scheduleService.completeForAgent(agentId);
|
||||
} catch (error) {
|
||||
logger.warn({ err: error, agentId }, "Failed to delete schedules for archived agent");
|
||||
logger.warn({ err: error, agentId }, "Failed to complete schedules for archived agent");
|
||||
}
|
||||
});
|
||||
logger.info({ elapsed: elapsed() }, "Schedule service initialized");
|
||||
@@ -1000,6 +996,7 @@ export async function createPaseoDaemon(
|
||||
agentManager.setPaseoToolsEnabled(config.mcpInjectIntoAgents !== false);
|
||||
|
||||
const mcpEnabled = config.mcpEnabled ?? true;
|
||||
let agentMcpBaseUrl: string | null = null;
|
||||
if (mcpEnabled) {
|
||||
const agentMcpRoute = "/mcp/agents";
|
||||
|
||||
@@ -1158,9 +1155,11 @@ export async function createPaseoDaemon(
|
||||
const logAndResolve = async () => {
|
||||
boundListenTarget = resolveBoundListenTarget(listenTarget, httpServer);
|
||||
const mcpBaseUrl = mcpEnabled ? createAgentMcpBaseUrl(boundListenTarget) : null;
|
||||
agentManager.setMcpBaseUrl(mcpBaseUrl);
|
||||
agentMcpBaseUrl = config.mcpInjectIntoAgents === false ? null : mcpBaseUrl;
|
||||
agentManager.setMcpBaseUrl(agentMcpBaseUrl);
|
||||
agentManager.setPaseoToolsEnabled(config.mcpInjectIntoAgents !== false);
|
||||
daemonConfigStore.onFieldChange("mcp.injectIntoAgents", (value) => {
|
||||
agentManager.setMcpBaseUrl(value ? mcpBaseUrl : null);
|
||||
agentManager.setPaseoToolsEnabled(value !== false);
|
||||
});
|
||||
daemonConfigStore.onFieldChange("appendSystemPrompt", (value) => {
|
||||
|
||||
@@ -505,7 +505,6 @@ export function loadConfig(
|
||||
agentProviderSettings: extractAgentProviderSettings(providerOverrides),
|
||||
metadataGeneration: persisted.agents?.metadataGeneration,
|
||||
providerOverrides,
|
||||
paseoAgentConfig: persisted.agents?.paseo,
|
||||
log: resolveLogConfigFromEnv(env, persisted),
|
||||
};
|
||||
}
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user