Compare commits

...

27 Commits

Author SHA1 Message Date
Mohamed Boudra
119afd7281 chore(release): cut 0.1.100 2026-06-24 17:42:47 +07:00
Mohamed Boudra
e58725ee39 docs(changelog): 0.1.100 2026-06-24 17:41:17 +07:00
Mohamed Boudra
36cdfaf516 chore(acp): update provider catalog to latest registry versions 2026-06-24 17:41:13 +07:00
Mohamed Boudra
c5442ef0a2 Stop Claude context meter from doubling requests (#1701)
* fix(claude): stop probing context usage after turns

Use Claude stream/result usage for parent context tracking, and update the meter from compact boundary post tokens. Subagent usage reports stay out of the parent context meter.

* fix(claude): clear compact context fallback between turns
2026-06-24 17:44:39 +08:00
Mohamed Boudra
0748149ec9 feat(providers): expose custom agent selection (#1700) 2026-06-24 16:39:48 +08:00
Mohamed Boudra
8c67415fdb Merge branch 'main' of github.com:getpaseo/paseo 2026-06-24 14:42:26 +07:00
Mohamed Boudra
6fe320055d feat(app): cycle agent modes with Shift+Tab 2026-06-24 14:41:14 +07:00
Mohamed Boudra
2ef119c24b Fix OMP slash commands and skills loading (#1698)
* Fix OMP slash command discovery

Keep Pi on get_commands while allowing the OMP-backed provider to use get_available_commands. Both providers reject the other command without echoing an id, so the adapter needs an explicit command-list RPC setting instead of a timeout-based fallback.

* Address OMP command test review
2026-06-24 15:11:05 +08:00
Mohamed Boudra
79be6d8dba Stop OpenCode helper servers from leaking (#1697)
* Fix OpenCode helper server ownership

Own OpenCode helper generations as soon as spawn returns so startup timeout and shutdown clean up through the manager/reaper path. Collapse the OpenCode runtime wrapper so the provider talks to the server manager directly.

* Protect dedicated OpenCode server startup
2026-06-24 14:44:43 +08:00
paseo-ai[bot]
42e3f63dec fix: update lockfile signatures and Nix hash [skip ci] 2026-06-23 15:52:14 +00:00
Mohamed Boudra
7a817774b7 chore(release): cut 0.1.99 2026-06-23 22:47:51 +07:00
Mohamed Boudra
12101914c2 Refresh changelog for 0.1.99 2026-06-23 22:46:18 +07:00
Mohamed Boudra
f94b488c4f Fix ACP user message echo dedupe 2026-06-23 22:25:16 +07:00
Mohamed Boudra
9170c2f0e6 Update ACP provider catalog to latest registry versions 2026-06-23 21:53:11 +07:00
Mohamed Boudra
78d46a8a82 Restore workspace after reopening app
Native state restore can resume at the host route instead of the root index, so host home now resolves the remembered workspace itself. Keep shared route policy outside src/app so Expo Router does not register it as a page.
2026-06-23 21:09:35 +07:00
Mohamed Boudra
1970a14349 Keep provider diagnostics and model lists in sync (#1660)
* fix(server): align provider catalog diagnostics

* fix(server): preserve catalog profile models

* refactor: unify provider catalog discovery under AgentClient.fetchCatalog

Remove listModels/listModes from AgentClient and fetchModels/fetchModes
from ProviderDefinition. All provider runtime discovery now flows through
a single fetchCatalog(options) => ProviderCatalog API.

ProviderSnapshotManager.listModels/listModes remain as cached snapshot
conveniences only. Provider implementations (acp, codex, opencode, pi,
claude, mock) updated accordingly; agent-manager default model resolution
now calls fetchCatalog.

Reshape step toward issue pi-model-list-empty.

* refactor: remove remaining provider listModels/listModes runtime API residue

Migrate remaining AgentClient/provider-client implementations and tests to
fetchCatalog. Remove obsolete ListModelsOptions/ListModesOptions interfaces.
Update ProviderSnapshotManager.getProviderDiagnostic to materialize clients
via ensureClient(provider, definition) so diagnostics self-heal the settings
sheet instead of failing when providerClients[provider] is absent.

Allowed to remain: ProviderSnapshotManager.listModels/listModes as cached
snapshot readers; protocol/client legacy list_provider_models names; unrelated
local helper in create-agent-mode.

* test(server): repair test clients after fetchCatalog refactor

- Restore TestAgentClient.fetchCatalog with proper model list and resumeSession.
- Restore NativeArchiveRecordingClient and EnvProbeAgentClient removed during refactor.
- Fix ResumeCaptureClient.fetchCatalog and resumeSession.
- Fix stream-coalescing TestAgentClient.fetchCatalog shape and isAvailable.
- Mock accessible OpenCode provider in full-access mode tests so fetchCatalog does not throw.

* refactor(app): update stable discovered models ref directly during render
2026-06-23 17:55:47 +08:00
Mohamed Boudra
a397d411dd Improve GitHub panel toolbar and loading states (#1664)
* feat(app): add toolbar and loading states to the GitHub PR panel

Adds a toolbar under the PR title with a refresh button (refreshes git
and GitHub status + timeline in one tap), an always-visible View Pull
Request button, and a Merge control with a method dropdown that is
disabled-with-reason when the PR can't be merged yet.

The merge control reuses the existing git-actions policy verbatim via a
new buildPrPanelMergeActions selector fed into GitActionsSplitButton, so
no merge readiness/squash/auto-merge logic is duplicated.

Also replaces the empty-state flash with skeletons: a full-panel
skeleton while PR status loads and an activity skeleton while the
timeline loads, with empty states shown only when loaded-and-empty and
an error state with retry.

* refactor(app): tidy PR panel skeleton (i18n, shared pulse, index)

Addresses review feedback on the PR panel work:
- Localize the skeleton's "Checks" label via t() instead of hardcoding.
- Hoist the shared SkeletonPulse + useSkeletonPulse into activity-skeleton
  so pane-skeleton reuses them instead of duplicating the pulse driver.
- Export PullRequestPaneError/Skeleton from the module index and import
  them through it, matching the module's existing public surface.

* test(app): remove mock-based PR pane test

* fix(app): simplify PR panel toolbar

* fix(app): report PR panel retry failures
2026-06-23 17:50:53 +08:00
Mohamed Boudra
d9cd6ea0fd Keep composer mode preferences stable (#1658)
* fix(app): preserve composer mode preferences

Treat undefined provider preference updates as no-ops, and keep saved mode selections instead of silently defaulting them when provider metadata is incomplete or does not list the saved mode.

* fix(app): document mode preference preservation
2026-06-23 09:22:26 +00:00
Mohamed Boudra
c03e7b82b4 Keep compact file explorer visible while open (#1661)
* fix(app): keep compact explorer mounted while open

* fix(app): share compact explorer host state

* fix(app): refresh diff row metrics after font changes
2026-06-23 16:10:37 +08:00
Mohamed Boudra
26b2f25050 refactor(server): decompose session.ts into per-domain subsystems (#1646)
* refactor(server): move checkout write handlers into CheckoutSession

The checkout read side moved into session/checkout/checkout-session.ts in
#1644; the 17 inline write/PR/stash handlers stayed on the Session god-object.
This carves them into the same subsystem behind an expanded CheckoutSessionHost,
so dispatchCheckoutMessage becomes pure delegation and session.ts drops ~800
lines.

Moved into CheckoutSession (verbatim, only this.X -> this.host.X rewiring):
switch/rename branch, commit, merge, merge-from-base, pull, push, PR
create/merge, github auto-merge/check-details, PR status/timeline, github
search, stash save/pop/list, plus resolveCurrentPullRequest and the
PR-timeline helpers.

CheckoutSessionHost gains the Session-owned, non-checkout collaborators these
handlers orchestrate (notifyGitMutation, emitWorkspaceUpdateForCwd,
handleWorkspaceGitBranchSnapshot, renameCurrentBranch, checkoutExistingBranch,
and the LLM commit/PR-text generators); paseoHome/worktreesRoot join the
options bag. GitMutationRefreshReason moves to utils/checkout-git.ts so both
the shell and the subsystem share one canonical type.

session.test.ts checkout tests now drive the handlers through the public
handleMessage boundary instead of reaching into private methods; new mock-free
fake-host tests cover the moved handlers directly in checkout-session.test.ts.

Behavior-preserving: full session.test.ts + checkout-session.test.ts green,
typecheck/lint/format clean.

* refactor(server): extract chat/schedule/loop handlers into ChatScheduleLoopSession

Move the 7 chat/*, 9 schedule/*, and 5 loop/* request handlers, the three
rpc-error emitters (kept separate), and toScheduleSummary out of the Session
god-object into session/chat/chat-schedule-loop-session.ts, mirroring the shipped
CheckoutSession/VoiceSession deep-module-with-narrow-Host pattern. The least-coupled
remaining domain: stateless request/response over chatService/scheduleService/
loopService, reaching the shell only through a narrow ChatScheduleLoopSessionHost
(emit + agent-roster reads + the mention-fanout send). session.ts drops ~590 lines.

The two dispatch switches collapse into one: schedule/* was previously reached only
via the chat dispatcher's default fall-through arm — now all 21 types are explicit
delegation cases, removing that fragility.

Behavior-preserving: a new routing test drives the real Session.handleMessage for all
21 types (guards against a silently-dropped case), green before and after the move; a
collocated subsystem test covers the mention-fanout send seam, the fanout-limit error
code, the self->agent target remap, and the toScheduleSummary runs-stripping.

* refactor(server): extract provider catalog handlers into ProviderCatalogSession

Move the 8 provider-catalog handlers (model/mode/feature listing, providers
snapshot pull/refresh, diagnostic, usage) plus emitProviderDisabledResponse,
getProviderSnapshotEntryForRead, buildDraftAgentSessionConfig, the two mode-icon
downgrade helpers, and the providers_snapshot_update PUSH wiring out of session.ts
into session/provider/provider-catalog-session.ts behind a narrow
ProviderCatalogSessionHost seam — the same deep-module pattern as ChatScheduleLoopSession,
CheckoutSession, and VoiceSession.

The PUSH (start) and every PULL gate provider visibility and downgrade mode icons
through the SAME injected predicates (isProviderVisibleToClient + supportsCustomModeIcons),
both reading appVersion/clientCapabilities live — the COMPAT invariant the shell could
only enforce by code proximity before. dispatchProviderMessage collapses to delegation;
start()/dispose() wire into subscribeToOptionalManagers/cleanup at the same ordinal.

session.ts drops ~372 lines. Behavior-preservation gate (provider routing through the
real Session.handleMessage) green before and after; full session.test.ts 115/115; new
collocated subsystem test covers the PUSH/PULL parity seam, the disabled-provider path,
and the usage/feature error envelopes.

* refactor(server): extract workspace file-access handlers into WorkspaceFilesSession

Move the file-explorer, file-upload, file-transfer-frame, project-icon and
file-download-token handlers out of session.ts into
session/files/workspace-files-session.ts behind a narrow WorkspaceFilesSessionHost
seam (emit/emitBinary/hasBinaryChannel), matching the deep-module-with-Host shape
used by CheckoutSession/ChatScheduleLoopSession/ProviderCatalogSession.

The new module owns the FileUploadStore and is injected the daemon's
DownloadTokenStore; the shell's dispatch cases and the file_transfer binary route
collapse to delegation. The five handlers touch no workspace-git observer,
registry or subscription state, so this is a clean self-contained slice of the
workspace domain. session.ts drops 240 lines (7316 -> 7076).

Behavior-preservation tests (file explorer list/read/binary, download token
success + empty-cwd, project icon, upload round-trip) added to session.test.ts and
run green against the pre- and post-refactor Session; a collocated
workspace-files-session.test.ts exercises the module via a fake host with real
upload/download stores (no module mocks).

* refactor(server): extract agent-config setters into AgentConfigSession

Move the four agent-config setters (set_agent_mode/model/feature/thinking)
out of the session god-object into session/agent-config/, collapsing their
four near-identical try/log/emit envelopes into one applyConfigChange helper
behind a narrow { emit } host plus an AgentConfigOperations port.

Behavior-preserving: the dispatch cases delegate unchanged, setAgentModeCommand
still routes the mode path, and model/feature still emit no notice. Adds the
previously-absent failure-envelope coverage: 8 gate tests in session.test.ts
(success + forced failure per setter, asserting the *_response envelope and the
activity_log-before-response ordering) green before and after the carve, plus a
collocated agent-config-session.test.ts (typed fake operations + fake host, no
module mocks). session.ts drops ~173 lines.

* refactor(server): extract project-config handlers into ProjectConfigSession

Move read/write_project_config handlers, their failure emitters, and the
root-resolution helpers (resolveKnownProjectRootForConfig, canonicalizeConfigRoot,
stripTrailingPathSeparators) out of session.ts into session/project-config/, behind
a narrow { emit } host plus a projectRegistry port — matching the established
session/<domain>/ pattern. Also corrects a misfiling: project-config was dispatched
inside dispatchAgentConfigMessage despite being its own domain.

session.ts drops 155 lines (6903 -> 6748). The handleMessage-driven gate in
session.test.ts (project config RPC authorization) is green before and after; new
collocated project-config-session.test.ts covers the subsystem with a fake host and
fake registry over a real filesystem (no module mocks).

* refactor(server): extract daemon status/pairing handlers into DaemonSession

Move the two daemon.* RPC handlers (daemon.get_status, daemon.get_pairing_offer)
out of session.ts into session/daemon/daemon-session.ts behind a narrow
{ emit } host + injected reads (paseoHome, serverId, daemonVersion,
daemonRuntimeConfig, listProviderAvailability). These were misfiled inside
dispatchAgentConfigMessage despite being daemon- not agent-scoped; the slice
also removes the now-dead serverId/daemonVersion/daemonRuntimeConfig fields and
the getPidLockInfo/generateLocalPairingOffer imports from the god-file, and
collapses the inline daemonRuntimeConfig type into a named DaemonRuntimeConfig.

The daemon-config get/set handlers stay inline: daemonConfigStore is a
listener-bearing, cross-domain hub (read by structured-generation,
websocket-server, bootstrap, archive-if-safe), so moving it would double-own it.

session.ts: 6748 -> 6662 lines. Gate: new session.test.ts daemon round-trips
(status success + listing-rejects fallback, pairing relay-disabled) pass before
and after; new collocated daemon-session.test.ts (fake host + injected reads
over a real temp dir, no module mocks) 4/4. typecheck/lint/format clean.
2026-06-22 10:44:07 +08:00
Mohamed Boudra
2b5cc727f0 refactor(server): move VoiceSession into session/voice/ to match CheckoutSession (#1645)
#1640 extracted the voice subsystem to server/voice/ (a peer of session.ts);
#1644 extracted the checkout read side to session/checkout/. The two carves
landed in inconsistent homes, so there was no single place to find "what has
session.ts been carved into."

Relocate server/voice/ -> server/session/voice/ so all session subsystems share
one home alongside session/checkout/. Pure relocation: git-tracked renames with
import-depth bumped one level (../ -> ../../); the only external touch point is
the import path in session.ts. Behavior preserved verbatim — the 17 voice unit
tests pass unchanged before and after.

Also record the actual shipped layout and the next carve (the checkout mutation
handlers still inline in session.ts -> session/checkout/checkout-session.ts) in
the decomposition plan, and fix its now-stale voice path.
2026-06-21 20:45:26 +08:00
Mohamed Boudra
18f880e561 refactor(server): extract checkout read subsystem into CheckoutSession (#1644)
Extract the checkout read & live-stream side (status, branch validate/suggest, manual refresh, diff + status subscriptions) out of session.ts into session/checkout/checkout-session.ts behind a narrow CheckoutSessionHost seam and a CheckoutDiffSubscriber port. session.ts -234 net lines; 12 unit tests via injected fakes (no module mocks); 6 session tests moved onto the public handleMessage path. Also fixes a pre-existing tilde-resolution bug in the refresh handler (TDD).
2026-06-21 19:37:48 +08:00
Mohamed Boudra
cf4dd8616c refactor(app): move workspace setup-status fetch into the setup store (#1641)
* refactor(app): move workspace setup-status fetch into the setup store

The workspace screen ran a fetch-once-and-store workflow inline in a
useEffect, deduped by a hidden requestedWorkspaceSetupStatusKeyRef state
machine and a manual cancellation flag. The fetch + dedup + response
validation only existed inside the component, so it could only be
exercised through E2E.

Move the workflow into useWorkspaceSetupStore as an idempotent
ensureSetupStatus action with the daemon client injected as a port. The
store owns the in-flight dedup (requestedKeys) and clears the marker on
error/removal so a later attempt retries. The component effect now just
delegates; the ref and cancellation flag are gone.

This makes the workflow unit-testable with a fake client (no mocks) and
is the first slice of pulling business logic out of the workspace-screen
god component.

* fix(app): release the setup-status in-flight marker on every settle

Greptile P1: when fetchWorkspaceSetupStatus returned a null snapshot or a
mismatched workspaceId, the key stayed in requestedKeys forever, so a later
mount could never retry — a regression versus the old component-scoped ref,
which reset on remount.

Make requestedKeys a pure in-flight marker: add it before the fetch and
release it in a finally once the request settles (success, ignored, or
error). A settle that stored no snapshot leaves no marker, so the next call
retries; once a snapshot lands, the snapshots[key] guard prevents redundant
refetches. This also lets removeWorkspace/clearServer drop their now-redundant
requestedKeys pruning.

Adds tests for retry-after-null-snapshot and retry-after-mismatched-workspace.
2026-06-21 16:40:13 +08:00
Mohamed Boudra
617cf8a7bf refactor(server): extract voice mode subsystem into VoiceSession (#1640)
* refactor(server): extract voice mode subsystem into VoiceSession

session.ts was a 10.5k-line god object and the most-churned file in the
repo. ~1,100 of those lines were an entire voice/audio subsystem — the
STT/TTS/dictation managers, the barge-in audio-buffering state machine,
voice-turn orchestration, and the MCP voice bridge — interleaved field by
field and method by method with workspace/git/agent/provider concerns.

Move the whole subsystem into a new VoiceSession deep module. Session now
holds one `voiceSession` field, constructs it once, and delegates the
voice/dictation/abort message types, the permission auto-allow gate, and
cleanup to it. VoiceSession owns all 18 voice fields and ~25 methods and
reaches back into the agent run only through a narrow VoiceSessionHost
seam (emit, loadAgent, reloadAgentSession, sendSpokenInput,
interruptAgentIfRunning, hasActiveAgentRun).

Behavior is preserved (verbatim method moves); deleting voice-session.ts
now removes the feature mechanically. The voice unit tests drive the
VoiceSession boundary instead of reaching into Session internals, so
future voice tests can construct a VoiceSession with a fake host.

session.ts: 10,468 -> 9,272 lines.

* refactor(server): move VoiceSession into voice/ and test it at the boundary

Address Greptile review on #1640:

- Move voice-session.ts into the existing voice/ subdirectory, alongside
  voice-turn-controller.ts, instead of adding another peer to the 30+ file
  server/ directory. The directory now carries the domain.
- Add voice/voice-session.test.ts beside the module: it constructs a
  VoiceSession with a fake VoiceSessionHost and drives it through the public
  API (handleSetVoiceMode, then turn-detection/STT events), asserting on the
  host seam (sendSpokenInput) and emitted messages. No Session, no private
  field/method access.
- Remove the voice tests from session.test.ts that reached through Session's
  private voiceSession field into VoiceSession internals.

Same three behaviors are covered (streaming final -> agent, finalization
timeout empty path, low-confidence filtering); driving handleSetVoiceMode
additionally exercises the enable path the old tests bypassed.
2026-06-21 07:14:23 +00:00
Mohamed Boudra
9c86a410ea fix(website): support stable AppImage asset names 2026-06-21 01:14:00 +07:00
Mohamed Boudra
2d8acc1611 docs: add troubleshooting page
Covers providers showing "Not installed", the login-shell/PATH
mismatch behind agents and terminals not seeing your commands, and
restarting the daemon after config changes.
2026-06-21 00:52:29 +07:00
paseo-ai[bot]
f2e7ac2dc1 fix: update lockfile signatures and Nix hash [skip ci] 2026-06-20 17:34:11 +00:00
145 changed files with 12339 additions and 6328 deletions

View File

@@ -1,5 +1,35 @@
# Changelog
## 0.1.100 - 2026-06-24
### Added
- Cycle agent modes with Shift+Tab
- Select a custom Copilot agent when starting or mid-session ([#1700](https://github.com/getpaseo/paseo/pull/1700))
### Improved
- ACP provider catalog updated to the latest registry versions
### Fixed
- Claude no longer sends an extra API request after each message ([#1701](https://github.com/getpaseo/paseo/pull/1701))
- OpenCode no longer leaves stray background servers running after sessions end ([#1697](https://github.com/getpaseo/paseo/pull/1697))
- Slash commands and skills now load in OMP agents ([#1698](https://github.com/getpaseo/paseo/pull/1698))
## 0.1.99 - 2026-06-23
### Improved
- The PR panel now has a refresh button and clearer loading states ([#1664](https://github.com/getpaseo/paseo/pull/1664))
- Provider diagnostics and model lists now stay in sync ([#1660](https://github.com/getpaseo/paseo/pull/1660))
### Fixed
- ACP providers like Grok no longer show duplicate user messages
- Saved composer modes no longer reset while provider data is loading ([#1658](https://github.com/getpaseo/paseo/pull/1658))
- The right sidebar no longer gets stuck on mobile ([#1661](https://github.com/getpaseo/paseo/pull/1661))
## 0.1.98 - 2026-06-21
### Added

View File

@@ -49,6 +49,38 @@ PASEO_DEV_RESET_HOME=1 npm run dev # clear and reseed the derived wor
In Paseo-managed worktree services, use the injected service environment rather than hardcoded root checkout ports.
### Expo Router layout ownership
Each layout owns only the routes directly inside its directory. In the root
layout, register `h/[serverId]`; do not register host leaf routes such as
`h/[serverId]/workspace/[workspaceId]`, `h/[serverId]/open-project`, or
`h/[serverId]/index` there. The `h/[serverId]/_layout.tsx` file owns those leaf
routes with its own nested stack and relative screen names:
`workspace/[workspaceId]/index`, `open-project`, `index`, and so on. Expo Router
warns with `[Layout children]: No route named ...` when a layout registers
grandchildren. Treat that warning as a route-tree bug: on native, this shape can
leave a nested index route mounted without its local dynamic params and render a
blank screen.
Do not paper over missing required route params by reading global params in the
leaf. Required dynamic params belong to the matched route. If
`useLocalSearchParams()` misses one, fix the layout ownership.
Keep non-route modules out of `src/app`. Expo Router treats ordinary `.ts` and
`.tsx` files there as routes, which produces `missing the required default
export` warnings and pollutes the route tree. Put shared route policy in
`src/navigation`, `src/utils`, or another non-route directory.
Treat `/h/[serverId]` as the host home route. It resolves to the last remembered
workspace for that host after the workspace-selection store hydrates unless the
host's hydrated workspace list proves that workspace is gone; hosts without a
remembered workspace go to `open-project`.
Keep workspace identity and retention outside native-stack `getId`/
`dangerouslySingular`. Expo Router maps `dangerouslySingular` to React
Navigation `getId`, and `getId` has broken Android native-stack/Fabric by
reordering an already-mounted workspace screen.
### iOS simulator preview service
Paseo worktrees expose the native iOS dev app through the `ios-simulator` service in `paseo.json`. The service URL serves the simulator preview at `/.sim`, so the preview link is `${PASEO_URL}/.sim`.

View File

@@ -10,6 +10,8 @@ Extend `ACPAgentClient` from `packages/server/src/server/agent/providers/acp-age
The only built-in ACP provider today is `copilot` (`copilot-acp-agent.ts`). `GenericACPAgentClient` (`generic-acp-agent.ts`) is also ACP-based but is used for user-defined custom providers configured via `extends: "acp"` overrides — see [docs/custom-providers.md](custom-providers.md).
Copilot custom agents are exposed through ACP session config, not the slash-command list. When custom agents are available, Copilot returns a select config option with `id: "agent"` and `category: "_agent"`; Paseo maps that to the `agent` provider feature. Copilot uses the agent display name as the option value, and the blank value means the default Copilot agent.
### Direct
Implement the `AgentClient` and `AgentSession` interfaces from `agent-sdk-types.ts` yourself. This gives full control but requires you to handle process management, streaming, permissions, and session persistence from scratch.
@@ -26,15 +28,17 @@ Pi import discovery reads Pi's persisted JSONL session files because Pi RPC does
OMP is a built-in Pi-compatible provider, disabled by default. It uses the `omp` command and imports terminal-started sessions from `~/.omp/agent/sessions` when enabled. Other Pi-compatible forks can still be custom providers that extend `pi`, override `command`, and set `params.sessionDir` to their JSONL session directory.
Pi and OMP currently use different RPC names for slash-command discovery. The Pi package accepts `get_commands`; OMP accepts `get_available_commands`. Keep this as an explicit adapter setting for the built-in provider instead of probing with a fallback, because both packages return unknown-command errors without the request `id`, which otherwise turns a fast mismatch into the normal RPC timeout.
Pi RPC extension UI dialog requests (`select`, `input`, `editor`, `confirm`) are bridged into Paseo question permissions and answered with `extension_ui_response`. Pi extensions such as `ask_user` may chain dialogs: for example, a `select` can be followed by an optional-comment `input`. When an `ask_user` tool call declares `allowComment: true`, Paseo presents the selection and optional comment as one question permission, answers Pi's initial `select` immediately, then auto-answers the follow-up optional `input` with the comment the user already supplied (or an empty string). Preserve placeholders and optional/skip semantics for standalone optional inputs so the app can still distinguish "skip this optional input" from "cancel the whole dialog." Fire-and-forget extension UI requests such as notifications are intentionally ignored by the provider adapter unless Paseo grows first-class UI for them.
OpenCode MCP injection is dynamic and session-scoped. Call OpenCode's `mcp.add` endpoint with the MCP server config and do not follow it with `mcp.connect`; `connect` only toggles MCP servers already present in OpenCode's own config. New OpenCode versions return `McpServerNotFoundError`/404 for `connect` after a dynamic add because the server is not config-backed, while older versions silently swallowed the same missing-config path.
OpenCode owns user message IDs. Do not pass Paseo-generated IDs to OpenCode prompt APIs; let OpenCode create `msg*` IDs and record the user timeline item from the `message.updated` event.
Every provider adapter owns its canonical user-message timeline rows. When a foreground prompt is accepted, the adapter must emit exactly one `user_message` timeline item for that submitted prompt, using the same message ID it gives to or receives from the provider runtime. Optimistic client messages are UI-only and provider transcript echoes are optional; neither is allowed to be the only source of truth. If the provider later echoes the same submitted user message, dedupe by provider-visible message ID, not by text.
Every provider adapter owns its canonical user-message timeline rows. When a foreground prompt is accepted, the adapter must emit exactly one `user_message` timeline item for that submitted prompt, using the same message ID it gives to or receives from the provider runtime. Optimistic client messages are UI-only and provider transcript echoes are optional; neither is allowed to be the only source of truth. If the provider later echoes the same submitted user message, dedupe it only within the active turn. Prefer provider-visible message IDs, but ACP runtimes may omit that ID or replace it with a provider-owned one; in that case suppress only echo chunks whose accumulated text is a prefix of the active submitted prompt. Do not perform global transcript text dedupe.
Draft metadata lookups should avoid creating provider sessions when the upstream provider has top-level APIs for that metadata. Prefer `AgentClient.listModels`, `listModes`, `listCommands`, or `listFeatures` over creating a scratch `AgentSession`; scratch sessions can show up as empty native sessions in provider import/history UIs.
Draft metadata lookups should avoid creating provider sessions when the upstream provider has top-level APIs for that metadata. Prefer `AgentClient.fetchCatalog`, `listCommands`, or `listFeatures` over creating a scratch `AgentSession`; scratch sessions can show up as empty native sessions in provider import/history UIs. `fetchCatalog` is the single discovery API for models and modes — provider implementations may use one process, separate upstream calls, or static data internally, but callers outside the provider do not get separate runtime model/mode probes.
Provider session import has its own contract. The picker calls `listImportableSessions` and receives rows only: provider handle, cwd, title, prompt previews, and last activity. Import calls `importSession({ providerHandleId, cwd })` for the selected row and must not call listing again. The provider returns the resumed session, storage config, persistence handle, and hydrated timeline for that one native session; `AgentManager.importProviderSession` seeds the daemon timeline and publishes the Paseo agent only after it is ready.
@@ -42,6 +46,8 @@ Provider session import has its own contract. The picker calls `listImportableSe
Provider-owned helper processes that can outlive an individual agent session must be recorded in the daemon's managed-process registry. Store provider/kind metadata, the PID, launch command/args, and process identity captured from the platform process table. Remove the record on normal exit or shutdown.
If a helper process has a readiness phase, the provider's lifecycle model must own the process immediately after `spawn`, before readiness succeeds. Startup timeout, startup exit, and daemon shutdown must all clean up through that owned generation. Do not keep a spawned helper only inside a readiness promise; that creates a live process outside the manager/reaper contract.
Daemon bootstrap reconciles that ledger in the background, without blocking startup: dead PIDs are deleted, PID identity mismatches are deleted without killing anything, only positively matched Paseo-owned leftovers are terminated, and a record whose process cannot be inspected is left in place for the next reconcile rather than deleted. Do not add broad process-name sweepers for provider cleanup; cleanup starts from records Paseo previously wrote.
---
@@ -336,14 +342,13 @@ interface AgentClient {
overrides?: Partial<AgentSessionConfig>,
launchContext?: AgentLaunchContext,
): Promise<AgentSession>;
listModels(options: ListModelsOptions): Promise<AgentModelDefinition[]>;
fetchCatalog(options: FetchCatalogOptions): Promise<ProviderCatalog>;
isAvailable(): Promise<boolean>;
// Optional:
listModes?(options: ListModesOptions): Promise<AgentMode[]>;
listImportableSessions?(
listImportableSessions(
options?: ListImportableSessionsOptions,
): Promise<ImportableProviderSession[]>;
importSession?(
importSession(
input: ImportProviderSessionInput,
context: ImportProviderSessionContext,
): Promise<ImportedProviderSession>;

View File

@@ -13,6 +13,15 @@ Each domain becomes a controller class in its own file with the **exact** contra
Session shrinks to a connection/dispatch shell: it keeps `handleMessage`, the `??` chain (1739-1751), `emit`/`emitBinary`, `sessionLogger`, connection identity, inflight metrics, lifecycle intents, and the **ordered** `cleanup()`. Each `dispatchXMessage` collapses to `return this.xController.dispatch(msg)`.
## Progress (shipped — diverged from the original filenames)
The first carves shipped as **deep modules with a narrow Host seam**, not the `dispatch(msg)`-owned-set controllers sketched below: `session.ts` keeps each `dispatchXMessage` switch and delegates per case to the subsystem. Home convention that emerged: session subsystems live at **`session/<domain>/`**, with `session.ts` as the orchestrator shell.
- **#1640 — VoiceSession** (`session/voice/voice-session.ts`, seam `VoiceSessionHost`): the STT/TTS/dictation/turn-detection subsystem. _(Originally landed at `server/voice/`; relocated under `session/` so all session subsystems share one home.)_
- **#1644 — CheckoutSession, read side** (`session/checkout/checkout-session.ts`, seam `CheckoutSessionHost`, port `CheckoutDiffSubscriber`): status, branch validate/suggest, diff subscribe/unsubscribe, manual refresh. The workspace-git observer already delegates `emitStatusUpdate`/`scheduleDiffRefresh` to it.
**Next carve — CheckoutSession mutation side (Slice 4 below).** The 17 checkout _write_ handlers still inline in `session.ts` (`dispatchCheckoutMessage`, ~2010) — branch switch/rename, commit, merge, merge-from-base, pull, push, PR create/merge, github set-auto-merge/get-check-details, PR status, PR timeline, github search, stash save/pop/list — move into the existing `session/checkout/checkout-session.ts` behind `CheckoutSessionHost`. The Slice-3 observer entanglement the table fears is already resolved: #1644 moved the status/diff read side into CheckoutSession, so the workspace observer delegates today and this no longer blocks on the WorkspaceController split.
### Why this is safe at the dispatch seam (verified)
`dispatchInboundMessage` builds `a() ?? b() ?? ... ?? dispatchMiscMessage()` and short-circuits on the first non-`undefined` **Promise object** (not its resolved value). Message-type spaces are **disjoint** (no duplicate `case` labels across switches), so at most one dispatcher matches any message — collapsing to delegation cannot change which handler runs. `dispatchTerminalMessage` (2150-2153) already proves this. Two quirks preserved verbatim: schedule/\* is reached via the chat dispatcher's OWN `default` arm (2183), not the top-level `??`; and `start_workspace_script_request` (a workspace type) is special-cased before terminal delegation (2150).
@@ -117,7 +126,7 @@ In-place, separately reviewable. Split the `audio_output` TTS-debug branch out o
## Slice 7 — VoiceSessionController (XL)
**Move:** voice handlers + ~25 voice fields + the TTS-debug hook (Slice 6) + `voiceModeAgentId`/`isVoiceMode` + the `shouldAutoAllowVoicePermission` predicate (Slice 3) → `packages/server/src/server/voice/voice-session-controller.ts`. Carve voice types out of `dispatchVoiceAndControlMessage`, leaving infra (restart/shutdown/heartbeat/ping/abort) on the shell.
**Move:** voice handlers + ~25 voice fields + the TTS-debug hook (Slice 6) + `voiceModeAgentId`/`isVoiceMode` + the `shouldAutoAllowVoicePermission` predicate (Slice 3) → the existing `packages/server/src/server/session/voice/voice-session.ts` (see Progress above). Carve voice types out of `dispatchVoiceAndControlMessage`, leaving infra (restart/shutdown/heartbeat/ping/abort) on the shell.
**SessionContext surface:** pure `emit`, `emitBinary`, `hasBinaryChannel`, `sessionLogger`/`sessionId`/`paseoHome`, `getSpeechReadiness`, agent-control port `{ loadAgent, reloadWithSystemPrompt, interruptIfRunning, isRunning, sendSpokenText, buildAgentPrompt }`, `getSignal`/`abortCurrent` (Slice 6).

View File

@@ -1 +1 @@
sha256-oJSIAxDUwa/MXkCfKuQR6Owb6/YykAYP/mRKVCGK+fQ=
sha256-c3FItM+qFwZ/B21jOJ0W33LFZn1LUk4qNRbBekVT5vU=

42
package-lock.json generated
View File

@@ -1,12 +1,12 @@
{
"name": "paseo",
"version": "0.1.98",
"version": "0.1.100",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "paseo",
"version": "0.1.98",
"version": "0.1.100",
"hasInstallScript": true,
"license": "AGPL-3.0-or-later",
"workspaces": [
@@ -35243,7 +35243,7 @@
},
"packages/app": {
"name": "@getpaseo/app",
"version": "0.1.98",
"version": "0.1.100",
"dependencies": {
"@dnd-kit/core": "^6.3.1",
"@dnd-kit/sortable": "^10.0.0",
@@ -35566,12 +35566,12 @@
},
"packages/cli": {
"name": "@getpaseo/cli",
"version": "0.1.98",
"version": "0.1.100",
"dependencies": {
"@clack/prompts": "^1.0.0",
"@getpaseo/client": "0.1.98",
"@getpaseo/protocol": "0.1.98",
"@getpaseo/server": "0.1.98",
"@getpaseo/client": "0.1.100",
"@getpaseo/protocol": "0.1.100",
"@getpaseo/server": "0.1.100",
"chalk": "^5.3.0",
"commander": "^12.0.0",
"mime-types": "^2.1.35",
@@ -35817,10 +35817,10 @@
},
"packages/client": {
"name": "@getpaseo/client",
"version": "0.1.98",
"version": "0.1.100",
"dependencies": {
"@getpaseo/protocol": "0.1.98",
"@getpaseo/relay": "0.1.98",
"@getpaseo/protocol": "0.1.100",
"@getpaseo/relay": "0.1.100",
"zod": "^4.4.3"
},
"devDependencies": {
@@ -35831,7 +35831,7 @@
},
"packages/desktop": {
"name": "@getpaseo/desktop",
"version": "0.1.98",
"version": "0.1.100",
"license": "AGPL-3.0-or-later",
"dependencies": {
"@getpaseo/cli": "*",
@@ -36074,7 +36074,7 @@
},
"packages/expo-two-way-audio": {
"name": "@getpaseo/expo-two-way-audio",
"version": "0.1.98",
"version": "0.1.100",
"license": "MIT",
"devDependencies": {
"@types/jest": "^29.5.14",
@@ -36970,7 +36970,7 @@
},
"packages/highlight": {
"name": "@getpaseo/highlight",
"version": "0.1.98",
"version": "0.1.100",
"dependencies": {
"@codemirror/language": "^6.12.3",
"@codemirror/legacy-modes": "^6.5.3",
@@ -37201,7 +37201,7 @@
},
"packages/protocol": {
"name": "@getpaseo/protocol",
"version": "0.1.98",
"version": "0.1.100",
"dependencies": {
"zod": "^4.4.3"
},
@@ -37213,7 +37213,7 @@
},
"packages/relay": {
"name": "@getpaseo/relay",
"version": "0.1.98",
"version": "0.1.100",
"dependencies": {
"base64-js": "^1.5.1",
"tweetnacl": "^1.0.3",
@@ -37431,15 +37431,15 @@
},
"packages/server": {
"name": "@getpaseo/server",
"version": "0.1.98",
"version": "0.1.100",
"dependencies": {
"@agentclientprotocol/sdk": "^0.17.1",
"@anthropic-ai/claude-agent-sdk": "^0.3.181",
"@anthropic-ai/sdk": "^0.104.2",
"@getpaseo/client": "0.1.98",
"@getpaseo/highlight": "0.1.98",
"@getpaseo/protocol": "0.1.98",
"@getpaseo/relay": "0.1.98",
"@getpaseo/client": "0.1.100",
"@getpaseo/highlight": "0.1.100",
"@getpaseo/protocol": "0.1.100",
"@getpaseo/relay": "0.1.100",
"@isaacs/ttlcache": "^2.1.4",
"@modelcontextprotocol/sdk": "^1.20.1",
"@opencode-ai/sdk": "1.14.46",
@@ -37848,7 +37848,7 @@
},
"packages/website": {
"name": "@getpaseo/website",
"version": "0.1.98",
"version": "0.1.100",
"dependencies": {
"@cloudflare/vite-plugin": "^1.29.1",
"@cloudflare/workers-types": "^4.20260317.1",

View File

@@ -1,6 +1,6 @@
{
"name": "paseo",
"version": "0.1.98",
"version": "0.1.100",
"private": true,
"description": "Paseo: voice-controlled development environment with OpenAI Realtime API",
"keywords": [

View File

@@ -228,6 +228,7 @@ test("changes diff keeps unwrapped gutter and code rows aligned after code size
await changeCodeFontSizeFromSettings(page, 18);
await returnToWorkspaceChanges(page);
await expectStoredCodeFontSize(page, 18);
await scrollToLowerUnwrappedDiffRows(page);
await expectDiffCodeFontSize(page, 18);
@@ -238,6 +239,9 @@ test("changes diff keeps unwrapped gutter and code rows aligned after code size
async function useCodeFont(page: Page, codeFontSize: number): Promise<void> {
await page.addInitScript(
({ settingsKey, fontSize }) => {
if (localStorage.getItem(settingsKey)) {
return;
}
localStorage.setItem(
settingsKey,
JSON.stringify({
@@ -270,10 +274,13 @@ async function useUnwrappedDiffLines(page: Page): Promise<void> {
}
async function expectDiffCodeFontSize(page: Page, fontSize: number): Promise<void> {
const actualFontSize = await page
.getByTestId("diff-code-text-1")
.evaluate((text) => Number.parseFloat(getComputedStyle(text).fontSize));
expect(actualFontSize).toBe(fontSize);
await expect
.poll(async () => {
return page
.getByTestId("diff-code-text-1")
.evaluate((text) => Number.parseFloat(getComputedStyle(text).fontSize));
})
.toBe(fontSize);
}
async function expectVisibleDiffRowsAligned(page: Page): Promise<void> {
@@ -400,6 +407,22 @@ async function changeCodeFontSizeFromSettings(page: Page, codeFontSize: number):
await page.getByLabel("Code font size").fill(String(codeFontSize));
await page.getByLabel("Code font size").press("Enter");
await expect(page.getByLabel("Code font size")).toHaveValue(String(codeFontSize));
await expectStoredCodeFontSize(page, codeFontSize);
}
async function expectStoredCodeFontSize(page: Page, codeFontSize: number): Promise<void> {
await expect
.poll(async () => {
const raw = await page.evaluate(
(settingsKey) => localStorage.getItem(settingsKey),
APP_SETTINGS_KEY,
);
if (!raw) {
return null;
}
return (JSON.parse(raw) as { codeFontSize?: number }).codeFontSize ?? null;
})
.toBe(codeFontSize);
}
async function returnToWorkspaceChanges(page: Page): Promise<void> {

View File

@@ -1,6 +1,6 @@
{
"name": "@getpaseo/app",
"version": "0.1.98",
"version": "0.1.100",
"private": true,
"main": "index.ts",
"scripts": {

View File

@@ -28,6 +28,7 @@ import { DownloadToast } from "@/components/download-toast";
import { QuittingOverlay } from "@/components/quitting-overlay";
import { KeyboardShortcutsDialog } from "@/components/keyboard-shortcuts-dialog";
import { LeftSidebar } from "@/components/left-sidebar";
import { CompactExplorerSidebarHost } from "@/components/compact-explorer-sidebar-host";
import { ProjectPickerModal } from "@/components/project-picker-modal";
import { ProviderSettingsHost } from "@/components/provider-settings-host";
import { WorkspaceSetupDialog } from "@/components/workspace-setup-dialog";
@@ -40,6 +41,7 @@ import {
useHorizontalScrollOptional,
} from "@/contexts/horizontal-scroll-context";
import { SessionProvider } from "@/contexts/session-context";
import { ExplorerSidebarAnimationProvider } from "@/contexts/explorer-sidebar-animation-context";
import {
SidebarAnimationProvider,
useSidebarAnimation,
@@ -54,7 +56,7 @@ import {
startDaemonIfGateAllows,
startHostRuntimeBootstrap,
type StartupBlocker,
} from "@/app/host-runtime-bootstrap";
} from "@/navigation/host-runtime-bootstrap";
import { shouldUseDesktopDaemon } from "@/desktop/daemon/desktop-daemon";
import { listenToDesktopEvent } from "@/desktop/electron/events";
import { updateDesktopWindowControls } from "@/desktop/electron/window";
@@ -465,14 +467,26 @@ function AppContainer({
useActiveWorktreeNewAction();
useGlobalNewWorkspaceAction();
const workspaceChrome = (
<View style={rowStyle}>
{!isCompactLayout && chromeEnabled && !isFocusModeEnabled && (
<LeftSidebar selectedAgentId={selectedAgentId} />
)}
{isCompactLayout && chromeEnabled ? (
<ExplorerSidebarAnimationProvider>
<CompactExplorerSidebarHost enabled={chromeEnabled}>
<View style={flexStyle}>{children}</View>
</CompactExplorerSidebarHost>
</ExplorerSidebarAnimationProvider>
) : (
<View style={flexStyle}>{children}</View>
)}
</View>
);
const content = (
<View style={layoutStyles.surfaceFill}>
<View style={rowStyle}>
{!isCompactLayout && chromeEnabled && !isFocusModeEnabled && (
<LeftSidebar selectedAgentId={selectedAgentId} />
)}
<View style={flexStyle}>{children}</View>
</View>
{workspaceChrome}
<FloatingPanelPortalHost />
{isCompactLayout && chromeEnabled && <LeftSidebar selectedAgentId={selectedAgentId} />}
<DownloadToast />
@@ -863,8 +877,6 @@ function FaviconStatusSync() {
return null;
}
const AGENT_SCREEN_OPTIONS = { gestureEnabled: false };
function RootStack() {
const storeReady = useStoreReady();
const { theme } = useUnistyles();
@@ -889,19 +901,7 @@ function RootStack() {
<Stack.Screen name="settings/projects/[projectKey]" />
<Stack.Screen name="pair-scan" />
</Stack.Protected>
{/*
Do not add getId or dangerouslySingular back to the workspace route.
Expo Router maps dangerouslySingular to React Navigation getId, and
getId repeatedly breaks Android native-stack/Fabric by reordering an
already-mounted workspace screen. Keep workspace identity/retention
outside this route-level native-stack API.
*/}
<Stack.Screen name="h/[serverId]/workspace/[workspaceId]" />
<Stack.Screen name="h/[serverId]/agent/[agentId]" options={AGENT_SCREEN_OPTIONS} />
<Stack.Screen name="h/[serverId]/index" />
<Stack.Screen name="h/[serverId]/sessions" />
<Stack.Screen name="h/[serverId]/open-project" />
<Stack.Screen name="h/[serverId]/settings" />
<Stack.Screen name="h/[serverId]" />
<Stack.Screen name="settings/hosts/[serverId]/index" />
<Stack.Screen name="settings/hosts/[serverId]/[hostSection]" />
</Stack>

View File

@@ -1,8 +1,16 @@
import { Redirect, Slot, useLocalSearchParams } from "expo-router";
import { Redirect, Stack, useLocalSearchParams } from "expo-router";
import { useHostRuntimeBootstrapState } from "@/app/_layout";
import { resolveStartupRoute } from "@/app/host-runtime-bootstrap";
import { HostRouteProvider } from "@/navigation/host-route-context";
import { resolveStartupRoute } from "@/navigation/host-runtime-bootstrap";
import { useHostRegistryStatus, useHosts } from "@/runtime/host-runtime";
const HOST_STACK_SCREEN_OPTIONS = {
headerShown: false,
animation: "none" as const,
};
const AGENT_SCREEN_OPTIONS = { gestureEnabled: false };
export default function HostRouteLayout() {
return <KnownHostRoute />;
}
@@ -24,8 +32,21 @@ function KnownHostRoute() {
return <Redirect href={startupRoute.href} />;
}
// Keep the host Slot mounted while startup gates are active. React Navigation
// web can reserialize a shallower tree and drop nested workspace URL segments
// if the layout swaps Slot for a splash; leaf routes own the splash boundary.
return <Slot />;
const stack = (
<Stack screenOptions={HOST_STACK_SCREEN_OPTIONS}>
<Stack.Screen name="index" />
<Stack.Screen name="workspace/[workspaceId]/index" />
<Stack.Screen name="agent/[agentId]" options={AGENT_SCREEN_OPTIONS} />
<Stack.Screen name="sessions" />
<Stack.Screen name="open-project" />
<Stack.Screen name="new" />
<Stack.Screen name="settings" />
</Stack>
);
if (!routeServerId) {
return stack;
}
return <HostRouteProvider serverId={routeServerId}>{stack}</HostRouteProvider>;
}

View File

@@ -1,9 +1,39 @@
import { Redirect, useLocalSearchParams } from "expo-router";
import { buildHostOpenProjectRoute } from "@/utils/host-routes";
import { Redirect } from "expo-router";
import { useHostRouteServerId } from "@/navigation/host-route-context";
import {
resolveHostIndexRoute,
resolveWorkspaceSelectionStatus,
} from "@/navigation/host-runtime-bootstrap";
import { StartupSplashScreen } from "@/screens/startup-splash-screen";
import { useHasHydratedWorkspaces, useWorkspaceExists } from "@/stores/session-store-hooks";
import {
useIsLastWorkspaceSelectionHydrated,
useLastWorkspaceSelection,
} from "@/stores/navigation-active-workspace-store";
export default function HostIndexRoute() {
const params = useLocalSearchParams<{ serverId?: string }>();
const serverId = typeof params.serverId === "string" ? params.serverId : "";
if (!serverId) return null;
return <Redirect href={buildHostOpenProjectRoute(serverId)} />;
const serverId = useHostRouteServerId();
const workspaceSelection = useLastWorkspaceSelection();
const isWorkspaceSelectionLoaded = useIsLastWorkspaceSelectionHydrated();
const workspaceSelectionWorkspaceId =
workspaceSelection?.serverId === serverId ? workspaceSelection.workspaceId : null;
const hasHydratedWorkspaces = useHasHydratedWorkspaces(serverId);
const workspaceSelectionExists = useWorkspaceExists(serverId, workspaceSelectionWorkspaceId);
if (!serverId || !isWorkspaceSelectionLoaded) {
return <StartupSplashScreen />;
}
return (
<Redirect
href={resolveHostIndexRoute({
serverId,
workspaceSelection,
workspaceSelectionStatus: resolveWorkspaceSelectionStatus({
hasHydratedWorkspaces,
workspaceExists: workspaceSelectionExists,
}),
})}
/>
);
}

View File

@@ -2,8 +2,12 @@ import React from "react";
import { Redirect, usePathname } from "expo-router";
import { StartupSplashScreen } from "@/screens/startup-splash-screen";
import { useEarliestOnlineHostServerId, useHostRuntimeBootstrapState } from "@/app/_layout";
import { resolveStartupRoute } from "@/app/host-runtime-bootstrap";
import {
resolveStartupRoute,
resolveWorkspaceSelectionStatus,
} from "@/navigation/host-runtime-bootstrap";
import { useHostRegistryStatus, useHosts } from "@/runtime/host-runtime";
import { useHasHydratedWorkspaces, useWorkspaceExists } from "@/stores/session-store-hooks";
import {
useIsLastWorkspaceSelectionHydrated,
useLastWorkspaceSelection,
@@ -20,6 +24,13 @@ export default function Index() {
const hostRegistryStatus = useHostRegistryStatus();
const workspaceSelection = useLastWorkspaceSelection();
const isWorkspaceSelectionLoaded = useIsLastWorkspaceSelectionHydrated();
const workspaceSelectionServerId = workspaceSelection?.serverId ?? null;
const workspaceSelectionWorkspaceId = workspaceSelection?.workspaceId ?? null;
const hasHydratedWorkspaceSelectionHost = useHasHydratedWorkspaces(workspaceSelectionServerId);
const workspaceSelectionExists = useWorkspaceExists(
workspaceSelectionServerId,
workspaceSelectionWorkspaceId,
);
const startupRoute = resolveStartupRoute({
route: { kind: "index", pathname },
@@ -28,6 +39,10 @@ export default function Index() {
hosts,
anyOnlineHostServerId,
workspaceSelection,
workspaceSelectionStatus: resolveWorkspaceSelectionStatus({
hasHydratedWorkspaces: hasHydratedWorkspaceSelectionHost,
workspaceExists: workspaceSelectionExists,
}),
isWorkspaceSelectionLoaded,
hasGivenUpWaitingForHost: bootstrapState.hasGivenUpWaitingForHost,
});

View File

@@ -0,0 +1,106 @@
import { describe, expect, it } from "vitest";
import {
resolveCompactExplorerSidebarHostModel,
type CompactExplorerSidebarHostModel,
} from "@/components/compact-explorer-sidebar-host-state";
import type { WorkspaceDescriptor } from "@/stores/session-store";
function createWorkspace(
input: Partial<WorkspaceDescriptor> & Pick<WorkspaceDescriptor, "id">,
): WorkspaceDescriptor {
return {
id: input.id,
projectId: input.projectId ?? "project-1",
projectDisplayName: input.projectDisplayName ?? "Project 1",
projectRootPath: input.projectRootPath ?? "/repo",
workspaceDirectory: input.workspaceDirectory ?? "/repo",
projectKind: input.projectKind ?? "git",
workspaceKind: input.workspaceKind ?? "local_checkout",
name: input.name ?? "main",
status: input.status ?? "done",
archivingAt: input.archivingAt ?? null,
statusEnteredAt: null,
diffStat: input.diffStat ?? null,
scripts: input.scripts ?? [],
};
}
function createModel(
overrides: Partial<CompactExplorerSidebarHostModel> = {},
): CompactExplorerSidebarHostModel {
return {
serverId: overrides.serverId ?? "server-1",
workspaceId: overrides.workspaceId ?? "workspace-a",
persistenceKey: overrides.persistenceKey ?? "server-1:workspace-a",
workspaceRoot: overrides.workspaceRoot ?? "/repo/a",
isGit: overrides.isGit ?? true,
};
}
describe("resolveCompactExplorerSidebarHostModel", () => {
it("retains the last workspace root for the same active selection while the workspace reloads", () => {
const previous = createModel();
const result = resolveCompactExplorerSidebarHostModel({
previous,
selection: { serverId: "server-1", workspaceId: "workspace-a" },
workspace: null,
isGit: false,
});
expect(result).toEqual({
serverId: "server-1",
workspaceId: "workspace-a",
persistenceKey: "server-1:workspace-a",
workspaceRoot: "/repo/a",
isGit: true,
});
});
it("switches ownership to the active workspace instead of leaking the previous one", () => {
const previous = createModel();
const result = resolveCompactExplorerSidebarHostModel({
previous,
selection: { serverId: "server-1", workspaceId: "workspace-b" },
workspace: null,
isGit: false,
});
expect(result).toEqual({
serverId: "server-1",
workspaceId: "workspace-b",
persistenceKey: "server-1:workspace-b",
workspaceRoot: "",
isGit: false,
});
});
it("does not retain a previous owner when there is no active workspace selection", () => {
const result = resolveCompactExplorerSidebarHostModel({
previous: createModel(),
selection: null,
workspace: null,
isGit: false,
});
expect(result).toBeNull();
});
it("uses the current workspace directory when it is available", () => {
const result = resolveCompactExplorerSidebarHostModel({
previous: null,
selection: { serverId: "server-1", workspaceId: "workspace-a" },
workspace: createWorkspace({ id: "workspace-a", workspaceDirectory: "/repo/current" }),
isGit: true,
});
expect(result).toEqual({
serverId: "server-1",
workspaceId: "workspace-a",
persistenceKey: "server-1:workspace-a",
workspaceRoot: "/repo/current",
isGit: true,
});
});
});

View File

@@ -0,0 +1,59 @@
import { buildWorkspaceTabPersistenceKey } from "@/stores/workspace-layout-store";
import type { ActiveWorkspaceSelection } from "@/stores/navigation-active-workspace-store";
import type { WorkspaceDescriptor } from "@/stores/session-store";
export interface CompactExplorerSidebarHostModel {
serverId: string;
workspaceId: string;
persistenceKey: string;
workspaceRoot: string;
isGit: boolean;
}
interface ResolveCompactExplorerSidebarHostModelInput {
previous: CompactExplorerSidebarHostModel | null;
selection: ActiveWorkspaceSelection | null;
workspace: WorkspaceDescriptor | null;
isGit: boolean;
}
function trimNonEmpty(value: string | null | undefined): string | null {
if (typeof value !== "string") {
return null;
}
const trimmed = value.trim();
return trimmed.length > 0 ? trimmed : null;
}
export function resolveCompactExplorerSidebarHostModel(
input: ResolveCompactExplorerSidebarHostModelInput,
): CompactExplorerSidebarHostModel | null {
const serverId = trimNonEmpty(input.selection?.serverId);
const workspaceId = trimNonEmpty(input.selection?.workspaceId);
if (!serverId || !workspaceId) {
return null;
}
const persistenceKey = buildWorkspaceTabPersistenceKey({ serverId, workspaceId });
if (!persistenceKey) {
return null;
}
const previousForSelection =
input.previous &&
input.previous.serverId === serverId &&
input.previous.workspaceId === workspaceId
? input.previous
: null;
return {
serverId,
workspaceId,
persistenceKey,
workspaceRoot:
trimNonEmpty(input.workspace?.workspaceDirectory) ??
previousForSelection?.workspaceRoot ??
"",
isGit: input.workspace ? input.isGit : (previousForSelection?.isGit ?? input.isGit),
};
}

View File

@@ -0,0 +1,162 @@
import { type ReactNode, useCallback, useEffect, useMemo, useRef } from "react";
import { View } from "react-native";
import { GestureDetector } from "react-native-gesture-handler";
import { useActiveWorkspaceSelection } from "@/stores/navigation-active-workspace-store";
import { useWorkspace } from "@/stores/session-store-hooks";
import { CompactExplorerSidebar } from "@/components/explorer-sidebar";
import { useExplorerOpenGesture } from "@/hooks/use-explorer-open-gesture";
import { useHostRuntimeClient, useHostRuntimeIsConnected } from "@/runtime/host-runtime";
import { selectIsFileExplorerOpen, usePanelStore } from "@/stores/panel-store";
import { useWorkspaceLayoutStore } from "@/stores/workspace-layout-store";
import { useWorkspaceCheckoutStatus } from "@/screens/workspace/use-workspace-checkout-status";
import { openWorkspaceFileFromExplorer } from "@/screens/workspace/workspace-file-open-command";
import { isWeb } from "@/constants/platform";
import {
resolveCompactExplorerSidebarHostModel,
type CompactExplorerSidebarHostModel,
} from "@/components/compact-explorer-sidebar-host-state";
interface CompactExplorerOpenGestureSurfaceProps {
children: ReactNode;
enabled: boolean;
onOpenExplorer: () => void;
}
const COMPACT_WEB_GESTURE_TOUCH_ACTION = isWeb ? "auto" : "pan-y";
function CompactExplorerOpenGestureSurface({
children,
enabled,
onOpenExplorer,
}: CompactExplorerOpenGestureSurfaceProps) {
const explorerOpenGesture = useExplorerOpenGesture({
enabled,
onOpen: onOpenExplorer,
});
return (
<GestureDetector gesture={explorerOpenGesture} touchAction={COMPACT_WEB_GESTURE_TOUCH_ACTION}>
<View style={styles.fill}>{children}</View>
</GestureDetector>
);
}
function useActiveCompactExplorerSidebarModel(
enabled: boolean,
): CompactExplorerSidebarHostModel | null {
const selection = useActiveWorkspaceSelection();
const workspace = useWorkspace(selection?.serverId ?? null, selection?.workspaceId ?? null);
const isExplorerOpen = usePanelStore((state) =>
selectIsFileExplorerOpen(state, { isCompact: true }),
);
const showMobileAgent = usePanelStore((state) => state.showMobileAgent);
const client = useHostRuntimeClient(selection?.serverId ?? "");
const isConnected = useHostRuntimeIsConnected(selection?.serverId ?? "");
const retainedModelRef = useRef<CompactExplorerSidebarHostModel | null>(null);
const { checkoutQuery } = useWorkspaceCheckoutStatus({
client,
isConnected,
isRouteFocused: enabled && selection !== null,
normalizedServerId: selection?.serverId ?? "",
normalizedWorkspaceId: selection?.workspaceId ?? "",
workspaceDirectory: workspace?.workspaceDirectory || null,
});
const resolvedModel = useMemo(
() =>
resolveCompactExplorerSidebarHostModel({
previous: isExplorerOpen ? retainedModelRef.current : null,
selection,
workspace,
isGit: checkoutQuery.data?.isGit ?? false,
}),
[checkoutQuery.data?.isGit, isExplorerOpen, selection, workspace],
);
useEffect(() => {
if (!selection) {
retainedModelRef.current = null;
if (enabled && isExplorerOpen) {
showMobileAgent();
}
return;
}
if (!isExplorerOpen) {
retainedModelRef.current = null;
return;
}
if (resolvedModel) {
retainedModelRef.current = resolvedModel;
}
}, [enabled, isExplorerOpen, resolvedModel, selection, showMobileAgent]);
return selection ? (resolvedModel ?? (isExplorerOpen ? retainedModelRef.current : null)) : null;
}
interface CompactExplorerSidebarHostProps {
children: ReactNode;
enabled: boolean;
}
export function CompactExplorerSidebarHost({ children, enabled }: CompactExplorerSidebarHostProps) {
const model = useActiveCompactExplorerSidebarModel(enabled);
const openFileExplorerForCheckout = usePanelStore((state) => state.openFileExplorerForCheckout);
const showMobileAgent = usePanelStore((state) => state.showMobileAgent);
const openWorkspaceTabFocused = useWorkspaceLayoutStore((state) => state.openTabFocused);
const focusWorkspaceTab = useWorkspaceLayoutStore((state) => state.focusTab);
const handleOpenExplorer = useCallback(() => {
if (!model?.workspaceRoot) {
return;
}
openFileExplorerForCheckout({
isCompact: true,
checkout: {
serverId: model.serverId,
cwd: model.workspaceRoot,
isGit: model.isGit,
},
});
}, [model, openFileExplorerForCheckout]);
const handleOpenFile = useCallback(
(filePath: string) => {
if (!model) {
return;
}
openWorkspaceFileFromExplorer({
filePath,
persistenceKey: model.persistenceKey,
showMobileAgent,
openWorkspaceTabFocused,
focusWorkspaceTab,
});
},
[focusWorkspaceTab, model, openWorkspaceTabFocused, showMobileAgent],
);
return (
<>
<CompactExplorerOpenGestureSurface
enabled={enabled && Boolean(model?.workspaceRoot)}
onOpenExplorer={handleOpenExplorer}
>
{children}
</CompactExplorerOpenGestureSurface>
{enabled && model ? (
<CompactExplorerSidebar
serverId={model.serverId}
workspaceId={model.workspaceId}
workspaceRoot={model.workspaceRoot}
isGit={model.isGit}
onOpenFile={handleOpenFile}
/>
) : null}
</>
);
}
const styles = {
fill: {
flex: 1,
},
} as const;

View File

@@ -7,7 +7,6 @@ import {
StyleSheet as RNStyleSheet,
} from "react-native";
import { useSafeAreaInsets } from "react-native-safe-area-context";
import { useIsFocused } from "@react-navigation/native";
import Animated, { useAnimatedStyle, useSharedValue, runOnJS } from "react-native-reanimated";
import { Gesture, GestureDetector } from "react-native-gesture-handler";
import { StyleSheet, useUnistyles } from "react-native-unistyles";
@@ -16,9 +15,13 @@ import { useTranslation } from "react-i18next";
import {
formatPrTabLabel,
PullRequestPane,
PullRequestPaneError,
PullRequestPaneSkeleton,
PullRequestTabIcon,
usePrPaneData,
} from "@/git/pull-request-panel";
import { useCheckoutGitActionsStore } from "@/git/actions-store";
import type { UsePrPaneDataResult } from "@/git/pull-request-panel/use-data";
import {
usePanelStore,
selectIsFileExplorerOpen,
@@ -28,8 +31,9 @@ import {
} from "@/stores/panel-store";
import { useExplorerSidebarAnimation } from "@/contexts/explorer-sidebar-animation-context";
import { useSidebarAnimation } from "@/contexts/sidebar-animation-context";
import { useToast } from "@/contexts/toast-context";
import { canCloseRightSidebarGesture } from "@/utils/sidebar-animation-state";
import { HEADER_INNER_HEIGHT, useIsCompactFormFactor } from "@/constants/layout";
import { HEADER_INNER_HEIGHT } from "@/constants/layout";
import { GitDiffPane } from "@/git/diff-pane";
import { FileExplorerPane } from "./file-explorer-pane";
import { useKeyboardShiftStyle } from "@/hooks/use-keyboard-shift-style";
@@ -49,7 +53,29 @@ interface ExplorerSidebarProps {
onOpenFile?: (filePath: string) => void;
}
export function ExplorerSidebar({
interface ExplorerSidebarSharedState {
explorerTab: ExplorerTab;
handleTabPress: (tab: ExplorerTab) => void;
}
function useExplorerSidebarSharedState({
serverId,
workspaceRoot,
isGit,
}: Pick<ExplorerSidebarProps, "serverId" | "workspaceRoot" | "isGit">): ExplorerSidebarSharedState {
const explorerTab = usePanelStore((state) => state.explorerTab);
const setExplorerTabForCheckout = usePanelStore((state) => state.setExplorerTabForCheckout);
const handleTabPress = useCallback(
(tab: ExplorerTab) => {
setExplorerTabForCheckout({ serverId, cwd: workspaceRoot, isGit, tab });
},
[isGit, serverId, setExplorerTabForCheckout, workspaceRoot],
);
return { explorerTab, handleTabPress };
}
export function CompactExplorerSidebar({
serverId,
workspaceId,
workspaceRoot,
@@ -57,40 +83,22 @@ export function ExplorerSidebar({
onOpenFile,
}: ExplorerSidebarProps) {
const { theme } = useUnistyles();
const isScreenFocused = useIsFocused();
const insets = useSafeAreaInsets();
const isMobile = useIsCompactFormFactor();
const isOpen = usePanelStore((state) => selectIsFileExplorerOpen(state, { isCompact: isMobile }));
const isOpen = usePanelStore((state) => selectIsFileExplorerOpen(state, { isCompact: true }));
const showMobileAgent = usePanelStore((state) => state.showMobileAgent);
const closeDesktopFileExplorer = usePanelStore((state) => state.closeDesktopFileExplorer);
const explorerTab = usePanelStore((state) => state.explorerTab);
const explorerWidth = usePanelStore((state) => state.explorerWidth);
const setExplorerTabForCheckout = usePanelStore((state) => state.setExplorerTabForCheckout);
const setExplorerWidth = usePanelStore((state) => state.setExplorerWidth);
const { width: viewportWidth } = useWindowDimensions();
const { explorerTab, handleTabPress } = useExplorerSidebarSharedState({
serverId,
workspaceRoot,
isGit,
});
const closeTouchStartX = useSharedValue(0);
const closeTouchStartY = useSharedValue(0);
const { mobilePanelState, gestureAnimatingRef: mobilePanelGestureAnimatingRef } =
useSidebarAnimation();
const { style: mobileKeyboardInsetStyle } = useKeyboardShiftStyle({
mode: "padding",
enabled: isMobile,
enabled: true,
});
useEffect(() => {
if (isMobile) {
return;
}
const maxWidth = Math.max(
MIN_EXPLORER_SIDEBAR_WIDTH,
Math.min(MAX_EXPLORER_SIDEBAR_WIDTH, viewportWidth - MIN_CHAT_WIDTH),
);
if (explorerWidth > maxWidth) {
setExplorerWidth(maxWidth);
}
}, [explorerWidth, isMobile, setExplorerWidth, viewportWidth]);
const {
translateX,
backdropOpacity,
@@ -103,23 +111,15 @@ export function ExplorerSidebar({
closeGestureRef,
} = useExplorerSidebarAnimation();
// For resize drag, track the starting width
const startWidthRef = useRef(explorerWidth);
const resizeWidth = useSharedValue(explorerWidth);
const handleClose = useCallback(
(reason: string) => {
logExplorerSidebar("handleClose", {
reason,
isOpen,
});
if (isMobile) {
showMobileAgent();
return;
}
closeDesktopFileExplorer();
showMobileAgent();
},
[closeDesktopFileExplorer, isMobile, isOpen, showMobileAgent],
[isOpen, showMobileAgent],
);
const handleCloseFromGesture = useCallback(() => {
@@ -128,24 +128,14 @@ export function ExplorerSidebar({
showMobileAgent();
}, [gestureAnimatingRef, mobilePanelGestureAnimatingRef, showMobileAgent]);
const enableSidebarCloseGesture = isMobile;
const handleTabPress = useCallback(
(tab: ExplorerTab) => {
setExplorerTabForCheckout({ serverId, cwd: workspaceRoot, isGit, tab });
},
[isGit, serverId, setExplorerTabForCheckout, workspaceRoot],
);
const handleHeaderClose = useCallback(() => handleClose("header-close-button"), [handleClose]);
const handleDesktopClose = useCallback(() => handleClose("desktop-close-button"), [handleClose]);
// Swipe gesture to close (swipe right on mobile)
const closeGesture = useMemo(
() =>
Gesture.Pan()
.withRef(closeGestureRef)
.enabled(enableSidebarCloseGesture)
.enabled(true)
// Use manual activation so child views keep touch streams
// unless we detect an intentional right-swipe close.
.manualActivation(true)
@@ -219,7 +209,6 @@ export function ExplorerSidebar({
isGesturing.value = false;
}),
[
enableSidebarCloseGesture,
windowWidth,
translateX,
backdropOpacity,
@@ -234,32 +223,6 @@ export function ExplorerSidebar({
],
);
// Desktop resize gesture (drag left edge)
const resizeGesture = useMemo(
() =>
Gesture.Pan()
.enabled(!isMobile)
.hitSlop({ left: 8, right: 8, top: 0, bottom: 0 })
.onStart(() => {
startWidthRef.current = explorerWidth;
resizeWidth.value = explorerWidth;
})
.onUpdate((event) => {
// Dragging left (negative translationX) increases width
const newWidth = startWidthRef.current - event.translationX;
const maxWidth = Math.max(
MIN_EXPLORER_SIDEBAR_WIDTH,
Math.min(MAX_EXPLORER_SIDEBAR_WIDTH, viewportWidth - MIN_CHAT_WIDTH),
);
const clampedWidth = Math.max(MIN_EXPLORER_SIDEBAR_WIDTH, Math.min(maxWidth, newWidth));
resizeWidth.value = clampedWidth;
})
.onEnd(() => {
runOnJS(setExplorerWidth)(resizeWidth.value);
}),
[isMobile, explorerWidth, resizeWidth, setExplorerWidth, viewportWidth],
);
const sidebarAnimatedStyle = useAnimatedStyle(() => ({
transform: [{ translateX: translateX.value }],
}));
@@ -268,10 +231,6 @@ export function ExplorerSidebar({
opacity: backdropOpacity.value,
}));
const resizeAnimatedStyle = useAnimatedStyle(() => ({
width: resizeWidth.value,
}));
const backdropCombinedStyle = useMemo(
() => [
explorerStaticStyles.backdrop,
@@ -312,10 +271,6 @@ export function ExplorerSidebar({
],
[overlayVisible],
);
const desktopSidebarStyle = useMemo(
() => [explorerStaticStyles.desktopSidebar, resizeAnimatedStyle, { paddingTop: insets.top }],
[resizeAnimatedStyle, insets.top],
);
// Mobile: full-screen overlay with gesture.
// On web, keep it interactive only while open so closed sidebars don't eat taps.
@@ -324,39 +279,101 @@ export function ExplorerSidebar({
else if (isOpen) overlayPointerEvents = "auto";
else overlayPointerEvents = "none";
// Navigation stacks can keep previous screens mounted; hide sidebars for unfocused
// screens so only the active screen exposes explorer/terminal surfaces.
if (!isScreenFocused) {
return null;
}
return (
<View style={overlayStyle} pointerEvents={overlayPointerEvents}>
<Animated.View style={backdropCombinedStyle} />
if (isMobile) {
return (
<View style={overlayStyle} pointerEvents={overlayPointerEvents}>
{/* Backdrop */}
<Animated.View style={backdropCombinedStyle} />
<GestureDetector gesture={closeGesture} touchAction="pan-y">
<Animated.View style={mobileSidebarStyle} pointerEvents="auto">
<ExplorerSidebarContent
activeTab={explorerTab}
onTabPress={handleTabPress}
onClose={handleHeaderClose}
serverId={serverId}
workspaceId={workspaceId}
workspaceRoot={workspaceRoot}
isGit={isGit}
isMobile
isOpen={isOpen}
onOpenFile={onOpenFile}
/>
</Animated.View>
</GestureDetector>
</View>
);
}
<GestureDetector gesture={closeGesture} touchAction="pan-y">
<Animated.View style={mobileSidebarStyle} pointerEvents="auto">
<SidebarContent
activeTab={explorerTab}
onTabPress={handleTabPress}
onClose={handleHeaderClose}
serverId={serverId}
workspaceId={workspaceId}
workspaceRoot={workspaceRoot}
isGit={isGit}
isMobile={isMobile}
isOpen={isOpen}
onOpenFile={onOpenFile}
/>
</Animated.View>
</GestureDetector>
</View>
export function ExplorerSidebar({
serverId,
workspaceId,
workspaceRoot,
isGit,
onOpenFile,
}: ExplorerSidebarProps) {
const insets = useSafeAreaInsets();
const explorerWidth = usePanelStore((state) => state.explorerWidth);
const setExplorerWidth = usePanelStore((state) => state.setExplorerWidth);
const isOpen = usePanelStore((state) => selectIsFileExplorerOpen(state, { isCompact: false }));
const closeDesktopFileExplorer = usePanelStore((state) => state.closeDesktopFileExplorer);
const { explorerTab, handleTabPress } = useExplorerSidebarSharedState({
serverId,
workspaceRoot,
isGit,
});
const { width: viewportWidth } = useWindowDimensions();
const startWidthRef = useRef(explorerWidth);
const resizeWidth = useSharedValue(explorerWidth);
useEffect(() => {
const maxWidth = Math.max(
MIN_EXPLORER_SIDEBAR_WIDTH,
Math.min(MAX_EXPLORER_SIDEBAR_WIDTH, viewportWidth - MIN_CHAT_WIDTH),
);
}
if (explorerWidth > maxWidth) {
setExplorerWidth(maxWidth);
}
}, [explorerWidth, setExplorerWidth, viewportWidth]);
const handleDesktopClose = useCallback(() => {
logExplorerSidebar("handleClose", {
reason: "desktop-close-button",
isOpen,
});
closeDesktopFileExplorer();
}, [closeDesktopFileExplorer, isOpen]);
const resizeGesture = useMemo(
() =>
Gesture.Pan()
.enabled(true)
.hitSlop({ left: 8, right: 8, top: 0, bottom: 0 })
.onStart(() => {
startWidthRef.current = explorerWidth;
resizeWidth.value = explorerWidth;
})
.onUpdate((event) => {
const newWidth = startWidthRef.current - event.translationX;
const maxWidth = Math.max(
MIN_EXPLORER_SIDEBAR_WIDTH,
Math.min(MAX_EXPLORER_SIDEBAR_WIDTH, viewportWidth - MIN_CHAT_WIDTH),
);
const clampedWidth = Math.max(MIN_EXPLORER_SIDEBAR_WIDTH, Math.min(maxWidth, newWidth));
resizeWidth.value = clampedWidth;
})
.onEnd(() => {
runOnJS(setExplorerWidth)(resizeWidth.value);
}),
[explorerWidth, resizeWidth, setExplorerWidth, viewportWidth],
);
const resizeAnimatedStyle = useAnimatedStyle(() => ({
width: resizeWidth.value,
}));
const desktopSidebarStyle = useMemo(
() => [explorerStaticStyles.desktopSidebar, resizeAnimatedStyle, { paddingTop: insets.top }],
[resizeAnimatedStyle, insets.top],
);
// Desktop: fixed width sidebar with resize handle
if (!isOpen) {
return null;
}
@@ -364,12 +381,11 @@ export function ExplorerSidebar({
return (
<Animated.View style={desktopSidebarStyle}>
<View style={DESKTOP_SIDEBAR_BORDER_STYLE}>
{/* Resize handle - absolutely positioned over left border */}
<GestureDetector gesture={resizeGesture}>
<View style={RESIZE_HANDLE_STYLE} />
</GestureDetector>
<SidebarContent
<ExplorerSidebarContent
activeTab={explorerTab}
onTabPress={handleTabPress}
onClose={handleDesktopClose}
@@ -427,7 +443,7 @@ interface SidebarContentProps {
onOpenFile?: (filePath: string) => void;
}
function SidebarContent({
function ExplorerSidebarContent({
activeTab,
onTabPress,
onClose,
@@ -441,6 +457,7 @@ function SidebarContent({
}: SidebarContentProps) {
const { theme } = useUnistyles();
const { t } = useTranslation();
const toast = useToast();
const padding = useWindowControlsPadding("explorerSidebar");
const canQueryPullRequest = isGit && Boolean(workspaceRoot);
const prPane = usePrPaneData({
@@ -450,11 +467,17 @@ function SidebarContent({
timelineEnabled: activeTab === "pr" && canQueryPullRequest && isOpen,
});
const hasPullRequest = prPane.prNumber !== null;
const showPrTab = hasPullRequest || (activeTab === "pr" && prPane.isLoading);
const requestedTab: ExplorerTab =
!isGit && (activeTab === "changes" || activeTab === "pr") ? "files" : activeTab;
const resolvedTab: ExplorerTab =
requestedTab === "pr" && !hasPullRequest ? "changes" : requestedTab;
const resolvedTab: ExplorerTab = requestedTab === "pr" && !showPrTab ? "changes" : requestedTab;
const prTabLabel = formatPrTabLabel(prPane.prNumber);
const refreshGitActions = useCheckoutGitActionsStore((s) => s.refresh);
const handlePrRetry = useCallback(() => {
refreshGitActions({ serverId, cwd: workspaceRoot }).catch((error) => {
toast.error(error instanceof Error ? error.message : t("workspace.git.diff.failedRefresh"));
});
}, [refreshGitActions, serverId, t, toast, workspaceRoot]);
const workspaceAttachmentScopeKey = useMemo(
() => buildWorkspaceAttachmentScopeKey({ serverId, workspaceId, cwd: workspaceRoot }),
[serverId, workspaceId, workspaceRoot],
@@ -487,7 +510,7 @@ function SidebarContent({
onTabPress={onTabPress}
testID="explorer-tab-files"
/>
{isGit && hasPullRequest && (
{isGit && showPrTab && (
<ExplorerTabButton
tab="pr"
active={resolvedTab === "pr"}
@@ -531,12 +554,13 @@ function SidebarContent({
onOpenFile={onOpenFile}
/>
)}
{resolvedTab === "pr" && prPane.data && (
<PullRequestPane
{resolvedTab === "pr" && (
<PrTabContent
serverId={serverId}
cwd={workspaceRoot}
data={prPane.data}
prPane={prPane}
workspaceAttachmentScopeKey={workspaceAttachmentScopeKey}
onRetry={handlePrRetry}
/>
)}
</View>
@@ -544,6 +568,38 @@ function SidebarContent({
);
}
interface PrTabContentProps {
serverId: string;
cwd: string;
prPane: UsePrPaneDataResult;
workspaceAttachmentScopeKey: string;
onRetry: () => void;
}
function PrTabContent({
serverId,
cwd,
prPane,
workspaceAttachmentScopeKey,
onRetry,
}: PrTabContentProps) {
if (prPane.data) {
return (
<PullRequestPane
serverId={serverId}
cwd={cwd}
data={prPane.data}
activityLoading={prPane.activityLoading}
workspaceAttachmentScopeKey={workspaceAttachmentScopeKey}
/>
);
}
if (prPane.error) {
return <PullRequestPaneError onRetry={onRetry} />;
}
return <PullRequestPaneSkeleton />;
}
// Static styles for Animated.Views — must NOT use Unistyles dynamic theme to
// avoid the "Unable to find node on an unmounted component" crash when Unistyles
// tries to patch the native node that Reanimated also manages.

View File

@@ -600,13 +600,20 @@ export function ProviderDiagnosticSheet({
const modelsRefreshing = isRefreshing || providerSnapshotRefreshing;
const stableDiscoveredRef = useRef<AgentModelDefinition[]>([]);
if (providerEntry?.models && providerEntry.models.length > 0) {
stableDiscoveredRef.current = providerEntry.models;
const currentModels = providerEntry?.models;
if (currentModels && currentModels.length > 0) {
stableDiscoveredRef.current = currentModels;
}
const discoveredModels =
providerEntry?.models && providerEntry.models.length > 0
? providerEntry.models
: stableDiscoveredRef.current;
const discoveredModels = useMemo(() => {
if (currentModels && currentModels.length > 0) {
return currentModels;
}
if (providerSnapshotRefreshing) {
return stableDiscoveredRef.current;
}
return [];
}, [currentModels, providerSnapshotRefreshing]);
const [clockTick, setClockTick] = useState(0);
useEffect(() => {

View File

@@ -16,6 +16,8 @@ import { Bot, ShieldAlert, ShieldCheck, ShieldOff, ShieldQuestionMark } from "lu
import { ComboboxTrigger } from "@/components/ui/combobox-trigger";
import { type SheetHeader } from "@/components/adaptive-modal-sheet";
import { Combobox, ComboboxItem, type ComboboxOption } from "@/components/ui/combobox";
import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip";
import { Shortcut } from "@/components/ui/shortcut";
import { useSessionStore } from "@/stores/session-store";
import { useProvidersSnapshot } from "@/hooks/use-providers-snapshot";
import { mergeProviderPreferences, useFormPreferences } from "@/hooks/use-form-preferences";
@@ -24,7 +26,12 @@ import { useToast } from "@/contexts/toast-context";
import { useIsCompactFormFactor } from "@/constants/layout";
import { toErrorMessage } from "@/utils/error-messages";
import { showProviderNoticeToast } from "@/utils/provider-notice-toast";
import { formatAgentModeLabel } from "@/composer/agent-controls/utils";
import { formatAgentModeLabel, getAgentControlHintKey } from "@/composer/agent-controls/utils";
import { useShortcutKeys } from "@/hooks/use-shortcut-keys";
import { useKeyboardActionHandler } from "@/hooks/use-keyboard-action-handler";
import type { KeyboardActionDefinition } from "@/keyboard/keyboard-action-dispatcher";
import { resolveNextAgentModeId } from "@/composer/agent-controls/mode";
import { useComposerKeyboardScope } from "@/composer/keyboard-scope";
import type { AgentMode, AgentProvider } from "@getpaseo/protocol/agent-types";
import { getModeVisuals, type AgentProviderDefinition } from "@getpaseo/protocol/provider-manifest";
@@ -106,7 +113,10 @@ function AgentModeControlView({
}: AgentModeControlViewProps) {
const { theme } = useUnistyles();
const { t } = useTranslation();
const { isActiveComposer } = useComposerKeyboardScope();
const cycleShortcutKeys = useShortcutKeys("cycle-agent-mode");
const anchorRef = useRef<View>(null);
const keyboardHandlerIdRef = useRef(`mode-control:${Math.random().toString(36).slice(2)}`);
const [open, setOpen] = useState(false);
const [searchQuery, setSearchQuery] = useState("");
@@ -146,6 +156,26 @@ function AgentModeControlView({
[onSelectMode, handleOpenChange],
);
const handleKeyboardAction = useCallback(
(action: KeyboardActionDefinition): boolean => {
if (action.id !== "message-input.mode-cycle") return false;
if (disabled || !isActiveComposer) return false;
const nextModeId = resolveNextAgentModeId({ modeOptions, selectedMode: selectedModeId });
if (!nextModeId) return false;
onSelectMode(nextModeId);
return true;
},
[disabled, isActiveComposer, modeOptions, onSelectMode, selectedModeId],
);
useKeyboardActionHandler({
handlerId: keyboardHandlerIdRef.current,
actions: ["message-input.mode-cycle"],
enabled: isActiveComposer && !disabled && modeOptions.length > 1,
priority: 200,
handle: handleKeyboardAction,
});
const renderOption = useCallback(
(args: {
option: ComboboxOption;
@@ -194,21 +224,31 @@ function AgentModeControlView({
return (
<>
<ComboboxTrigger
ref={anchorRef}
collapsable={false}
disabled={disabled}
onPress={handlePress}
style={pressableStyle}
accessibilityRole="button"
accessibilityLabel={t("agentControls.mode.selectWithValue", {
value: selectedModeLabel,
})}
testID="mode-control"
>
{Icon ? <Icon size={theme.iconSize.md} color={iconColor} /> : null}
<Text style={labelStyle}>{selectedModeLabel}</Text>
</ComboboxTrigger>
<Tooltip delayDuration={0} enabledOnDesktop enabledOnMobile={false}>
<TooltipTrigger asChild triggerRefProp="ref">
<ComboboxTrigger
ref={anchorRef}
collapsable={false}
disabled={disabled}
onPress={handlePress}
style={pressableStyle}
accessibilityRole="button"
accessibilityLabel={t("agentControls.mode.selectWithValue", {
value: selectedModeLabel,
})}
testID="mode-control"
>
{Icon ? <Icon size={theme.iconSize.md} color={iconColor} /> : null}
<Text style={labelStyle}>{selectedModeLabel}</Text>
</ComboboxTrigger>
</TooltipTrigger>
<TooltipContent side="top" align="center" offset={8}>
<View style={styles.tooltipRow}>
<Text style={styles.tooltipText}>{t(getAgentControlHintKey("mode"))}</Text>
{isActiveComposer && cycleShortcutKeys ? <Shortcut chord={cycleShortcutKeys} /> : null}
</View>
</TooltipContent>
</Tooltip>
<Combobox
options={options}
value={selectedMode.id}
@@ -373,4 +413,13 @@ const styles = StyleSheet.create((theme) => ({
fontSize: theme.fontSize.sm,
fontWeight: theme.fontWeight.normal,
},
tooltipRow: {
flexDirection: "row",
alignItems: "center",
gap: theme.spacing[2],
},
tooltipText: {
color: theme.colors.foreground,
fontSize: theme.fontSize.xs,
},
}));

View File

@@ -1,5 +1,14 @@
import { describe, expect, it } from "vitest";
import { resolveAgentControlsMode } from "./mode";
import type { AgentMode } from "@getpaseo/protocol/agent-types";
import { resolveAgentControlsMode, resolveNextAgentModeId } from "./mode";
const PLAN_MODE = { id: "plan", label: "Plan" } satisfies AgentMode;
const MODES = [
PLAN_MODE,
{ id: "build", label: "Build" },
{ id: "full-access", label: "Full Access" },
] satisfies AgentMode[];
describe("resolveAgentControlsMode", () => {
it("uses ready mode when no controlled agent controls are provided", () => {
@@ -29,3 +38,32 @@ describe("resolveAgentControlsMode", () => {
).toBe("draft");
});
});
describe("resolveNextAgentModeId", () => {
it("cycles from the selected mode to the next mode", () => {
expect(resolveNextAgentModeId({ modeOptions: MODES, selectedMode: "build" })).toBe(
"full-access",
);
});
it("wraps from the last mode to the first mode", () => {
expect(resolveNextAgentModeId({ modeOptions: MODES, selectedMode: "full-access" })).toBe(
"plan",
);
});
it("treats an empty selection as the visible first mode", () => {
expect(resolveNextAgentModeId({ modeOptions: MODES, selectedMode: "" })).toBe("build");
});
it("treats a stale selection as the visible first mode", () => {
expect(resolveNextAgentModeId({ modeOptions: MODES, selectedMode: "deleted-mode" })).toBe(
"build",
);
});
it("returns null when there are fewer than two modes", () => {
expect(resolveNextAgentModeId({ modeOptions: [], selectedMode: "" })).toBeNull();
expect(resolveNextAgentModeId({ modeOptions: [PLAN_MODE], selectedMode: "plan" })).toBeNull();
});
});

View File

@@ -1,4 +1,20 @@
import type { DraftAgentControlsProps } from "@/composer/agent-controls";
import type { AgentMode } from "@getpaseo/protocol/agent-types";
export function resolveNextAgentModeId({
modeOptions,
selectedMode,
}: {
modeOptions: readonly AgentMode[];
selectedMode: string | null | undefined;
}): string | null {
if (modeOptions.length < 2) return null;
const selectedIndex = modeOptions.findIndex((mode) => mode.id === selectedMode);
const currentIndex = selectedIndex >= 0 ? selectedIndex : 0;
const nextIndex = (currentIndex + 1) % modeOptions.length;
return modeOptions[nextIndex]?.id ?? null;
}
export function resolveAgentControlsMode(agentControls?: DraftAgentControlsProps) {
return agentControls ? "draft" : "ready";

View File

@@ -89,6 +89,7 @@ import { useKeyboardActionHandler } from "@/hooks/use-keyboard-action-handler";
import type { KeyboardActionDefinition } from "@/keyboard/keyboard-action-dispatcher";
import type { MessageInputKeyboardActionKind } from "@/keyboard/actions";
import { submitAgentInput } from "@/composer/submit";
import { ComposerKeyboardScopeProvider } from "@/composer/keyboard-scope";
import { useAppSettings } from "@/hooks/use-settings";
import { isWeb, isNative } from "@/constants/platform";
import type { GitHubSearchItem } from "@getpaseo/protocol/messages";
@@ -1746,8 +1747,15 @@ export function Composer({
);
const leftContent = useMemo(
() => renderLeftContent({ agentControls, agentId, serverId, focusInput, isCompactLayout }),
[agentId, focusInput, serverId, agentControls, isCompactLayout],
() =>
renderLeftContent({
agentControls,
agentId,
serverId,
focusInput,
isCompactLayout,
}),
[agentControls, agentId, focusInput, isCompactLayout, serverId],
);
const handleAttachButtonRef = useCallback((node: View | null) => {
@@ -1860,90 +1868,92 @@ export function Composer({
const autocompleteVisible = autocomplete.isVisible && isPaneFocused;
return (
<Animated.View style={composerContainerStyle}>
<AttachmentLightbox metadata={lightboxMetadata} onClose={handleLightboxClose} />
{/* Input area */}
<View style={inputAreaContainerStyle}>
<View style={styles.inputAreaContent}>
{queueList}
{sendErrorNode}
<ComposerKeyboardScopeProvider isActiveComposer={isPaneFocused}>
<Animated.View style={composerContainerStyle}>
<AttachmentLightbox metadata={lightboxMetadata} onClose={handleLightboxClose} />
{/* Input area */}
<View style={inputAreaContainerStyle}>
<View style={styles.inputAreaContent}>
{queueList}
{sendErrorNode}
<View ref={messageInputContainerRef} style={styles.messageInputContainer}>
<AutocompletePopover
visible={autocompleteVisible}
anchorRef={messageInputContainerRef}
options={autocomplete.options}
selectedIndex={autocomplete.selectedIndex}
onSelect={autocomplete.onSelectOption}
isLoading={autocomplete.isLoading}
errorMessage={autocomplete.errorMessage}
loadingText={autocomplete.loadingText}
emptyText={autocomplete.emptyText}
/>
<View ref={messageInputContainerRef} style={styles.messageInputContainer}>
<AutocompletePopover
visible={autocompleteVisible}
anchorRef={messageInputContainerRef}
options={autocomplete.options}
selectedIndex={autocomplete.selectedIndex}
onSelect={autocomplete.onSelectOption}
isLoading={autocomplete.isLoading}
errorMessage={autocomplete.errorMessage}
loadingText={autocomplete.loadingText}
emptyText={autocomplete.emptyText}
/>
{/* MessageInput handles everything: text, dictation, attachments, all buttons */}
<StableMessageInput
ref={messageInputRef}
value={userInput}
onChangeText={setUserInput}
onSubmit={handleSubmit}
hasExternalContent={hasExternalContent}
allowEmptySubmit={allowEmptySubmit}
submitButtonAccessibilityLabel={submitButtonAccessibilityLabel}
submitButtonTestID={submitButtonTestID}
submitIcon={submitIcon}
isSubmitDisabled={isSubmitBusy}
isSubmitLoading={isSubmitBusy}
preserveHeightOnSubmit={submitBehavior === "preserve-and-lock"}
attachments={selectedAttachments}
cwd={cwd}
attachmentMenuItems={attachmentMenuItems}
onAttachButtonRef={handleAttachButtonRef}
onAddImages={addImages}
client={client}
isReadyForDictation={isDictationReady}
placeholder={messagePlaceholder}
autoFocus={messageInputAutoFocus}
autoFocusKey={`${serverId}:${agentId}`}
disabled={isSubmitLoading}
isPaneFocused={isPaneFocused}
leftContent={leftContent}
beforeVoiceContent={beforeVoiceContent}
rightContent={rightContent}
voiceServerId={serverId}
voiceAgentId={agentId}
isAgentRunning={isAgentRunning}
defaultSendBehavior={appSettings.sendBehavior}
onQueue={handleQueue}
onSubmitLoadingPress={submitLoadingPressHandler}
onKeyPress={handleCommandKeyPress}
onSelectionChange={handleSelectionChange}
onFocusChange={handleFocusChange}
onHeightChange={onComposerHeightChange}
inputWrapperStyle={inputWrapperStyle}
attachmentSlot={attachmentTray}
/>
<Combobox
options={githubSearchOptions}
value=""
onSelect={noop}
keepOpenOnSelect
searchable
searchPlaceholder={t("composer.github.searchPlaceholder")}
title={t("composer.github.title")}
open={isGithubPickerOpen}
onOpenChange={handleGithubPickerOpenChange}
onSearchQueryChange={setGithubSearchQuery}
desktopPlacement="top-start"
anchorRef={attachButtonRef}
emptyText={githubEmptyText}
renderOption={renderGithubPickerOption}
/>
{/* MessageInput handles everything: text, dictation, attachments, all buttons */}
<StableMessageInput
ref={messageInputRef}
value={userInput}
onChangeText={setUserInput}
onSubmit={handleSubmit}
hasExternalContent={hasExternalContent}
allowEmptySubmit={allowEmptySubmit}
submitButtonAccessibilityLabel={submitButtonAccessibilityLabel}
submitButtonTestID={submitButtonTestID}
submitIcon={submitIcon}
isSubmitDisabled={isSubmitBusy}
isSubmitLoading={isSubmitBusy}
preserveHeightOnSubmit={submitBehavior === "preserve-and-lock"}
attachments={selectedAttachments}
cwd={cwd}
attachmentMenuItems={attachmentMenuItems}
onAttachButtonRef={handleAttachButtonRef}
onAddImages={addImages}
client={client}
isReadyForDictation={isDictationReady}
placeholder={messagePlaceholder}
autoFocus={messageInputAutoFocus}
autoFocusKey={`${serverId}:${agentId}`}
disabled={isSubmitLoading}
isPaneFocused={isPaneFocused}
leftContent={leftContent}
beforeVoiceContent={beforeVoiceContent}
rightContent={rightContent}
voiceServerId={serverId}
voiceAgentId={agentId}
isAgentRunning={isAgentRunning}
defaultSendBehavior={appSettings.sendBehavior}
onQueue={handleQueue}
onSubmitLoadingPress={submitLoadingPressHandler}
onKeyPress={handleCommandKeyPress}
onSelectionChange={handleSelectionChange}
onFocusChange={handleFocusChange}
onHeightChange={onComposerHeightChange}
inputWrapperStyle={inputWrapperStyle}
attachmentSlot={attachmentTray}
/>
<Combobox
options={githubSearchOptions}
value=""
onSelect={noop}
keepOpenOnSelect
searchable
searchPlaceholder={t("composer.github.searchPlaceholder")}
title={t("composer.github.title")}
open={isGithubPickerOpen}
onOpenChange={handleGithubPickerOpenChange}
onSearchQueryChange={setGithubSearchQuery}
desktopPlacement="top-start"
anchorRef={attachButtonRef}
emptyText={githubEmptyText}
renderOption={renderGithubPickerOption}
/>
</View>
</View>
</View>
</View>
{renderComposerFooter(footer, footerInlineContent)}
</Animated.View>
{renderComposerFooter(footer, footerInlineContent)}
</Animated.View>
</ComposerKeyboardScopeProvider>
);
}

View File

@@ -0,0 +1,31 @@
import {
createContext,
useContext,
useMemo,
type PropsWithChildren,
type ReactElement,
} from "react";
interface ComposerKeyboardScopeValue {
isActiveComposer: boolean;
}
const ComposerKeyboardScopeContext = createContext<ComposerKeyboardScopeValue>({
isActiveComposer: false,
});
export function ComposerKeyboardScopeProvider({
isActiveComposer,
children,
}: PropsWithChildren<ComposerKeyboardScopeValue>): ReactElement {
const value = useMemo(() => ({ isActiveComposer }), [isActiveComposer]);
return (
<ComposerKeyboardScopeContext.Provider value={value}>
{children}
</ComposerKeyboardScopeContext.Provider>
);
}
export function useComposerKeyboardScope(): ComposerKeyboardScopeValue {
return useContext(ComposerKeyboardScopeContext);
}

View File

@@ -81,6 +81,39 @@ describe("create agent preferences", () => {
});
});
it("does not erase a saved mode when a later partial update has no mode", () => {
expect(
mergeProviderPreferences({
preferences: {
provider: "codex",
providerPreferences: {
codex: {
model: "gpt-5.5",
mode: "full-access",
thinkingByModel: { "gpt-5.5": "high" },
},
},
},
provider: "codex",
updates: {
model: "gpt-5.6",
mode: undefined,
thinkingByModel: undefined,
featureValues: undefined,
},
}),
).toEqual({
provider: "codex",
providerPreferences: {
codex: {
model: "gpt-5.6",
mode: "full-access",
thinkingByModel: { "gpt-5.5": "high" },
},
},
});
});
it("loads invalid stored preferences as empty preferences", () => {
expect(parseFormPreferences({ providerPreferences: { codex: { mode: 42 } } })).toEqual({});
});

View File

@@ -46,6 +46,43 @@ export function parseFormPreferences(value: unknown): FormPreferences {
return result.success ? result.data : DEFAULT_FORM_PREFERENCES;
}
function mergeDefinedRecord<T>(
existing: Record<string, T> | undefined,
updates: Record<string, T> | undefined,
): Record<string, T> | undefined {
if (updates === undefined) {
return existing;
}
return {
...existing,
...updates,
};
}
function applyProviderPreferenceUpdates(
existing: ProviderPreferences,
updates: Partial<ProviderPreferences>,
): ProviderPreferences {
const next: ProviderPreferences = { ...existing };
const nextThinkingByModel = mergeDefinedRecord(existing.thinkingByModel, updates.thinkingByModel);
const nextFeatureValues = mergeDefinedRecord(existing.featureValues, updates.featureValues);
if (updates.model !== undefined) {
next.model = updates.model;
}
if (updates.mode !== undefined) {
next.mode = updates.mode;
}
if (nextThinkingByModel !== undefined) {
next.thinkingByModel = nextThinkingByModel;
}
if (nextFeatureValues !== undefined) {
next.featureValues = nextFeatureValues;
}
return next;
}
export function mergeProviderPreferences(args: {
preferences: FormPreferences;
provider: AgentProvider;
@@ -54,32 +91,13 @@ export function mergeProviderPreferences(args: {
const { preferences, provider, updates } = args;
const existingProviderPreferences = preferences.providerPreferences ?? {};
const existing = existingProviderPreferences[provider] ?? {};
const nextThinkingByModel =
updates.thinkingByModel === undefined
? existing.thinkingByModel
: {
...existing.thinkingByModel,
...updates.thinkingByModel,
};
const nextFeatureValues =
updates.featureValues === undefined
? existing.featureValues
: {
...existing.featureValues,
...updates.featureValues,
};
return {
...preferences,
provider,
providerPreferences: {
...existingProviderPreferences,
[provider]: {
...existing,
...updates,
...(nextThinkingByModel ? { thinkingByModel: nextThinkingByModel } : {}),
...(nextFeatureValues ? { featureValues: nextFeatureValues } : {}),
},
[provider]: applyProviderPreferenceUpdates(existing, updates),
},
};
}

View File

@@ -68,10 +68,10 @@ const CATALOG_DATA = [
id: "codebuddy-code",
title: "Codebuddy Code",
description: "Tencent Cloud's official intelligent coding tool",
version: "2.109.0",
version: "2.109.3",
iconId: "codebuddy-code",
installLink: "https://www.codebuddy.cn/cli/",
command: ["npx", "-y", "@tencent-ai/codebuddy-code@2.109.0", "--acp"],
command: ["npx", "-y", "@tencent-ai/codebuddy-code@2.109.3", "--acp"],
},
{
id: "codewhale",
@@ -140,29 +140,29 @@ const CATALOG_DATA = [
id: "dimcode",
title: "DimCode",
description: "A coding agent that puts leading models at your command.",
version: "0.2.7",
version: "0.2.9",
iconId: "dimcode",
installLink: "https://dimcode.dev/docs/acp.html",
command: ["npx", "-y", "dimcode@0.2.7", "acp"],
command: ["npx", "-y", "dimcode@0.2.9", "acp"],
},
{
id: "dirac",
title: "Dirac",
description:
"Reduces API costs by more than 50%, produces better and faster work. Uses Hash anchored parallel edits, AST manipulation and a whole lot of neat optimizations. Fully Open Source.",
version: "0.4.1",
version: "0.4.7",
iconId: "dirac",
installLink: "https://dirac.run",
command: ["npx", "-y", "dirac-cli@0.4.1", "--acp"],
command: ["npx", "-y", "dirac-cli@0.4.7", "--acp"],
},
{
id: "factory-droid",
title: "Factory Droid",
description: "Factory Droid - AI coding agent powered by Factory AI",
version: "0.153.1",
version: "0.157.1",
iconId: "factory-droid",
installLink: "https://factory.ai/product/cli",
command: ["npx", "-y", "droid@0.153.1", "exec", "--output-format", "acp-daemon"],
command: ["npx", "-y", "droid@0.157.1", "exec", "--output-format", "acp-daemon"],
env: {
DROID_DISABLE_AUTO_UPDATE: "true",
FACTORY_DROID_AUTO_UPDATE_ENABLED: "false",
@@ -284,10 +284,10 @@ const CATALOG_DATA = [
id: "nova",
title: "Nova",
description: "Nova by Compass AI - a fully-fledged software engineer at your command",
version: "1.1.18",
version: "1.1.19",
iconId: "nova",
installLink: "https://www.compassap.ai/portfolio/nova.html",
command: ["npx", "-y", "@compass-ai/nova@1.1.18", "acp"],
command: ["npx", "-y", "@compass-ai/nova@1.1.19", "acp"],
},
{
id: "poolside",
@@ -302,19 +302,19 @@ const CATALOG_DATA = [
id: "qoder",
title: "Qoder CLI",
description: "AI coding assistant with agentic capabilities",
version: "1.0.24",
version: "1.0.26",
iconId: "qoder",
installLink: "https://qoder.com",
command: ["npx", "-y", "@qoder-ai/qodercli@1.0.24", "--acp"],
command: ["npx", "-y", "@qoder-ai/qodercli@1.0.26", "--acp"],
},
{
id: "qwen-code",
title: "Qwen Code",
description: "Alibaba's Qwen coding assistant",
version: "0.18.4",
version: "0.19.1",
iconId: "qwen-code",
installLink: "https://qwenlm.github.io/qwen-code-docs/en/users/overview",
command: ["npx", "-y", "@qwen-code/qwen-code@0.18.4", "--acp", "--experimental-skills"],
command: ["npx", "-y", "@qwen-code/qwen-code@0.19.1", "--acp", "--experimental-skills"],
},
{
id: "sigit",

View File

@@ -122,6 +122,7 @@ function fileHeaderPressableStyle({ pressed }: PressableStateCallbackType) {
interface HighlightedTextProps {
tokens: HighlightToken[];
textMetricsStyle: TextStyle;
wrapLines?: boolean;
testID?: string;
}
@@ -140,14 +141,37 @@ function getWrappedTextStyle(wrapLines: boolean): WrappedWebTextStyle | undefine
: { whiteSpace: "pre", overflowWrap: "normal" };
}
function getNumericLineHeight(textMetricsStyle: TextStyle): number | undefined {
const { lineHeight } = textMetricsStyle;
return typeof lineHeight === "number" && Number.isFinite(lineHeight) ? lineHeight : undefined;
}
function useDiffRowMetricsStyle(textMetricsStyle: TextStyle): StyleProp<ViewStyle> {
const lineHeight = getNumericLineHeight(textMetricsStyle);
return useMemo(
() => (lineHeight !== undefined ? inlineUnistylesStyle({ minHeight: lineHeight }) : null),
[lineHeight],
);
}
function HighlightedToken({ token }: { token: HighlightToken }) {
return <Text style={syntaxTokenStyleFor(token.style)}>{token.text}</Text>;
}
function HighlightedText({ tokens, wrapLines = false, testID }: HighlightedTextProps) {
function HighlightedText({
tokens,
textMetricsStyle,
wrapLines = false,
testID,
}: HighlightedTextProps) {
const containerStyle = useMemo(
() => [styles.diffTextMetrics, styles.diffLineText, getWrappedTextStyle(wrapLines)],
[wrapLines],
() => [
styles.diffTextMetrics,
textMetricsStyle,
styles.diffLineText,
getWrappedTextStyle(wrapLines),
],
[textMetricsStyle, wrapLines],
);
const keyedTokens = useMemo(
@@ -246,6 +270,7 @@ function DiffGutterCell({
lineNumber,
type,
gutterWidth,
textMetricsStyle,
reviewTarget,
reviewActions,
isLineHovered,
@@ -256,6 +281,7 @@ function DiffGutterCell({
lineNumber: number | null;
type: DiffLine["type"] | undefined | null;
gutterWidth: number;
textMetricsStyle: TextStyle;
reviewTarget?: ReviewableDiffTarget | null;
reviewActions?: InlineReviewActions;
isLineHovered?: boolean;
@@ -263,23 +289,27 @@ function DiffGutterCell({
textTestID?: string;
actionTestID?: string;
}) {
const lineHeight = getNumericLineHeight(textMetricsStyle);
const rowMetricsStyle = useDiffRowMetricsStyle(textMetricsStyle);
const containerStyle = useMemo(
() => [
styles.gutterCell,
lineTypeBackground(type),
rowMetricsStyle,
inlineUnistylesStyle({ width: gutterWidth }),
style,
],
[type, gutterWidth, style],
[type, rowMetricsStyle, gutterWidth, style],
);
const textStyle = useMemo(
() => [
styles.diffTextMetrics,
textMetricsStyle,
styles.lineNumberText,
type === "add" && styles.addLineNumberText,
type === "remove" && styles.removeLineNumberText,
],
[type],
[textMetricsStyle, type],
);
const comments = useMemo(
() =>
@@ -297,6 +327,7 @@ function DiffGutterCell({
comments={comments}
isEditorOpen={isEditorOpen}
isLineHovered={isLineHovered}
lineHeight={lineHeight}
onStartComment={onStartComment}
style={containerStyle}
actionTestID={actionTestID}
@@ -311,6 +342,7 @@ function DiffGutterCell({
function DiffTextLine({
line,
wrapLines,
textMetricsStyle,
reviewTarget,
reviewActions,
onHoverChange,
@@ -320,6 +352,7 @@ function DiffTextLine({
}: {
line: DiffLine;
wrapLines: boolean;
textMetricsStyle: TextStyle;
reviewTarget?: ReviewableDiffTarget | null;
reviewActions?: InlineReviewActions;
onHoverChange?: (hovered: boolean) => void;
@@ -328,14 +361,16 @@ function DiffTextLine({
textTestID?: string;
}) {
const visibleTokens = hasVisibleDiffTokens(line.tokens) ? line.tokens : null;
const rowMetricsStyle = useDiffRowMetricsStyle(textMetricsStyle);
const containerStyle = useMemo(
() => [styles.textLineContainer, lineTypeBackground(line.type)],
[line.type],
() => [styles.textLineContainer, lineTypeBackground(line.type), rowMetricsStyle],
[line.type, rowMetricsStyle],
);
const textStyle = useMemo(
() => [
styles.diffTextMetrics,
textMetricsStyle,
styles.diffLineText,
getWrappedTextStyle(wrapLines),
line.type === "add" && styles.addLineText,
@@ -343,7 +378,7 @@ function DiffTextLine({
line.type === "header" && styles.headerLineText,
line.type === "context" && styles.contextLineText,
],
[line.type, wrapLines],
[line.type, textMetricsStyle, wrapLines],
);
return (
@@ -356,7 +391,12 @@ function DiffTextLine({
style={containerStyle}
>
{line.type !== "header" && visibleTokens ? (
<HighlightedText tokens={visibleTokens} wrapLines={wrapLines} testID={textTestID} />
<HighlightedText
tokens={visibleTokens}
textMetricsStyle={textMetricsStyle}
wrapLines={wrapLines}
testID={textTestID}
/>
) : (
<Text style={textStyle} testID={textTestID}>
{formatDiffContentText(line.content)}
@@ -369,6 +409,7 @@ function DiffTextLine({
function SplitTextLine({
line,
wrapLines,
textMetricsStyle,
reviewActions,
onHoverChange,
hoverTargetKey,
@@ -376,20 +417,23 @@ function SplitTextLine({
}: {
line: SplitDiffDisplayLine | null;
wrapLines: boolean;
textMetricsStyle: TextStyle;
reviewActions?: InlineReviewActions;
onHoverChange?: (hovered: boolean) => void;
hoverTargetKey?: string | null;
onHoverTargetChange?: (key: string | null) => void;
}) {
const visibleTokens = line && hasVisibleDiffTokens(line.tokens) ? line.tokens : null;
const rowMetricsStyle = useDiffRowMetricsStyle(textMetricsStyle);
const containerStyle = useMemo(
() => [styles.textLineContainer, lineTypeBackground(line?.type)],
[line?.type],
() => [styles.textLineContainer, lineTypeBackground(line?.type), rowMetricsStyle],
[line?.type, rowMetricsStyle],
);
const textStyle = useMemo(
() => [
styles.diffTextMetrics,
textMetricsStyle,
styles.diffLineText,
getWrappedTextStyle(wrapLines),
line?.type === "add" && styles.addLineText,
@@ -397,7 +441,7 @@ function SplitTextLine({
line?.type === "context" && styles.contextLineText,
!line && styles.emptySplitCellText,
],
[line, wrapLines],
[line, textMetricsStyle, wrapLines],
);
return (
@@ -410,7 +454,11 @@ function SplitTextLine({
style={containerStyle}
>
{visibleTokens ? (
<HighlightedText tokens={visibleTokens} wrapLines={wrapLines} />
<HighlightedText
tokens={visibleTokens}
textMetricsStyle={textMetricsStyle}
wrapLines={wrapLines}
/>
) : (
<Text style={textStyle}>{formatDiffContentText(line?.content)}</Text>
)}
@@ -423,6 +471,7 @@ function DiffLineView({
lineNumber,
gutterWidth,
wrapLines,
textMetricsStyle,
reviewTarget,
reviewActions,
}: {
@@ -430,19 +479,22 @@ function DiffLineView({
lineNumber: number | null;
gutterWidth: number;
wrapLines: boolean;
textMetricsStyle: TextStyle;
reviewTarget?: ReviewableDiffTarget | null;
reviewActions?: InlineReviewActions;
}) {
const [isLineHovered, setIsLineHovered] = useState(false);
const visibleTokens = hasVisibleDiffTokens(line.tokens) ? line.tokens : null;
const rowMetricsStyle = useDiffRowMetricsStyle(textMetricsStyle);
const containerStyle = useMemo(
() => [styles.diffLineContainer, lineTypeBackground(line.type)],
[line.type],
() => [styles.diffLineContainer, lineTypeBackground(line.type), rowMetricsStyle],
[line.type, rowMetricsStyle],
);
const textStyle = useMemo(
() => [
styles.diffTextMetrics,
textMetricsStyle,
styles.diffLineText,
getWrappedTextStyle(wrapLines),
line.type === "add" && styles.addLineText,
@@ -450,7 +502,7 @@ function DiffLineView({
line.type === "header" && styles.headerLineText,
line.type === "context" && styles.contextLineText,
],
[line.type, wrapLines],
[line.type, textMetricsStyle, wrapLines],
);
return (
@@ -464,13 +516,18 @@ function DiffLineView({
lineNumber={lineNumber}
type={line.type}
gutterWidth={gutterWidth}
textMetricsStyle={textMetricsStyle}
reviewTarget={reviewTarget}
reviewActions={reviewActions}
isLineHovered={isLineHovered}
style={styles.lineNumberGutter}
/>
{line.type !== "header" && visibleTokens ? (
<HighlightedText tokens={visibleTokens} wrapLines={wrapLines} />
<HighlightedText
tokens={visibleTokens}
textMetricsStyle={textMetricsStyle}
wrapLines={wrapLines}
/>
) : (
<Text style={textStyle}>{formatDiffContentText(line.content)}</Text>
)}
@@ -482,23 +539,27 @@ function SplitDiffLine({
line,
gutterWidth,
wrapLines,
textMetricsStyle,
reviewActions,
}: {
line: SplitDiffDisplayLine | null;
gutterWidth: number;
wrapLines: boolean;
textMetricsStyle: TextStyle;
reviewActions?: InlineReviewActions;
}) {
const [isLineHovered, setIsLineHovered] = useState(false);
const visibleTokens = line && hasVisibleDiffTokens(line.tokens) ? line.tokens : null;
const rowMetricsStyle = useDiffRowMetricsStyle(textMetricsStyle);
const containerStyle = useMemo(
() => [styles.diffLineContainer, lineTypeBackground(line?.type)],
[line?.type],
() => [styles.diffLineContainer, lineTypeBackground(line?.type), rowMetricsStyle],
[line?.type, rowMetricsStyle],
);
const textStyle = useMemo(
() => [
styles.diffTextMetrics,
textMetricsStyle,
styles.diffLineText,
getWrappedTextStyle(wrapLines),
line?.type === "add" && styles.addLineText,
@@ -506,7 +567,7 @@ function SplitDiffLine({
line?.type === "context" && styles.contextLineText,
!line && styles.emptySplitCellText,
],
[line, wrapLines],
[line, textMetricsStyle, wrapLines],
);
return (
@@ -520,13 +581,18 @@ function SplitDiffLine({
lineNumber={line?.lineNumber ?? null}
type={line?.type}
gutterWidth={gutterWidth}
textMetricsStyle={textMetricsStyle}
reviewTarget={line?.reviewTarget}
reviewActions={reviewActions}
isLineHovered={isLineHovered}
style={styles.lineNumberGutter}
/>
{visibleTokens ? (
<HighlightedText tokens={visibleTokens} wrapLines={wrapLines} />
<HighlightedText
tokens={visibleTokens}
textMetricsStyle={textMetricsStyle}
wrapLines={wrapLines}
/>
) : (
<Text style={textStyle}>{formatDiffContentText(line?.content)}</Text>
)}
@@ -649,6 +715,7 @@ function SplitDiffColumn({
side,
gutterWidth,
wrapLines,
textMetricsStyle,
reviewActions,
showDivider = false,
}: {
@@ -656,6 +723,7 @@ function SplitDiffColumn({
side: "left" | "right";
gutterWidth: number;
wrapLines: boolean;
textMetricsStyle: TextStyle;
reviewActions?: InlineReviewActions;
showDivider?: boolean;
}) {
@@ -677,6 +745,10 @@ function SplitDiffColumn({
],
[scrollWidth],
);
const headerLineTextStyle = useMemo(
() => [styles.diffTextMetrics, textMetricsStyle, styles.diffLineText, styles.headerLineText],
[textMetricsStyle],
);
const keyedRows = useMemo(() => rows.map((row, i) => ({ key: `row-${i}`, row })), [rows]);
@@ -688,7 +760,7 @@ function SplitDiffColumn({
if (row.kind === "header") {
return (
<View key={key} style={styles.splitHeaderRow}>
<Text style={HEADER_LINE_TEXT_STYLE}>{row.content}</Text>
<Text style={headerLineTextStyle}>{row.content}</Text>
</View>
);
}
@@ -704,6 +776,7 @@ function SplitDiffColumn({
line={line}
gutterWidth={gutterWidth}
wrapLines={wrapLines}
textMetricsStyle={textMetricsStyle}
reviewActions={reviewActions}
/>
<InlineReviewRow
@@ -726,7 +799,13 @@ function SplitDiffColumn({
{keyedRows.map(({ key, row }) => {
if (row.kind === "header") {
return (
<DiffGutterCell key={key} lineNumber={null} type="header" gutterWidth={gutterWidth} />
<DiffGutterCell
key={key}
lineNumber={null}
type="header"
gutterWidth={gutterWidth}
textMetricsStyle={textMetricsStyle}
/>
);
}
const line = side === "left" ? row.left : row.right;
@@ -742,6 +821,7 @@ function SplitDiffColumn({
lineNumber={line?.lineNumber ?? null}
type={line?.type}
gutterWidth={gutterWidth}
textMetricsStyle={textMetricsStyle}
reviewTarget={line?.reviewTarget}
reviewActions={reviewActions}
isLineHovered={
@@ -769,7 +849,7 @@ function SplitDiffColumn({
if (row.kind === "header") {
return (
<View key={key} style={styles.splitHeaderRow}>
<Text style={HEADER_LINE_TEXT_STYLE}>{row.content}</Text>
<Text style={headerLineTextStyle}>{row.content}</Text>
</View>
);
}
@@ -785,6 +865,7 @@ function SplitDiffColumn({
<SplitTextLine
line={line}
wrapLines={false}
textMetricsStyle={textMetricsStyle}
reviewActions={reviewActions}
hoverTargetKey={reviewTargetKey}
onHoverTargetChange={setHoveredReviewTargetKey}
@@ -910,6 +991,7 @@ function DiffFileBody({
layout,
wrapLines,
codeFontSize,
textMetricsStyle,
reviewActions,
onBodyHeightChange,
testID,
@@ -918,6 +1000,7 @@ function DiffFileBody({
layout: "unified" | "split";
wrapLines: boolean;
codeFontSize: number;
textMetricsStyle: TextStyle;
reviewActions?: InlineReviewActions;
onBodyHeightChange?: (file: ParsedDiffFile, height: number) => void;
testID?: string;
@@ -978,6 +1061,7 @@ function DiffFileBody({
side="left"
gutterWidth={gutterWidth}
wrapLines={wrapLines}
textMetricsStyle={textMetricsStyle}
reviewActions={reviewActions}
/>
<SplitDiffColumn
@@ -985,6 +1069,7 @@ function DiffFileBody({
side="right"
gutterWidth={gutterWidth}
wrapLines={wrapLines}
textMetricsStyle={textMetricsStyle}
reviewActions={reviewActions}
showDivider
/>
@@ -1005,6 +1090,7 @@ function DiffFileBody({
lineNumber={lineNumber}
gutterWidth={gutterWidth}
wrapLines={wrapLines}
textMetricsStyle={textMetricsStyle}
reviewTarget={reviewTarget}
reviewActions={reviewActions}
/>
@@ -1031,6 +1117,7 @@ function DiffFileBody({
lineNumber={lineNumber}
type={line.type}
gutterWidth={gutterWidth}
textMetricsStyle={textMetricsStyle}
reviewTarget={reviewTarget}
reviewActions={reviewActions}
isLineHovered={
@@ -1059,6 +1146,7 @@ function DiffFileBody({
<DiffTextLine
line={line}
wrapLines={false}
textMetricsStyle={textMetricsStyle}
reviewTarget={reviewTarget}
reviewActions={reviewActions}
hoverTargetKey={reviewTarget?.key ?? null}
@@ -1621,6 +1709,14 @@ export function GitDiffPane({ serverId, workspaceId, cwd, enabled }: GitDiffPane
const diffBodyTypographyKey = [appSettings.monoFontFamily, codeFontSize, diffBodyLineHeight].join(
":",
);
const diffTextMetricsStyle = useMemo<TextStyle>(() => {
const monoFontFamily = appSettings.monoFontFamily.trim();
return {
fontSize: codeFontSize,
lineHeight: diffBodyLineHeight,
...(monoFontFamily ? { fontFamily: monoFontFamily } : null),
};
}, [appSettings.monoFontFamily, codeFontSize, diffBodyLineHeight]);
const diffModeTriggerStyle = useMemo(() => buildDiffModeTriggerStyle(), []);
const unifiedToggleStyle = useMemo(
@@ -2009,6 +2105,7 @@ export function GitDiffPane({ serverId, workspaceId, cwd, enabled }: GitDiffPane
layout={effectiveLayout}
wrapLines={wrapLines}
codeFontSize={codeFontSize}
textMetricsStyle={diffTextMetricsStyle}
reviewActions={reviewActions}
onBodyHeightChange={handleBodyHeightChange}
testID={`diff-file-${item.fileIndex}-body`}
@@ -2017,6 +2114,7 @@ export function GitDiffPane({ serverId, workspaceId, cwd, enabled }: GitDiffPane
},
[
codeFontSize,
diffTextMetricsStyle,
effectiveLayout,
handleBodyHeightChange,
handleHeaderHeightChange,
@@ -2680,7 +2778,6 @@ const styles = StyleSheet.create((theme) => ({
},
}));
const HEADER_LINE_TEXT_STYLE = [styles.diffTextMetrics, styles.diffLineText, styles.headerLineText];
const FILE_SECTION_BODY_STYLE = [styles.fileSectionBodyContainer, styles.fileSectionBorder];
const DIFF_CONTENT_SPLIT_ROW_STYLE = [styles.diffContent, styles.splitRow];
const DIFF_CONTENT_ROW_STYLE = [styles.diffContent, styles.diffContentRow];

View File

@@ -0,0 +1,87 @@
import { useEffect, useMemo, useRef } from "react";
import { Animated, View, type StyleProp, type ViewStyle } from "react-native";
import { StyleSheet } from "react-native-unistyles";
const ROW_KEYS = [0, 1, 2].map((i) => `pr-activity-skeleton-row-${i}`);
export function useSkeletonPulse(): Animated.Value {
const pulse = useRef(new Animated.Value(0)).current;
useEffect(() => {
const animation = Animated.loop(
Animated.sequence([
Animated.timing(pulse, { toValue: 1, duration: 1000, useNativeDriver: true }),
Animated.timing(pulse, { toValue: 0, duration: 1000, useNativeDriver: true }),
]),
);
animation.start();
return () => animation.stop();
}, [pulse]);
return pulse;
}
export function SkeletonPulse({
pulse,
style,
}: {
pulse: Animated.Value;
style: StyleProp<ViewStyle>;
}) {
const opacity = pulse.interpolate({
inputRange: [0, 1],
outputRange: [0.4, 0.8],
});
const pulseStyle = useMemo(() => [style, { opacity }], [style, opacity]);
return <Animated.View style={pulseStyle} />;
}
export function PrActivitySkeleton() {
const pulse = useSkeletonPulse();
return (
<View style={styles.container} testID="pr-pane-activity-skeleton">
{ROW_KEYS.map((key) => (
<View key={key} style={styles.row}>
<SkeletonPulse pulse={pulse} style={styles.avatar} />
<View style={styles.lines}>
<SkeletonPulse pulse={pulse} style={styles.lineWide} />
<SkeletonPulse pulse={pulse} style={styles.lineNarrow} />
</View>
</View>
))}
</View>
);
}
const styles = StyleSheet.create((theme) => ({
container: {
paddingHorizontal: theme.spacing[3],
gap: theme.spacing[3],
},
row: {
flexDirection: "row",
alignItems: "center",
gap: theme.spacing[2],
},
avatar: {
width: 20,
height: 20,
borderRadius: theme.borderRadius.full,
backgroundColor: theme.colors.surface2,
},
lines: {
flex: 1,
gap: theme.spacing[1],
},
lineWide: {
width: "70%",
height: 12,
borderRadius: theme.borderRadius.sm,
backgroundColor: theme.colors.surface2,
},
lineNarrow: {
width: "45%",
height: 10,
borderRadius: theme.borderRadius.sm,
backgroundColor: theme.colors.surface2,
},
}));

View File

@@ -1,4 +1,6 @@
export { PullRequestPane } from "./pane";
export { PullRequestPaneError } from "./pane-error";
export { PullRequestPaneSkeleton } from "./pane-skeleton";
export { PullRequestTabIcon } from "./tab-icon";
export { formatPrTabLabel } from "./tab-label";
export { prPaneTimelineQueryKind } from "./query-keys";

View File

@@ -0,0 +1,39 @@
import { Text, View } from "react-native";
import { StyleSheet } from "react-native-unistyles";
import { RotateCw } from "lucide-react-native";
import { useTranslation } from "react-i18next";
import { Button } from "@/components/ui/button";
export function PullRequestPaneError({
onRetry,
message,
}: {
onRetry: () => void;
message?: string;
}) {
const { t } = useTranslation();
return (
<View style={styles.root} testID="pr-pane-error">
<Text style={styles.message}>{message ?? t("workspace.git.diff.failedRefresh")}</Text>
<Button variant="ghost" size="xs" leftIcon={RotateCw} onPress={onRetry}>
{t("common.actions.retry")}
</Button>
</View>
);
}
const styles = StyleSheet.create((theme) => ({
root: {
flex: 1,
alignItems: "center",
justifyContent: "center",
gap: theme.spacing[3],
paddingHorizontal: theme.spacing[4],
backgroundColor: theme.colors.surfaceSidebar,
},
message: {
fontSize: theme.fontSize.xs,
color: theme.colors.foregroundMuted,
textAlign: "center",
},
}));

View File

@@ -0,0 +1,113 @@
import { Text, View } from "react-native";
import { StyleSheet } from "react-native-unistyles";
import { useTranslation } from "react-i18next";
import { PrActivitySkeleton, SkeletonPulse, useSkeletonPulse } from "./activity-skeleton";
const CHECK_ROW_KEYS = [0, 1, 2].map((i) => `pr-pane-skeleton-check-${i}`);
export function PullRequestPaneSkeleton() {
const { t } = useTranslation();
const pulse = useSkeletonPulse();
return (
<View style={styles.root} testID="pr-pane-skeleton">
<View style={styles.header}>
<SkeletonPulse pulse={pulse} style={styles.title} />
<SkeletonPulse pulse={pulse} style={styles.subtitle} />
</View>
<View style={styles.toolbar}>
<SkeletonPulse pulse={pulse} style={styles.toolbarButton} />
<SkeletonPulse pulse={pulse} style={styles.toolbarButton} />
</View>
<View style={styles.section}>
<Text style={styles.sectionTitle}>{t("workspace.git.pr.sections.checks")}</Text>
<View style={styles.checks}>
{CHECK_ROW_KEYS.map((key) => (
<View key={key} style={styles.checkRow}>
<SkeletonPulse pulse={pulse} style={styles.checkDot} />
<SkeletonPulse pulse={pulse} style={styles.checkName} />
</View>
))}
</View>
</View>
<View style={styles.section}>
<PrActivitySkeleton />
</View>
</View>
);
}
const styles = StyleSheet.create((theme) => ({
root: {
flex: 1,
minHeight: 0,
backgroundColor: theme.colors.surfaceSidebar,
},
header: {
gap: theme.spacing[2],
paddingHorizontal: theme.spacing[4],
paddingVertical: theme.spacing[4],
},
title: {
width: "75%",
height: 16,
borderRadius: theme.borderRadius.sm,
backgroundColor: theme.colors.surface2,
},
subtitle: {
width: "40%",
height: 12,
borderRadius: theme.borderRadius.sm,
backgroundColor: theme.colors.surface2,
},
toolbar: {
flexDirection: "row",
alignItems: "center",
gap: theme.spacing[2],
paddingHorizontal: theme.spacing[3],
paddingVertical: theme.spacing[2],
borderBottomWidth: 1,
borderBottomColor: theme.colors.border,
},
toolbarButton: {
width: 96,
height: 24,
borderRadius: theme.borderRadius.lg,
backgroundColor: theme.colors.surface2,
},
section: {
paddingVertical: theme.spacing[2],
},
sectionTitle: {
fontSize: theme.fontSize.xs,
fontWeight: theme.fontWeight.medium,
color: theme.colors.foregroundMuted,
paddingHorizontal: theme.spacing[3],
paddingVertical: theme.spacing[2],
},
checks: {
gap: theme.spacing[1],
},
checkRow: {
flexDirection: "row",
alignItems: "center",
gap: theme.spacing[2],
paddingHorizontal: theme.spacing[3],
minHeight: 32,
},
checkDot: {
width: 14,
height: 14,
borderRadius: theme.borderRadius.full,
backgroundColor: theme.colors.surface2,
},
checkName: {
width: "60%",
height: 12,
borderRadius: theme.borderRadius.sm,
backgroundColor: theme.colors.surface2,
},
}));

View File

@@ -25,10 +25,13 @@ import {
MessageSquare,
MessageSquarePlus,
MoreHorizontal,
RotateCw,
} from "lucide-react-native";
import type { PressableStateCallbackType } from "react-native";
import { useTranslation } from "react-i18next";
import { openExternalUrl } from "@/utils/open-external-url";
import { Button } from "@/components/ui/button";
import { LoadingSpinner } from "@/components/ui/loading-spinner";
import { StatusBadge } from "@/components/ui/status-badge";
import {
DropdownMenu,
@@ -42,9 +45,12 @@ import { writeMarkdownToRichClipboard } from "@/utils/rich-clipboard";
import { useHostRuntimeClient } from "@/runtime/host-runtime";
import { useSessionStore } from "@/stores/session-store";
import { useWorkspaceAttachmentsStore } from "@/attachments/workspace-attachments-store";
import { useToast } from "@/contexts/toast-context";
import { useCheckoutGitActionsStore } from "@/git/actions-store";
import { isNative } from "@/constants/platform";
import { useIsCompactFormFactor } from "@/constants/layout";
import type { Theme } from "@/styles/theme";
import { useIsCompactFormFactor, WORKSPACE_SECONDARY_HEADER_HEIGHT } from "@/constants/layout";
import { ICON_SIZE, type Theme } from "@/styles/theme";
import { PrActivitySkeleton } from "./activity-skeleton";
import {
collapseActivity,
expandActivity,
@@ -85,6 +91,8 @@ const ThemedGitPullRequestDraft = withUnistyles(GitPullRequestDraft);
const ThemedMessageSquare = withUnistyles(MessageSquare);
const ThemedMessageSquarePlus = withUnistyles(MessageSquarePlus);
const ThemedMoreHorizontal = withUnistyles(MoreHorizontal);
const ThemedRotateCw = withUnistyles(RotateCw);
const ThemedLoadingSpinner = withUnistyles(LoadingSpinner);
const foregroundColorMapping = (theme: Theme) => ({ color: theme.colors.foreground });
const foregroundMutedColorMapping = (theme: Theme) => ({ color: theme.colors.foregroundMuted });
@@ -138,6 +146,13 @@ function kebabTriggerStyle({
return [styles.kebabButton, hovered && styles.kebabButtonHovered];
}
function refreshButtonStyle({
hovered = false,
pressed = false,
}: PressableStateCallbackType & { hovered?: boolean }) {
return [styles.refreshButton, (hovered || pressed) && styles.refreshButtonHovered];
}
function renderKebabTriggerIcon({ hovered }: { hovered?: boolean }) {
return (
<ThemedMoreHorizontal
@@ -176,13 +191,17 @@ export function PullRequestPane({
serverId,
cwd,
data,
activityLoading,
workspaceAttachmentScopeKey,
}: {
serverId: string;
cwd: string;
data: PrPaneData;
activityLoading: boolean;
workspaceAttachmentScopeKey?: string;
}) {
const { t } = useTranslation();
const toast = useToast();
const daemonClient = useHostRuntimeClient(serverId);
const canFetchGitHubCheckDetails = useSessionStore(
(state) => state.sessions[serverId]?.serverInfo?.features?.githubCheckDetails === true,
@@ -199,6 +218,24 @@ export function PullRequestPane({
void openExternalUrl(data.url);
}, [data.url]);
const refreshSupported = useSessionStore(
(state) => state.sessions[serverId]?.serverInfo?.features?.checkoutRefresh === true,
);
const runRefresh = useCheckoutGitActionsStore((state) => state.refresh);
const isRefreshing =
useCheckoutGitActionsStore((state) =>
state.getStatus({ serverId, cwd, actionId: "refresh" }),
) === "pending";
const handleRefresh = useCallback(() => {
if (isRefreshing) {
return;
}
void runRefresh({ serverId, cwd }).catch((error) => {
toast.error(error instanceof Error ? error.message : t("workspace.git.diff.failedRefresh"));
});
}, [cwd, isRefreshing, runRefresh, serverId, t, toast]);
const handleToggleChecks = useCallback(() => {
setChecksOpen((open) => !open);
}, []);
@@ -395,6 +432,47 @@ export function PullRequestPane({
return (
<View style={styles.root} testID="pr-pane">
<ScrollView style={styles.scroll} showsVerticalScrollIndicator={false}>
<View style={styles.toolbar} testID="pr-pane-toolbar">
<View style={styles.toolbarActions}>
<Button
variant="ghost"
size="xs"
leftIcon={ExternalLink}
onPress={handleOpenPrUrl}
style={styles.viewButton}
testID="pr-pane-view-pr"
>
{t("workspace.git.pr.actions.viewPullRequest")}
</Button>
</View>
{refreshSupported ? (
<Pressable
accessibilityRole="button"
accessibilityLabel={
isRefreshing
? t("workspace.git.diff.refreshing")
: t("workspace.git.diff.refreshState")
}
testID="pr-pane-refresh"
style={refreshButtonStyle}
hitSlop={8}
onPress={handleRefresh}
disabled={isRefreshing}
>
<View style={styles.refreshIcon}>
{isRefreshing ? (
<ThemedLoadingSpinner
size={ICON_SIZE.sm}
uniProps={foregroundMutedColorMapping}
/>
) : (
<ThemedRotateCw size={ICON_SIZE.sm} uniProps={foregroundMutedColorMapping} />
)}
</View>
</Pressable>
) : null}
</View>
<Pressable onPress={handleOpenPrUrl} style={styles.header}>
{({ hovered }) => (
<>
@@ -420,8 +498,6 @@ export function PullRequestPane({
)}
</Pressable>
<View style={styles.divider} />
<Section
title="Checks"
open={checksOpen}
@@ -488,27 +564,30 @@ export function PullRequestPane({
size="xs"
leftIcon={MessageSquarePlus}
onPress={handleAddAllToChat}
disabled={activityLoading}
>
Add all to chat
</Button>
</View>
) : null}
{visibleEntries.length === 0 ? (
{activityLoading ? <PrActivitySkeleton /> : null}
{!activityLoading && visibleEntries.length === 0 ? (
<Text style={styles.emptyText}>No activity yet</Text>
) : (
visibleEntries.map(({ entry, collapsed }) => (
<TimelineEntryCard
key={entry.id}
entry={entry}
collapsed={collapsed}
collapsedEntryIds={collapsedEntryIds}
attachEnabled={attachEnabled}
onAddToChat={handleAddActivityToChat}
onAddThreadToChat={handleAddThreadToChat}
onToggleCollapsed={handleToggleEntryCollapsed}
/>
))
)}
) : null}
{!activityLoading
? visibleEntries.map(({ entry, collapsed }) => (
<TimelineEntryCard
key={entry.id}
entry={entry}
collapsed={collapsed}
collapsedEntryIds={collapsedEntryIds}
attachEnabled={attachEnabled}
onAddToChat={handleAddActivityToChat}
onAddThreadToChat={handleAddThreadToChat}
onToggleCollapsed={handleToggleEntryCollapsed}
/>
))
: null}
</Section>
</ScrollView>
</View>
@@ -1236,6 +1315,48 @@ const styles = StyleSheet.create((theme) => ({
height: 1,
backgroundColor: theme.colors.border,
},
toolbar: {
height: WORKSPACE_SECONDARY_HEADER_HEIGHT,
flexDirection: "row",
alignItems: "center",
justifyContent: "space-between",
borderBottomWidth: 1,
borderBottomColor: theme.colors.border,
paddingTop: theme.spacing[2],
paddingRight: theme.spacing[3],
paddingBottom: theme.spacing[2],
paddingLeft: theme.spacing[3],
},
toolbarActions: {
flexDirection: "row",
alignItems: "center",
gap: theme.spacing[1],
},
viewButton: {
gap: theme.spacing[1],
minHeight: 24,
height: 24,
paddingVertical: 0,
paddingHorizontal: theme.spacing[1],
borderRadius: theme.borderRadius.base,
},
refreshButton: {
marginLeft: "auto",
width: 22,
height: 22,
borderRadius: theme.borderRadius.md,
alignItems: "center",
justifyContent: "center",
},
refreshButtonHovered: {
backgroundColor: theme.colors.surface2,
},
refreshIcon: {
width: ICON_SIZE.md,
height: ICON_SIZE.md,
alignItems: "center",
justifyContent: "center",
},
sectionHeader: {
flexDirection: "row",
alignItems: "center",

View File

@@ -435,6 +435,37 @@ describe("selectPrPaneState", () => {
expect(state.isLoading).toBe(false);
});
it("reports activityLoading while the timeline is pending its first response", () => {
const state = selectPrPaneState({
...baseSelectInput,
status: prStatus(),
shouldFetchTimeline: true,
timelineIsLoading: true,
});
expect(state.activityLoading).toBe(true);
});
it("does not report activityLoading when no timeline fetch was scheduled", () => {
const state = selectPrPaneState({
...baseSelectInput,
status: prStatus(),
shouldFetchTimeline: false,
timelineIsLoading: true,
});
expect(state.activityLoading).toBe(false);
});
it("does not report activityLoading once the timeline payload resolves", () => {
const state = selectPrPaneState({
...baseSelectInput,
status: prStatus(),
shouldFetchTimeline: true,
timelineIsLoading: false,
timelinePayload: timelinePayload(),
});
expect(state.activityLoading).toBe(false);
});
it("reports refreshing during background revalidation of either query", () => {
expect(
selectPrPaneState({

View File

@@ -27,6 +27,7 @@ export interface UsePrPaneDataResult {
data: PrPaneData | null;
prNumber: number | null;
isLoading: boolean;
activityLoading: boolean;
isRefreshing: boolean;
error: Error | null;
githubFeaturesEnabled: boolean;
@@ -167,13 +168,14 @@ export function selectPrPaneState(input: SelectPrPaneStateInput): UsePrPaneDataR
: mapPrPaneData(input.status, input.timelinePayload);
const statusRefreshing = input.statusIsFetching && !input.statusIsLoading;
const timelineRefreshing = input.timelineIsFetching && !input.timelineIsLoading;
const timelinePending =
input.shouldFetchTimeline && input.timelineIsLoading && input.timelinePayload === undefined;
return {
data,
prNumber: identity.prNumber,
isLoading:
input.statusIsLoading ||
(input.shouldFetchTimeline && input.timelineIsLoading && input.timelinePayload === undefined),
isLoading: input.statusIsLoading || timelinePending,
activityLoading: timelinePending,
isRefreshing: statusRefreshing || timelineRefreshing,
error: firstNonSuppressedError({
statusPayloadError: input.statusPayloadError,

View File

@@ -7,6 +7,7 @@ import { type CheckoutPrStatusPayload, useCheckoutPrStatusQuery } from "@/git/us
import {
buildGitActions,
narrowPullRequestState,
type BuildGitActionsInput,
type GitAction,
type GitActions,
} from "@/git/policy";
@@ -568,8 +569,8 @@ export function useGitActions({ serverId, cwd, icons }: UseGitActionsInput): Use
}, [prStatus?.url, handleCreatePr]);
// Build actions
const gitActions: GitActions = useMemo(() => {
const actions = buildGitActions({
const gitActionsInput = useMemo<BuildGitActionsInput>(
() => ({
isGit,
githubFeaturesEnabled,
githubAutoMergeActionsEnabled,
@@ -684,61 +685,66 @@ export function useGitActions({ serverId, cwd, icons }: UseGitActionsInput): Use
handler: handleArchiveWorktree,
},
},
});
return translateGitActions(actions, { baseRefLabel, hasPullRequest, t });
}, [
t,
isGit,
hasRemote,
hasPullRequest,
prStatus?.url,
prStatus?.state,
prStatus?.isDraft,
prStatus?.isMerged,
prStatus?.mergeable,
prStatus?.github,
aheadCount,
behindBaseCount,
isPaseoOwnedWorktree,
isOnBaseBranch,
githubFeaturesEnabled,
githubAutoMergeActionsEnabled,
hasUncommittedChanges,
aheadOfOrigin,
behindOfOrigin,
shipDefault,
baseRefLabel,
shouldPromoteArchive,
actionsDisabled,
commitStatus,
pullStatus,
pushStatus,
pullAndPushStatus,
prCreateStatus,
mergePrStatuses.squash,
mergePrStatuses.merge,
mergePrStatuses.rebase,
enablePrAutoMergeStatuses.squash,
enablePrAutoMergeStatuses.merge,
enablePrAutoMergeStatuses.rebase,
disablePrAutoMergeStatus,
mergeStatus,
mergeFromBaseStatus,
archiveStatus,
handleCommit,
handlePull,
handlePush,
handlePullAndPush,
handlePrAction,
handleMergePr,
handleEnablePrAutoMerge,
handleDisablePrAutoMerge,
handleMergeBranch,
handleMergeFromBase,
handleArchiveWorktree,
icons,
baseRef,
]);
}),
[
isGit,
hasRemote,
hasPullRequest,
prStatus?.url,
prStatus?.state,
prStatus?.isDraft,
prStatus?.isMerged,
prStatus?.mergeable,
prStatus?.github,
aheadCount,
behindBaseCount,
isPaseoOwnedWorktree,
isOnBaseBranch,
githubFeaturesEnabled,
githubAutoMergeActionsEnabled,
hasUncommittedChanges,
aheadOfOrigin,
behindOfOrigin,
shipDefault,
baseRefLabel,
shouldPromoteArchive,
actionsDisabled,
commitStatus,
pullStatus,
pushStatus,
pullAndPushStatus,
prCreateStatus,
mergePrStatuses.squash,
mergePrStatuses.merge,
mergePrStatuses.rebase,
enablePrAutoMergeStatuses.squash,
enablePrAutoMergeStatuses.merge,
enablePrAutoMergeStatuses.rebase,
disablePrAutoMergeStatus,
mergeStatus,
mergeFromBaseStatus,
archiveStatus,
handleCommit,
handlePull,
handlePush,
handlePullAndPush,
handlePrAction,
handleMergePr,
handleEnablePrAutoMerge,
handleDisablePrAutoMerge,
handleMergeBranch,
handleMergeFromBase,
handleArchiveWorktree,
icons,
baseRef,
],
);
const gitActions: GitActions = useMemo(
() =>
translateGitActions(buildGitActions(gitActionsInput), { baseRefLabel, hasPullRequest, t }),
[gitActionsInput, baseRefLabel, hasPullRequest, t],
);
return { gitActions, branchLabel, isGit };
}

View File

@@ -183,6 +183,7 @@ describe("translation resources", () => {
expect(en.composer.github.title).toBe("Attach issue or PR");
expect(en.agentControls.provider.fallback).toBe("Provider");
expect(en.agentControls.hints.model).toBe("Change model");
expect(en.agentControls.hints.mode).toBe("Change mode");
expect(en.agentControls.features.title).toBe("Features");
expect(en.agentControls.mode.title).toBe("Mode");
expect(en.agentStream.permission.required).toBe("Permission Required");
@@ -243,6 +244,7 @@ describe("translation resources", () => {
expect(en.workspace.git.actions.commit.label).toBe("Commit");
expect(en.workspace.git.diff.binaryFile).toBe("Binary file");
expect(en.workspace.git.pr.sections.checks).toBe("Checks");
expect(en.workspace.git.pr.actions.viewPullRequest).toBe("View");
expect(en.review.comment.placeholder).toBe("Leave a comment");
});
@@ -288,6 +290,7 @@ describe("translation resources", () => {
expect(en.settings.shortcuts.sections.tabsPanes).toBe("Tabs & Panes");
expect(en.settings.shortcuts.help.toggleCommandCenter).toBe("Toggle command center");
expect(en.settings.shortcuts.help.newWorkspace).toBe("New workspace");
expect(en.settings.shortcuts.help.cycleAgentMode).toBe("Cycle agent mode");
expect(en.settings.shortcuts.helpNotes.showKeyboardShortcuts).toBe(
"Available when focus is not in a text field or terminal.",
);

View File

@@ -165,7 +165,7 @@ export const ar: TranslationResources = {
hints: {
thinking: "وضع التفكير",
model: "تغيير النموذج",
mode: "تغيير وضع الإذن",
mode: "تغيير الوضع",
},
},
agentStream: {
@@ -717,6 +717,9 @@ export const ar: TranslationResources = {
failedOpen: "فشل في فتح مساحة العمل",
},
pr: {
actions: {
viewPullRequest: "عرض",
},
sections: {
checks: "الشيكات",
reviews: "التعليقات",
@@ -1564,6 +1567,7 @@ export const ar: TranslationResources = {
toggleFocusMode: "تبديل وضع التركيز",
cycleTheme: "موضوع الدورة",
focusMessageInput: "التركيز على إدخال الرسالة",
cycleAgentMode: "تبديل وضع الوكيل",
toggleVoiceMode: "تبديل الوضع الصوتي",
startStopDictation: "بدء إملاء /stop",
interruptAgent: "عامل المقاطعة",

View File

@@ -163,7 +163,7 @@ export const en = {
hints: {
thinking: "Thinking mode",
model: "Change model",
mode: "Change permission mode",
mode: "Change mode",
},
},
agentStream: {
@@ -724,6 +724,9 @@ export const en = {
failedOpen: "Failed to open workspace",
},
pr: {
actions: {
viewPullRequest: "View",
},
sections: {
checks: "Checks",
reviews: "Reviews",
@@ -1570,6 +1573,7 @@ export const en = {
toggleFocusMode: "Toggle focus mode",
cycleTheme: "Cycle theme",
focusMessageInput: "Focus message input",
cycleAgentMode: "Cycle agent mode",
toggleVoiceMode: "Toggle voice mode",
startStopDictation: "Start/stop dictation",
interruptAgent: "Interrupt agent",

View File

@@ -165,7 +165,7 @@ export const es: TranslationResources = {
hints: {
thinking: "Modo de pensamiento",
model: "Cambiar modelo",
mode: "Cambiar modo de permiso",
mode: "Cambiar modo",
},
},
agentStream: {
@@ -744,6 +744,9 @@ export const es: TranslationResources = {
failedOpen: "No se pudo abrir el espacio de trabajo",
},
pr: {
actions: {
viewPullRequest: "Ver",
},
sections: {
checks: "cheques",
reviews: "Reseñas",
@@ -1602,6 +1605,7 @@ export const es: TranslationResources = {
toggleFocusMode: "Alternar modo de enfoque",
cycleTheme: "Tema del ciclo",
focusMessageInput: "Entrada de mensaje de enfoque",
cycleAgentMode: "Alternar modo del agente",
toggleVoiceMode: "Alternar modo de voz",
startStopDictation: "Iniciar dictado/stop",
interruptAgent: "agente de interrupción",

View File

@@ -166,7 +166,7 @@ export const fr: TranslationResources = {
hints: {
thinking: "Mode réflexion",
model: "Changer de modèle",
mode: "Changer le mode d'autorisation",
mode: "Changer de mode",
},
},
agentStream: {
@@ -743,6 +743,9 @@ export const fr: TranslationResources = {
failedOpen: "Échec de l'ouverture de l'espace de travail",
},
pr: {
actions: {
viewPullRequest: "Voir",
},
sections: {
checks: "Chèques",
reviews: "Avis",
@@ -1606,6 +1609,7 @@ export const fr: TranslationResources = {
toggleFocusMode: "Basculer le mode de mise au point",
cycleTheme: "Thème du cycle",
focusMessageInput: "Saisie du message de focus",
cycleAgentMode: "Parcourir les modes de l'agent",
toggleVoiceMode: "Changer le mode vocal",
startStopDictation: "Démarrer la dictée/stop",
interruptAgent: "Agent d'interruption",

View File

@@ -165,7 +165,7 @@ export const ru: TranslationResources = {
hints: {
thinking: "Режим мышления",
model: "Изменить модель",
mode: "Изменить режим разрешений",
mode: "Изменить режим",
},
},
agentStream: {
@@ -736,6 +736,9 @@ export const ru: TranslationResources = {
failedOpen: "Не удалось открыть рабочую область",
},
pr: {
actions: {
viewPullRequest: "Открыть",
},
sections: {
checks: "Чеки",
reviews: "Отзывы",
@@ -1594,6 +1597,7 @@ export const ru: TranslationResources = {
toggleFocusMode: "Переключить режим фокусировки",
cycleTheme: "Циклическая тема",
focusMessageInput: "Фокус ввода сообщения",
cycleAgentMode: "Переключить режим агента",
toggleVoiceMode: "Переключить голосовой режим",
startStopDictation: "Начать диктовку /stop",
interruptAgent: "Агент прерываний",

View File

@@ -165,7 +165,7 @@ export const zhCN: TranslationResources = {
hints: {
thinking: "Thinking mode",
model: "切换 Model",
mode: "切换权限 Mode",
mode: "更改模式",
},
},
agentStream: {
@@ -711,6 +711,9 @@ export const zhCN: TranslationResources = {
failedOpen: "打开 workspace 失败",
},
pr: {
actions: {
viewPullRequest: "查看",
},
sections: {
checks: "Checks",
reviews: "Reviews",
@@ -1545,6 +1548,7 @@ export const zhCN: TranslationResources = {
toggleFocusMode: "切换专注模式",
cycleTheme: "循环切换主题",
focusMessageInput: "聚焦消息输入框",
cycleAgentMode: "循环切换代理模式",
toggleVoiceMode: "切换语音模式",
startStopDictation: "开始/停止听写",
interruptAgent: "中断 Agent",

View File

@@ -12,7 +12,8 @@ export type MessageInputKeyboardActionKind =
| "dictation-cancel"
| "dictation-confirm"
| "voice-toggle"
| "voice-mute-toggle";
| "voice-mute-toggle"
| "mode-cycle";
export type KeyboardActionId =
| "agent.interrupt"

View File

@@ -9,6 +9,7 @@ export type KeyboardActionId =
| "message-input.dictation-confirm"
| "message-input.voice-toggle"
| "message-input.voice-mute-toggle"
| "message-input.mode-cycle"
| "workspace.tab.new"
| "workspace.tab.close-current"
| "workspace.tab.navigate-index"
@@ -39,6 +40,7 @@ export type KeyboardActionDefinition =
| { id: "message-input.dictation-confirm"; scope: KeyboardActionScope }
| { id: "message-input.voice-toggle"; scope: KeyboardActionScope }
| { id: "message-input.voice-mute-toggle"; scope: KeyboardActionScope }
| { id: "message-input.mode-cycle"; scope: KeyboardActionScope }
| { id: "workspace.tab.new"; scope: KeyboardActionScope }
| { id: "workspace.tab.close-current"; scope: KeyboardActionScope }
| { id: "workspace.tab.navigate-index"; scope: KeyboardActionScope; index: number }

View File

@@ -299,6 +299,13 @@ describe("keyboard-shortcuts", () => {
action: "message-input.action",
payload: { kind: "dictation-toggle" },
},
{
name: "routes Shift+Tab to cycle agent mode from the message input",
event: { key: "Tab", code: "Tab", shiftKey: true },
context: { focusScope: "message-input" },
action: "message-input.action",
payload: { kind: "mode-cycle" },
},
{
name: "routes space to voice mute toggle outside editable scopes",
event: { key: " ", code: "Space" },
@@ -427,6 +434,16 @@ describe("keyboard-shortcuts", () => {
event: { key: "d", code: "KeyD", metaKey: true },
context: { isMac: true, focusScope: "terminal" },
},
{
name: "does not cycle agent mode outside the message input",
event: { key: "Tab", code: "Tab", shiftKey: true },
context: { focusScope: "other" },
},
{
name: "does not repeat agent mode cycling while Shift+Tab is held",
event: { key: "Tab", code: "Tab", shiftKey: true, repeat: true },
context: { focusScope: "message-input" },
},
{
name: "does not bind Cmd+Enter as a rebindable message queue shortcut",
event: { key: "Enter", code: "Enter", metaKey: true },
@@ -570,6 +587,7 @@ describe("keyboard-shortcut help sections", () => {
"workspace-tab-close-current": ["alt", "shift", "W"],
"workspace-pane-split-right": ["mod", "\\"],
"workspace-pane-close": ["mod", "shift", "W"],
"cycle-agent-mode": ["shift", "Tab"],
},
},
{
@@ -617,12 +635,14 @@ describe("keyboard-shortcut help sections", () => {
const projects = sections.find((section) => section.id === "projects");
const panels = sections.find((section) => section.id === "panels");
const openProject = findRow(sections, "new-agent");
const cycleAgentMode = findRow(sections, "cycle-agent-mode");
const showShortcuts = findRow(sections, "show-shortcuts");
expect(projects?.titleKey).toBe("settings.shortcuts.sections.projects");
expect(panels?.titleKey).toBe("settings.shortcuts.sections.panels");
expect(openProject?.labelKey).toBe("settings.shortcuts.help.openProject");
expect(openProject?.label).toBe("Open project");
expect(cycleAgentMode?.labelKey).toBe("settings.shortcuts.help.cycleAgentMode");
expect(showShortcuts?.noteKey).toBe("settings.shortcuts.helpNotes.showKeyboardShortcuts");
});

View File

@@ -152,6 +152,7 @@ const SHORTCUT_HELP_LABEL_KEYS: Record<string, string> = {
"toggle-focus": "settings.shortcuts.help.toggleFocusMode",
"cycle-theme": "settings.shortcuts.help.cycleTheme",
"focus-message-input": "settings.shortcuts.help.focusMessageInput",
"cycle-agent-mode": "settings.shortcuts.help.cycleAgentMode",
"voice-toggle": "settings.shortcuts.help.toggleVoiceMode",
"dictation-toggle": "settings.shortcuts.help.startStopDictation",
"agent-interrupt": "settings.shortcuts.help.interruptAgent",
@@ -910,6 +911,20 @@ const SHORTCUT_BINDINGS: readonly ShortcutBinding[] = [
keys: ["mod", "L"],
},
},
{
id: "message-input-mode-cycle-shift-tab",
action: "message-input.action",
combo: "Shift+Tab",
repeat: false,
when: { commandCenter: false, focusScope: "message-input" },
payload: { type: "message-input", kind: "mode-cycle" },
help: {
id: "cycle-agent-mode",
section: "agent-input",
label: "Cycle agent mode",
keys: ["shift", "Tab"],
},
},
{
id: "message-input-voice-toggle-cmd-shift-d-mac",
action: "message-input.action",

View File

@@ -276,6 +276,7 @@ describe("routeKeyboardShortcut — message-input.action", () => {
["dictation-confirm", "message-input.dictation-confirm"],
["voice-toggle", "message-input.voice-toggle"],
["voice-mute-toggle", "message-input.voice-mute-toggle"],
["mode-cycle", "message-input.mode-cycle"],
] as const)("kind=%s → dispatch %s", (kind, id) => {
expect(
routeKeyboardShortcut({ action: "message-input.action", payload: { kind } }, makeCtx()),

View File

@@ -82,6 +82,7 @@ const MESSAGE_INPUT_DISPATCH: Record<
"dictation-confirm": { id: "message-input.dictation-confirm", scope: "message-input" },
"voice-toggle": { id: "message-input.voice-toggle", scope: "message-input" },
"voice-mute-toggle": { id: "message-input.voice-mute-toggle", scope: "message-input" },
"mode-cycle": { id: "message-input.mode-cycle", scope: "message-input" },
};
function hasPayloadKey<K extends "index" | "delta" | "kind">(

View File

@@ -0,0 +1,21 @@
import { createContext, type ReactNode, useContext } from "react";
const HostRouteServerIdContext = createContext<string | null>(null);
export function HostRouteProvider({
children,
serverId,
}: {
children: ReactNode;
serverId: string;
}) {
return (
<HostRouteServerIdContext.Provider value={serverId}>
{children}
</HostRouteServerIdContext.Provider>
);
}
export function useHostRouteServerId(): string | null {
return useContext(HostRouteServerIdContext);
}

View File

@@ -1,5 +1,6 @@
import { describe, expect, it, vi } from "vitest";
import {
resolveHostIndexRoute,
resolveStartupBlocker,
resolveStartupNavigationReady,
resolveStartupRoute,
@@ -200,6 +201,7 @@ describe("resolveStartupRoute", () => {
hosts: [],
anyOnlineHostServerId: null,
workspaceSelection: null,
workspaceSelectionStatus: "unknown" as const,
isWorkspaceSelectionLoaded: true,
hasGivenUpWaitingForHost: false,
};
@@ -255,10 +257,22 @@ describe("resolveStartupRoute", () => {
...baseIndexInput,
hosts: [{ serverId: "server-1" }],
workspaceSelection: { serverId: "server-1", workspaceId: "workspace-a" },
workspaceSelectionStatus: "exists",
}),
).toEqual({ kind: "redirect", href: "/h/server-1/workspace/workspace-a" });
});
it("does not restore a saved workspace after workspace hydration proves it is missing", () => {
expect(
resolveStartupRoute({
...baseIndexInput,
hosts: [{ serverId: "server-1" }],
workspaceSelection: { serverId: "server-1", workspaceId: "workspace-a" },
workspaceSelectionStatus: "missing",
}),
).toEqual({ kind: "redirect", href: "/h/server-1" });
});
it("falls back to a saved host when the restored workspace host is no longer saved", () => {
expect(
resolveStartupRoute({
@@ -344,3 +358,45 @@ describe("resolveStartupRoute", () => {
).toEqual({ kind: "redirect", href: "/welcome" });
});
});
describe("resolveHostIndexRoute", () => {
it("restores the remembered workspace when the host index is reopened for the same host", () => {
expect(
resolveHostIndexRoute({
serverId: "server-saved",
workspaceSelection: { serverId: "server-saved", workspaceId: "workspace-a" },
workspaceSelectionStatus: "exists",
}),
).toEqual("/h/server-saved/workspace/workspace-a");
});
it("keeps restoring a remembered workspace before the host workspace list hydrates", () => {
expect(
resolveHostIndexRoute({
serverId: "server-saved",
workspaceSelection: { serverId: "server-saved", workspaceId: "workspace-a" },
workspaceSelectionStatus: "unknown",
}),
).toEqual("/h/server-saved/workspace/workspace-a");
});
it("opens project selection when the remembered workspace is proven missing", () => {
expect(
resolveHostIndexRoute({
serverId: "server-saved",
workspaceSelection: { serverId: "server-saved", workspaceId: "workspace-a" },
workspaceSelectionStatus: "missing",
}),
).toEqual("/h/server-saved/open-project");
});
it("opens project selection when the remembered workspace belongs to another host", () => {
expect(
resolveHostIndexRoute({
serverId: "server-saved",
workspaceSelection: { serverId: "server-other", workspaceId: "workspace-a" },
workspaceSelectionStatus: "exists",
}),
).toEqual("/h/server-saved/open-project");
});
});

View File

@@ -136,6 +136,7 @@ export interface ResolveIndexStartupRouteInput extends ResolveStartupRouteBaseIn
route: IndexStartupRouteTarget;
anyOnlineHostServerId: string | null;
workspaceSelection: ActiveWorkspaceSelection | null;
workspaceSelectionStatus: WorkspaceSelectionStatus;
isWorkspaceSelectionLoaded: boolean;
hasGivenUpWaitingForHost: boolean;
}
@@ -151,6 +152,42 @@ export type StartupRouteDecision =
| { kind: "splash" }
| { kind: "redirect"; href: Href };
export type WorkspaceSelectionStatus = "unknown" | "exists" | "missing";
function shouldRestoreWorkspaceSelection(input: {
workspaceSelection: ActiveWorkspaceSelection | null;
workspaceSelectionStatus: WorkspaceSelectionStatus;
}): input is {
workspaceSelection: ActiveWorkspaceSelection;
workspaceSelectionStatus: Exclude<WorkspaceSelectionStatus, "missing">;
} {
return input.workspaceSelection !== null && input.workspaceSelectionStatus !== "missing";
}
export function resolveWorkspaceSelectionStatus(input: {
hasHydratedWorkspaces: boolean;
workspaceExists: boolean;
}): WorkspaceSelectionStatus {
if (input.workspaceExists) {
return "exists";
}
return input.hasHydratedWorkspaces ? "missing" : "unknown";
}
export function resolveHostIndexRoute(input: {
serverId: string;
workspaceSelection: ActiveWorkspaceSelection | null;
workspaceSelectionStatus: WorkspaceSelectionStatus;
}): Href {
if (
input.workspaceSelection?.serverId === input.serverId &&
shouldRestoreWorkspaceSelection(input)
) {
return buildHostWorkspaceRoute(input.serverId, input.workspaceSelection.workspaceId);
}
return buildHostOpenProjectRoute(input.serverId);
}
function isIndexPathname(pathname: string) {
return pathname === "/" || pathname === "";
}
@@ -171,11 +208,16 @@ function resolveReadyIndexStartupRoute(input: ResolveIndexStartupRouteInput): St
return { kind: "splash" };
}
const workspaceSelection = input.workspaceSelection;
if (workspaceSelection && hostExists(input.hosts, workspaceSelection.serverId)) {
if (
shouldRestoreWorkspaceSelection(input) &&
hostExists(input.hosts, input.workspaceSelection.serverId)
) {
return {
kind: "redirect",
href: buildHostWorkspaceRoute(workspaceSelection.serverId, workspaceSelection.workspaceId),
href: buildHostWorkspaceRoute(
input.workspaceSelection.serverId,
input.workspaceSelection.workspaceId,
),
};
}

View File

@@ -511,6 +511,59 @@ describe("resolveFormState", () => {
expect(resolved.thinkingOptionId).toBe("xhigh");
});
it("preserves the saved mode while provider modes are absent from a loading snapshot", () => {
const loadingEntries: ProviderSnapshotEntry[] = [
{
provider: "codex",
status: "loading",
enabled: true,
label: TEST_CODEX_DEFINITION.label,
description: TEST_CODEX_DEFINITION.description,
defaultModeId: TEST_CODEX_DEFINITION.defaultModeId,
},
];
const providerDefinitions = buildProviderDefinitions(loadingEntries);
const resolvableProviderMap = buildProviderDefinitionMapForStatuses({
snapshotEntries: loadingEntries,
providerDefinitions,
statuses: new Set<ProviderSnapshotEntry["status"]>(["ready", "loading"]),
});
const resolved = resolveFormState(
undefined,
{
provider: "codex",
providerPreferences: { codex: { mode: "full-access", model: "gpt-5.3-codex" } },
},
null,
INITIAL_USER_MODIFIED,
makeState({ provider: "codex", modeId: "full-access", model: "gpt-5.3-codex" }).form,
resolvableProviderMap,
);
expect(resolved.provider).toBe("codex");
expect(resolved.modeId).toBe("full-access");
});
it("preserves a saved mode that is not in the current mode list", () => {
const resolved = resolveFormState(
undefined,
{
provider: "codex",
providerPreferences: { codex: { mode: "workspace-write", model: "gpt-5.3-codex" } },
},
CODEX_MODELS,
INITIAL_USER_MODIFIED,
makeState({ provider: "codex" }).form,
codexProviderMap,
);
expect(resolved.provider).toBe("codex");
expect(resolved.modeId).toBe("workspace-write");
});
it("ignores disabled ready providers when resolving selectable defaults", () => {
const entries: ProviderSnapshotEntry[] = [
{

View File

@@ -144,6 +144,24 @@ export function resolveThinkingOptionId(args: {
return effectiveModel?.defaultThinkingOptionId ?? thinkingOptions[0]?.id ?? "";
}
const normalizeSelectedModeId = normalizeSelectedModelId;
function resolvePreferredModeId(input: {
initialModeId?: string | null;
preferredModeId?: string | null;
providerDef: AgentProviderDefinition | undefined;
}): string {
// Saved modes are user intent. Provider create config validates unknown modes
// at submission time, so background form resolution should not erase them.
const initialModeId = normalizeSelectedModeId(input.initialModeId);
if (initialModeId) return initialModeId;
const preferredModeId = normalizeSelectedModeId(input.preferredModeId);
if (preferredModeId) return preferredModeId;
return input.providerDef?.defaultModeId ?? input.providerDef?.modes[0]?.id ?? "";
}
export function mergeSelectedComposerPreferences(args: {
preferences: FormPreferences;
provider: AgentProvider;
@@ -259,18 +277,11 @@ function resolveModeId(input: {
input;
if (userModified) return currentModeId;
if (!provider) return "";
const validModeIds = providerDef?.modes.map((m) => m.id) ?? [];
if (
typeof initialValues?.modeId === "string" &&
initialValues.modeId.length > 0 &&
validModeIds.includes(initialValues.modeId)
) {
return initialValues.modeId;
}
if (providerPrefs?.mode && validModeIds.includes(providerPrefs.mode)) {
return providerPrefs.mode;
}
return providerDef?.defaultModeId ?? validModeIds[0] ?? "";
return resolvePreferredModeId({
initialModeId: initialValues?.modeId,
preferredModeId: providerPrefs?.mode,
providerDef,
});
}
function resolveModelField(input: {
@@ -411,11 +422,10 @@ function pickNextModeForProvider(input: {
providerPrefs: ProviderPrefs | undefined;
}): string {
const { providerDef, providerPrefs } = input;
const validModeIds = providerDef?.modes.map((m) => m.id) ?? [];
if (providerPrefs?.mode && validModeIds.includes(providerPrefs.mode)) {
return providerPrefs.mode;
}
return providerDef?.defaultModeId ?? "";
return resolvePreferredModeId({
preferredModeId: providerPrefs?.mode,
providerDef,
});
}
function pickNextModeForProviderAndModel(input: {
@@ -425,14 +435,8 @@ function pickNextModeForProviderAndModel(input: {
providerDef: AgentProviderDefinition | undefined;
providerPrefs: ProviderPrefs | undefined;
}): string {
const validModeIds = input.providerDef?.modes.map((m) => m.id) ?? [];
if (
input.currentProvider === input.provider &&
input.currentModeId &&
validModeIds.includes(input.currentModeId)
) {
return input.currentModeId;
}
const currentModeId = normalizeSelectedModeId(input.currentModeId);
if (input.currentProvider === input.provider && currentModeId) return currentModeId;
return pickNextModeForProvider({
providerDef: input.providerDef,
providerPrefs: input.providerPrefs,

View File

@@ -282,6 +282,7 @@ export function InlineReviewGutterCell({
reviewTarget,
comments,
isLineHovered = false,
lineHeight,
onStartComment,
style,
actionTestID,
@@ -291,6 +292,7 @@ export function InlineReviewGutterCell({
comments: readonly ReviewDraftComment[];
isEditorOpen: boolean;
isLineHovered?: boolean;
lineHeight?: number;
onStartComment: (target: ReviewableDiffTarget) => void;
style?: StyleProp<ViewStyle>;
actionTestID?: string;
@@ -334,10 +336,28 @@ export function InlineReviewGutterCell({
}, [isInteractionActive]);
const pressableStyle = useCallback((): StyleProp<ViewStyle> => style, [style]);
const lineHeightStyle = useMemo<StyleProp<ViewStyle>>(
() =>
lineHeight !== undefined
? inlineUnistylesStyle({ height: lineHeight, minHeight: lineHeight })
: null,
[lineHeight],
);
const labelStyle = useMemo<StyleProp<ViewStyle>>(
() => [styles.gutterLabel, hasComments && styles.gutterLabelActive],
[hasComments],
() => [styles.gutterLabel, lineHeightStyle, hasComments && styles.gutterLabelActive],
[hasComments, lineHeightStyle],
);
const innerStyle = useMemo<StyleProp<ViewStyle>>(
() => [styles.gutterInner, lineHeightStyle],
[lineHeightStyle],
);
const actionIconStyle = useMemo<StyleProp<ViewStyle>>(
() => [
styles.gutterActionIcon,
lineHeight !== undefined && inlineUnistylesStyle({ top: Math.floor((lineHeight - 22) / 2) }),
],
[lineHeight],
);
return (
@@ -353,11 +373,11 @@ export function InlineReviewGutterCell({
onPressOut={handlePressOut}
style={pressableStyle}
>
<View style={styles.gutterInner}>
<View style={innerStyle}>
<View style={labelStyle}>
{children}
{showAction ? (
<View style={styles.gutterActionIcon} testID={actionTestID}>
<View style={actionIconStyle} testID={actionTestID}>
<ThemedPlus size={16} strokeWidth={2.4} uniProps={accentForegroundIconColorMapping} />
</View>
) : null}

View File

@@ -0,0 +1,57 @@
import { useMemo } from "react";
import { useQuery } from "@tanstack/react-query";
import { useTranslation } from "react-i18next";
import { checkoutStatusQueryKey } from "@/git/query-keys";
import { fetchCheckoutStatus } from "@/git/checkout-status-cache";
import { canCreateWorkspaceTerminal } from "@/screens/workspace/terminals/state";
import { useHostRuntimeClient } from "@/runtime/host-runtime";
interface UseWorkspaceCheckoutStatusInput {
client: ReturnType<typeof useHostRuntimeClient>;
isConnected: boolean;
isRouteFocused: boolean;
normalizedServerId: string;
normalizedWorkspaceId: string;
workspaceDirectory: string | null;
}
export function useWorkspaceCheckoutStatus(input: UseWorkspaceCheckoutStatusInput) {
const { t } = useTranslation();
const isCheckoutQueryEnabled = useMemo(
() =>
canCreateWorkspaceTerminal({
isRouteFocused: input.isRouteFocused,
client: input.client,
isConnected: input.isConnected,
workspaceDirectory: input.workspaceDirectory,
}),
[input.client, input.isConnected, input.isRouteFocused, input.workspaceDirectory],
);
const checkoutQuery = useQuery({
queryKey: checkoutStatusQueryKey(
input.normalizedServerId,
input.workspaceDirectory ?? `missing-workspace-directory:${input.normalizedWorkspaceId}`,
),
enabled: isCheckoutQueryEnabled,
queryFn: async () => {
if (!input.client || !input.workspaceDirectory) {
throw new Error(t("workspace.terminal.hostDisconnected"));
}
return await fetchCheckoutStatus({
client: input.client,
serverId: input.normalizedServerId,
cwd: input.workspaceDirectory,
});
},
staleTime: Infinity,
refetchOnMount: true,
refetchOnReconnect: false,
refetchOnWindowFocus: false,
});
const isCheckoutStatusLoading = useMemo(
() => isCheckoutQueryEnabled && checkoutQuery.data === undefined && !checkoutQuery.isError,
[checkoutQuery.data, checkoutQuery.isError, isCheckoutQueryEnabled],
);
return { checkoutQuery, isCheckoutStatusLoading };
}

View File

@@ -0,0 +1,31 @@
import {
createWorkspaceFileTabTarget,
normalizeWorkspaceFileLocation,
} from "@/workspace/file-open";
import type { WorkspaceTabTarget } from "@/stores/workspace-tabs-store";
interface OpenWorkspaceFileFromExplorerInput {
filePath: string;
persistenceKey: string | null;
showMobileAgent: () => void;
openWorkspaceTabFocused: (workspaceKey: string, target: WorkspaceTabTarget) => string | null;
focusWorkspaceTab: (workspaceKey: string, tabId: string) => void;
}
export function openWorkspaceFileFromExplorer(input: OpenWorkspaceFileFromExplorerInput): void {
input.showMobileAgent();
if (!input.persistenceKey) {
return;
}
const location = normalizeWorkspaceFileLocation({ path: input.filePath });
if (!location) {
return;
}
const tabId = input.openWorkspaceTabFocused(
input.persistenceKey,
createWorkspaceFileTabTarget(location),
);
if (tabId) {
input.focusWorkspaceTab(input.persistenceKey, tabId);
}
}

View File

@@ -11,7 +11,7 @@ import {
import { useStoreWithEqualityFn } from "zustand/traditional";
import { useIsFocused } from "@react-navigation/native";
import { ActivityIndicator, BackHandler, Keyboard, Pressable, Text, View } from "react-native";
import { useQuery, useQueryClient } from "@tanstack/react-query";
import { useQueryClient } from "@tanstack/react-query";
import { useRouter, type Href } from "expo-router";
import * as Clipboard from "expo-clipboard";
import { useTranslation } from "react-i18next";
@@ -34,7 +34,6 @@ import {
SquareTerminal,
X,
} from "lucide-react-native";
import { GestureDetector } from "react-native-gesture-handler";
import { useSafeAreaInsets } from "react-native-safe-area-context";
import { StyleSheet, withUnistyles } from "react-native-unistyles";
import type { Theme } from "@/styles/theme";
@@ -66,9 +65,7 @@ import { WorkspaceGitActions } from "@/git/workspace-actions";
import { WorkspaceOpenInEditorButton } from "@/screens/workspace/workspace-open-in-editor-button";
import { WorkspaceScriptsButton } from "@/screens/workspace/workspace-scripts-button";
import { ImportSessionSheet } from "@/components/import-session-sheet";
import { ExplorerSidebarAnimationProvider } from "@/contexts/explorer-sidebar-animation-context";
import { useToast } from "@/contexts/toast-context";
import { useExplorerOpenGesture } from "@/hooks/use-explorer-open-gesture";
import { selectIsFileExplorerOpen, usePanelStore } from "@/stores/panel-store";
import { type ExplorerCheckoutContext } from "@/stores/explorer-checkout-context";
import {
@@ -105,8 +102,6 @@ import { shouldShowWorkspaceSetup, useWorkspaceSetupStore } from "@/stores/works
import { useWorkspace } from "@/stores/session-store-hooks";
import { useWorkspaceTerminalSessionRetention } from "@/terminal/hooks/use-workspace-terminal-session-retention";
import type { CheckoutStatusPayload } from "@/git/use-status-query";
import { checkoutStatusQueryKey } from "@/git/query-keys";
import { fetchCheckoutStatus } from "@/git/checkout-status-cache";
import { confirmDialog } from "@/utils/confirm-dialog";
import { useArchiveAgent } from "@/hooks/use-archive-agent";
import { useStableEvent } from "@/hooks/use-stable-event";
@@ -178,7 +173,6 @@ import {
buildSettingsHostRoute,
buildSettingsHostSectionRoute,
} from "@/utils/host-routes";
import { canCreateWorkspaceTerminal } from "@/screens/workspace/terminals/state";
import {
useWorkspaceTerminals,
type TerminalProfileInput,
@@ -196,6 +190,7 @@ import {
type WorkspaceFileOpenRequest,
} from "@/workspace/file-open";
import { RenderProfile } from "@/utils/render-profiler";
import { useWorkspaceCheckoutStatus } from "@/screens/workspace/use-workspace-checkout-status";
const WORKSPACE_SETUP_AUTO_OPEN_WINDOW_MS = 30_000;
const WORKSPACE_FLOATING_PANEL_PORTAL_HOST_PREFIX = "workspace-floating-panels";
@@ -203,7 +198,6 @@ const EMPTY_UI_TABS: WorkspaceTab[] = [];
const EMPTY_WORKSPACE_SCRIPTS: WorkspaceDescriptor["scripts"] = [];
const EMPTY_PINNED_AGENT_IDS = new Set<string>();
const EMPTY_SET = new Set<string>();
const COMPACT_WEB_GESTURE_TOUCH_ACTION = isWeb ? "auto" : "pan-y";
function getWorkspaceScripts(
workspaceDescriptor: WorkspaceDescriptor | null | undefined,
@@ -868,29 +862,6 @@ const MobileMountedTabSlot = memo(function MobileMountedTabSlot({
);
});
interface MobileExplorerOpenGestureSurfaceProps {
children: ReactNode;
enabled: boolean;
onOpenExplorer: () => void;
}
function MobileExplorerOpenGestureSurface({
children,
enabled,
onOpenExplorer,
}: MobileExplorerOpenGestureSurfaceProps) {
const explorerOpenGesture = useExplorerOpenGesture({
enabled,
onOpen: onOpenExplorer,
});
return (
<GestureDetector gesture={explorerOpenGesture} touchAction={COMPACT_WEB_GESTURE_TOUCH_ACTION}>
<View style={styles.content}>{children}</View>
</GestureDetector>
);
}
function useStableTabDescriptorMap(tabDescriptors: WorkspaceTabDescriptor[]) {
const cacheRef = useRef(new Map<string, WorkspaceTabDescriptor>());
const tabDescriptorMap = useMemo(() => {
@@ -923,16 +894,12 @@ export const WorkspaceScreen = memo(function WorkspaceScreen({
isRouteFocused,
}: WorkspaceScreenProps) {
const navigationFocused = useIsFocused();
const effectiveRouteFocused = isRouteFocused ?? navigationFocused;
return (
<ExplorerSidebarAnimationProvider>
<WorkspaceScreenContent
serverId={serverId}
workspaceId={workspaceId}
isRouteFocused={effectiveRouteFocused}
/>
</ExplorerSidebarAnimationProvider>
<WorkspaceScreenContent
serverId={serverId}
workspaceId={workspaceId}
isRouteFocused={isRouteFocused ?? navigationFocused}
/>
);
});
@@ -1594,7 +1561,7 @@ function shouldShowWorkspaceExplorerSidebar(input: {
isFocusModeEnabled: boolean;
isMobile: boolean;
}): boolean {
return input.isRouteFocused && shouldShowWorkspaceScreenHeader(input);
return !input.isMobile && input.isRouteFocused && shouldShowWorkspaceScreenHeader(input);
}
function buildWorkspaceTerminalScopeKey(serverId: string, workspaceId: string): string | null {
@@ -1676,56 +1643,6 @@ function useWorkspaceTerminalTabActions({
};
}
function useWorkspaceCheckoutStatus(input: {
client: ReturnType<typeof useHostRuntimeClient>;
isConnected: boolean;
isRouteFocused: boolean;
normalizedServerId: string;
normalizedWorkspaceId: string;
workspaceDirectory: string | null;
}) {
const { t } = useTranslation();
const isCheckoutQueryEnabled = useMemo(
() =>
canCreateWorkspaceTerminal({
isRouteFocused: input.isRouteFocused,
client: input.client,
isConnected: input.isConnected,
workspaceDirectory: input.workspaceDirectory,
}),
[input.isRouteFocused, input.client, input.isConnected, input.workspaceDirectory],
);
const checkoutQuery = useQuery({
queryKey: checkoutStatusQueryKey(
input.normalizedServerId,
input.workspaceDirectory ?? `missing-workspace-directory:${input.normalizedWorkspaceId}`,
),
enabled: isCheckoutQueryEnabled,
queryFn: async () => {
if (!input.client || !input.workspaceDirectory) {
throw new Error(t("workspace.terminal.hostDisconnected"));
}
return await fetchCheckoutStatus({
client: input.client,
serverId: input.normalizedServerId,
cwd: input.workspaceDirectory,
});
},
staleTime: Infinity,
// Refetch on mount only after explicit invalidation (e.g. reconnect) — see
// useCheckoutStatusQuery for the rationale.
refetchOnMount: true,
refetchOnReconnect: false,
refetchOnWindowFocus: false,
});
const isCheckoutStatusLoading = useMemo(
() => isCheckoutQueryEnabled && checkoutQuery.data === undefined && !checkoutQuery.isError,
[isCheckoutQueryEnabled, checkoutQuery.data, checkoutQuery.isError],
);
return { checkoutQuery, isCheckoutStatusLoading };
}
function WorkspaceScreenContent({
serverId,
workspaceId,
@@ -1890,7 +1807,6 @@ function WorkspaceScreenContent({
const isExplorerOpen = usePanelStore((state) =>
selectIsFileExplorerOpen(state, { isCompact: isMobile }),
);
const openFileExplorerForCheckout = usePanelStore((state) => state.openFileExplorerForCheckout);
const toggleFileExplorerForCheckout = usePanelStore(
(state) => state.toggleFileExplorerForCheckout,
);
@@ -1907,16 +1823,6 @@ function WorkspaceScreenContent({
};
}, [isGitCheckout, normalizedServerId, workspaceDirectory]);
const openExplorerForWorkspace = useCallback(() => {
if (!activeExplorerCheckout) {
return;
}
openFileExplorerForCheckout({
isCompact: isMobile,
checkout: activeExplorerCheckout,
});
}, [activeExplorerCheckout, isMobile, openFileExplorerForCheckout]);
const handleToggleExplorer = useCallback(() => {
if (!activeExplorerCheckout) {
return;
@@ -1964,7 +1870,7 @@ function WorkspaceScreenContent({
const workspaceSetupSnapshot = useWorkspaceSetupStore((state) =>
persistenceKey ? (state.snapshots[persistenceKey] ?? null) : null,
);
const upsertWorkspaceSetupProgress = useWorkspaceSetupStore((state) => state.upsertProgress);
const ensureWorkspaceSetupStatus = useWorkspaceSetupStore((state) => state.ensureSetupStatus);
const showWorkspaceSetup = shouldShowWorkspaceSetup(workspaceSetupSnapshot);
const uiTabs = useMemo(
() => (workspaceLayout ? collectAllTabs(workspaceLayout.root) : EMPTY_UI_TABS),
@@ -2176,54 +2082,22 @@ function WorkspaceScreenContent({
const emptyWorkspaceSeedRef = useRef<string | null>(null);
const autoOpenedSetupTabWorkspaceRef = useRef<string | null>(null);
const requestedWorkspaceSetupStatusKeyRef = useRef<string | null>(null);
useEffect(() => {
if (!isRouteFocused) {
if (!isRouteFocused || !client || !normalizedServerId || !normalizedWorkspaceId) {
return;
}
if (!client || !normalizedServerId || !normalizedWorkspaceId || !persistenceKey) {
return;
}
if (workspaceSetupSnapshot) {
return;
}
if (requestedWorkspaceSetupStatusKeyRef.current === persistenceKey) {
return;
}
requestedWorkspaceSetupStatusKeyRef.current = persistenceKey;
let isCancelled = false;
client
.fetchWorkspaceSetupStatus(normalizedWorkspaceId)
.then((response) => {
if (isCancelled || response.workspaceId !== normalizedWorkspaceId || !response.snapshot) {
return;
}
upsertWorkspaceSetupProgress({
serverId: normalizedServerId,
payload: { workspaceId: response.workspaceId, ...response.snapshot },
});
return;
})
.catch(() => {
if (requestedWorkspaceSetupStatusKeyRef.current === persistenceKey) {
requestedWorkspaceSetupStatusKeyRef.current = null;
}
});
return () => {
isCancelled = true;
};
ensureWorkspaceSetupStatus({
serverId: normalizedServerId,
workspaceId: normalizedWorkspaceId,
client,
});
}, [
client,
ensureWorkspaceSetupStatus,
isRouteFocused,
normalizedServerId,
normalizedWorkspaceId,
persistenceKey,
upsertWorkspaceSetupProgress,
workspaceSetupSnapshot,
]);
useEffect(() => {
@@ -2319,9 +2193,6 @@ function WorkspaceScreenContent({
const handleOpenFileFromExplorer = useCallback(
function handleOpenFileFromExplorer(filePath: string) {
if (isMobile) {
showMobileAgent();
}
if (!persistenceKey) {
return;
}
@@ -2334,7 +2205,7 @@ function WorkspaceScreenContent({
navigateToTabId(tabId);
}
},
[isMobile, navigateToTabId, openWorkspaceTabFocused, persistenceKey, showMobileAgent],
[navigateToTabId, openWorkspaceTabFocused, persistenceKey],
);
const handleOpenFileFromChat = useCallback(
@@ -3727,12 +3598,7 @@ function WorkspaceScreenContent({
<View style={styles.centerContent}>
{isMobile ? (
<MobileExplorerOpenGestureSurface
enabled={Boolean(activeExplorerCheckout)}
onOpenExplorer={openExplorerForWorkspace}
>
{content}
</MobileExplorerOpenGestureSurface>
<View style={styles.content}>{content}</View>
) : (
<View style={styles.content}>{desktopContent}</View>
)}

View File

@@ -1,9 +1,111 @@
import { beforeEach, describe, expect, it } from "vitest";
import { shouldShowWorkspaceSetup, useWorkspaceSetupStore } from "./workspace-setup-store";
import {
shouldShowWorkspaceSetup,
useWorkspaceSetupStore,
type WorkspaceSetupStatusClient,
type WorkspaceSetupStatusResult,
} from "./workspace-setup-store";
const DEFAULT_SNAPSHOT: WorkspaceSetupStatusResult["snapshot"] = {
status: "running",
detail: {
type: "worktree_setup",
worktreePath: "/Users/test/project",
branchName: "main",
log: "",
commands: [],
},
error: null,
};
function setupResult(
workspaceId: string,
snapshot: WorkspaceSetupStatusResult["snapshot"] = DEFAULT_SNAPSHOT,
): WorkspaceSetupStatusResult {
return { requestId: "req-1", workspaceId, snapshot };
}
function makeClient(handler: (workspaceId: string) => Promise<WorkspaceSetupStatusResult>) {
const calls: string[] = [];
const client: WorkspaceSetupStatusClient = {
fetchWorkspaceSetupStatus: (workspaceId) => {
calls.push(workspaceId);
return handler(workspaceId);
},
};
return { client, calls };
}
function createDeferred<T>() {
let resolve!: (value: T) => void;
let reject!: (reason: unknown) => void;
const promise = new Promise<T>((res, rej) => {
resolve = res;
reject = rej;
});
return { promise, resolve, reject };
}
function flush() {
return new Promise((resolve) => setTimeout(resolve, 0));
}
function storedSnapshots() {
return Object.values(useWorkspaceSetupStore.getState().snapshots);
}
function resolveDefault(workspaceId: string): Promise<WorkspaceSetupStatusResult> {
return Promise.resolve(setupResult(workspaceId));
}
function rejectThenResolve() {
let attempt = 0;
return (workspaceId: string): Promise<WorkspaceSetupStatusResult> => {
attempt += 1;
if (attempt === 1) {
return Promise.reject(new Error("boom"));
}
return resolveDefault(workspaceId);
};
}
function nullThenResolve() {
let attempt = 0;
return (workspaceId: string): Promise<WorkspaceSetupStatusResult> => {
attempt += 1;
if (attempt === 1) {
return Promise.resolve(setupResult(workspaceId, null));
}
return resolveDefault(workspaceId);
};
}
function mismatchThenResolve() {
let attempt = 0;
return (workspaceId: string): Promise<WorkspaceSetupStatusResult> => {
attempt += 1;
if (attempt === 1) {
return Promise.resolve(setupResult("999"));
}
return resolveDefault(workspaceId);
};
}
function ensureSetupStatus(client: WorkspaceSetupStatusClient) {
useWorkspaceSetupStore.getState().ensureSetupStatus({
serverId: "server-1",
workspaceId: "42",
client,
});
}
describe("workspace-setup-store", () => {
beforeEach(() => {
useWorkspaceSetupStore.setState({ pendingWorkspaceSetup: null });
useWorkspaceSetupStore.setState({
pendingWorkspaceSetup: null,
snapshots: {},
requestedKeys: new Set(),
});
});
it("tracks deferred workspace setup by source directory and optional workspace id", () => {
@@ -96,4 +198,118 @@ describe("workspace-setup-store", () => {
}),
).toBe(true);
});
it("ensureSetupStatus fetches setup status once and stores the snapshot", async () => {
const { client, calls } = makeClient(resolveDefault);
ensureSetupStatus(client);
await flush();
expect(calls).toEqual(["42"]);
expect(storedSnapshots()).toEqual([
expect.objectContaining({ workspaceId: "42", status: "running" }),
]);
});
it("ensureSetupStatus does not refetch while a request is in flight", async () => {
const deferred = createDeferred<WorkspaceSetupStatusResult>();
const { client, calls } = makeClient(() => deferred.promise);
ensureSetupStatus(client);
ensureSetupStatus(client);
expect(calls).toEqual(["42"]);
deferred.resolve(setupResult("42"));
await flush();
ensureSetupStatus(client);
expect(calls).toEqual(["42"]);
});
it("ensureSetupStatus skips fetching when a snapshot already exists", () => {
useWorkspaceSetupStore.getState().upsertProgress({
serverId: "server-1",
payload: { workspaceId: "42", ...DEFAULT_SNAPSHOT },
});
const { client, calls } = makeClient(resolveDefault);
ensureSetupStatus(client);
expect(calls).toEqual([]);
});
it("ensureSetupStatus ignores a response for a different workspace", async () => {
const { client } = makeClient(() => Promise.resolve(setupResult("999")));
ensureSetupStatus(client);
await flush();
expect(storedSnapshots()).toHaveLength(0);
});
it("ensureSetupStatus does not store a snapshot when the response snapshot is null", async () => {
const { client } = makeClient((workspaceId) => Promise.resolve(setupResult(workspaceId, null)));
ensureSetupStatus(client);
await flush();
expect(storedSnapshots()).toHaveLength(0);
});
it("ensureSetupStatus retries after a null-snapshot response", async () => {
const { client, calls } = makeClient(nullThenResolve());
ensureSetupStatus(client);
await flush();
expect(calls).toEqual(["42"]);
expect(storedSnapshots()).toHaveLength(0);
ensureSetupStatus(client);
await flush();
expect(calls).toEqual(["42", "42"]);
expect(storedSnapshots()).toHaveLength(1);
});
it("ensureSetupStatus retries after a mismatched-workspace response", async () => {
const { client, calls } = makeClient(mismatchThenResolve());
ensureSetupStatus(client);
await flush();
expect(calls).toEqual(["42"]);
expect(storedSnapshots()).toHaveLength(0);
ensureSetupStatus(client);
await flush();
expect(calls).toEqual(["42", "42"]);
expect(storedSnapshots()).toHaveLength(1);
});
it("ensureSetupStatus clears the in-flight marker on error so a later call retries", async () => {
const { client, calls } = makeClient(rejectThenResolve());
ensureSetupStatus(client);
await flush();
expect(calls).toEqual(["42"]);
expect(storedSnapshots()).toHaveLength(0);
ensureSetupStatus(client);
await flush();
expect(calls).toEqual(["42", "42"]);
expect(storedSnapshots()).toHaveLength(1);
});
it("ensureSetupStatus retries after the workspace is removed", async () => {
const { client, calls } = makeClient(resolveDefault);
ensureSetupStatus(client);
await flush();
expect(calls).toEqual(["42"]);
useWorkspaceSetupStore.getState().removeWorkspace({ serverId: "server-1", workspaceId: "42" });
ensureSetupStatus(client);
await flush();
expect(calls).toEqual(["42", "42"]);
});
});

View File

@@ -17,6 +17,15 @@ export type WorkspaceSetupProgressPayload = Extract<
{ type: "workspace_setup_progress" }
>["payload"];
export type WorkspaceSetupStatusResult = Extract<
SessionOutboundMessage,
{ type: "workspace_setup_status_response" }
>["payload"];
export interface WorkspaceSetupStatusClient {
fetchWorkspaceSetupStatus: (workspaceId: string) => Promise<WorkspaceSetupStatusResult>;
}
export interface WorkspaceSetupSnapshot extends WorkspaceSetupProgressPayload {
updatedAt: number;
}
@@ -31,9 +40,15 @@ export function shouldShowWorkspaceSetup(snapshot: WorkspaceSetupSnapshot | null
interface WorkspaceSetupStoreState {
pendingWorkspaceSetup: PendingWorkspaceSetup | null;
snapshots: Record<string, WorkspaceSetupSnapshot>;
requestedKeys: Set<string>;
beginWorkspaceSetup: (value: PendingWorkspaceSetup) => void;
clearWorkspaceSetup: () => void;
upsertProgress: (input: { serverId: string; payload: WorkspaceSetupProgressPayload }) => void;
ensureSetupStatus: (input: {
serverId: string;
workspaceId: string;
client: WorkspaceSetupStatusClient;
}) => void;
removeWorkspace: (input: { serverId: string; workspaceId: string }) => void;
clearServer: (serverId: string) => void;
}
@@ -42,9 +57,10 @@ function buildWorkspaceSetupKey(input: { serverId: string; workspaceId: string }
return buildWorkspaceTabPersistenceKey(input);
}
export const useWorkspaceSetupStore = create<WorkspaceSetupStoreState>()((set) => ({
export const useWorkspaceSetupStore = create<WorkspaceSetupStoreState>()((set, get) => ({
pendingWorkspaceSetup: null,
snapshots: {},
requestedKeys: new Set(),
beginWorkspaceSetup: (value) => {
set({ pendingWorkspaceSetup: value });
},
@@ -67,6 +83,40 @@ export const useWorkspaceSetupStore = create<WorkspaceSetupStoreState>()((set) =
},
}));
},
ensureSetupStatus: async ({ serverId, workspaceId, client }) => {
const key = buildWorkspaceSetupKey({ serverId, workspaceId });
if (!key) {
return;
}
const state = get();
if (state.snapshots[key] || state.requestedKeys.has(key)) {
return;
}
// requestedKeys is a pure in-flight marker: it dedupes concurrent fetches and is
// released once the request settles. A settle that stored no snapshot (null snapshot,
// mismatched workspace, or error) leaves no marker, so a later call can retry; once a
// snapshot lands, the snapshots[key] guard above prevents redundant refetches.
set((current) => ({ requestedKeys: new Set(current.requestedKeys).add(key) }));
try {
const response = await client.fetchWorkspaceSetupStatus(workspaceId);
if (response.workspaceId === workspaceId && response.snapshot) {
get().upsertProgress({
serverId,
payload: { workspaceId: response.workspaceId, ...response.snapshot },
});
}
} catch {
// Swallowed: the finally clears the in-flight marker so a later call retries.
} finally {
set((current) => {
const next = new Set(current.requestedKeys);
next.delete(key);
return { requestedKeys: next };
});
}
},
removeWorkspace: ({ serverId, workspaceId }) => {
const key = buildWorkspaceSetupKey({ serverId, workspaceId });
if (!key) {

View File

@@ -8,6 +8,12 @@ describe("formatShortcut", () => {
expect(formatShortcut(["mod", "E"], "mac")).toBe("⌘E");
});
it("spells out Shift in shortcut labels", () => {
expect(formatShortcut(["shift", "Tab"], "mac")).toBe("Shift+Tab");
expect(formatShortcut(["mod", "shift", "P"], "mac")).toBe("Shift+⌘+P");
expect(formatShortcut(["shift", "Tab"], "non-mac")).toBe("Shift+Tab");
});
it("uses Ctrl+ on non-mac platforms", () => {
expect(formatShortcut(["mod", "B"], "non-mac")).toBe("Ctrl+B");
expect(formatShortcut(["mod", "E"], "non-mac")).toBe("Ctrl+E");

View File

@@ -27,18 +27,22 @@ export function formatShortcut(keys: ShortcutKey[], os: ShortcutOs): string {
const order = ["ctrl", "alt", "shift", "mod", "meta"];
const symbols: Record<string, string> = {
mod: "⌘",
shift: "⇧",
alt: "⌥",
ctrl: "⌃",
meta: "⌘",
};
const modifierSet = new Set(normalized);
const mods = order.filter((k) => modifierSet.has(k)).map((k) => symbols[k] ?? "");
const mods = order
.filter((k) => modifierSet.has(k))
.map((k) => (k === "shift" ? "Shift" : (symbols[k] ?? "")));
const main = normalized
.filter((k) => !order.includes(k))
.map(normalizeKey)
.join("");
if (mods.includes("Shift")) {
return [...mods, main].filter(Boolean).join("+");
}
return `${mods.join("")}${main}`;
}

View File

@@ -1,6 +1,6 @@
{
"name": "@getpaseo/cli",
"version": "0.1.98",
"version": "0.1.100",
"description": "Paseo CLI - control your AI coding agents from the command line",
"bin": {
"paseo": "bin/paseo"
@@ -27,9 +27,9 @@
},
"dependencies": {
"@clack/prompts": "^1.0.0",
"@getpaseo/client": "0.1.98",
"@getpaseo/protocol": "0.1.98",
"@getpaseo/server": "0.1.98",
"@getpaseo/client": "0.1.100",
"@getpaseo/protocol": "0.1.100",
"@getpaseo/server": "0.1.100",
"chalk": "^5.3.0",
"commander": "^12.0.0",
"mime-types": "^2.1.35",

View File

@@ -1,6 +1,6 @@
{
"name": "@getpaseo/client",
"version": "0.1.98",
"version": "0.1.100",
"description": "Paseo client SDK package",
"files": [
"dist",
@@ -35,8 +35,8 @@
"test": "vitest run"
},
"dependencies": {
"@getpaseo/protocol": "0.1.98",
"@getpaseo/relay": "0.1.98",
"@getpaseo/protocol": "0.1.100",
"@getpaseo/relay": "0.1.100",
"zod": "^4.4.3"
},
"devDependencies": {

View File

@@ -1,6 +1,6 @@
{
"name": "@getpaseo/desktop",
"version": "0.1.98",
"version": "0.1.100",
"private": true,
"description": "Paseo desktop app (Electron wrapper)",
"homepage": "https://paseo.sh",

View File

@@ -1,6 +1,6 @@
{
"name": "@getpaseo/expo-two-way-audio",
"version": "0.1.98",
"version": "0.1.100",
"description": "Native module for two way audio streaming",
"keywords": [
"ExpoTwoWayAudio",

View File

@@ -1,6 +1,6 @@
{
"name": "@getpaseo/highlight",
"version": "0.1.98",
"version": "0.1.100",
"files": [
"dist",
"!dist/**/*.map"

View File

@@ -1,6 +1,6 @@
{
"name": "@getpaseo/protocol",
"version": "0.1.98",
"version": "0.1.100",
"description": "Paseo shared protocol schemas and wire types",
"files": [
"dist",

View File

@@ -1,6 +1,6 @@
{
"name": "@getpaseo/relay",
"version": "0.1.98",
"version": "0.1.100",
"description": "Paseo relay for bridging daemon and client connections",
"files": [
"dist",

View File

@@ -1,6 +1,6 @@
{
"name": "@getpaseo/server",
"version": "0.1.98",
"version": "0.1.100",
"description": "Paseo backend server",
"files": [
"dist/server",
@@ -65,10 +65,10 @@
"@agentclientprotocol/sdk": "^0.17.1",
"@anthropic-ai/claude-agent-sdk": "^0.3.181",
"@anthropic-ai/sdk": "^0.104.2",
"@getpaseo/client": "0.1.98",
"@getpaseo/highlight": "0.1.98",
"@getpaseo/protocol": "0.1.98",
"@getpaseo/relay": "0.1.98",
"@getpaseo/client": "0.1.100",
"@getpaseo/highlight": "0.1.100",
"@getpaseo/protocol": "0.1.100",
"@getpaseo/relay": "0.1.100",
"@isaacs/ttlcache": "^2.1.4",
"@modelcontextprotocol/sdk": "^1.20.1",
"@opencode-ai/sdk": "1.14.46",

View File

@@ -11,7 +11,6 @@ import type {
AgentCapabilityFlags,
AgentClient,
AgentLaunchContext,
AgentModelDefinition,
AgentPersistenceHandle,
AgentPromptInput,
AgentProvider,
@@ -21,6 +20,7 @@ import type {
AgentSessionConfig,
AgentStreamEvent,
AgentTimelineItem,
ProviderCatalog,
} from "./agent-sdk-types.js";
/**
@@ -206,15 +206,18 @@ class TestAgentClient implements AgentClient {
return this.createSession(resolvedConfig);
}
async listModels(): Promise<AgentModelDefinition[]> {
return [
{
provider: this.provider,
id: "test-model",
label: "Test Model",
isDefault: true,
},
];
async fetchCatalog(): Promise<ProviderCatalog> {
return {
models: [
{
provider: this.provider,
id: "test-model",
label: "Test Model",
isDefault: true,
},
],
modes: [],
};
}
async isAvailable(): Promise<boolean> {

View File

@@ -99,25 +99,28 @@ class TestAgentClient implements AgentClient {
return new TestAgentSession(config);
}
async listModels() {
return [
{
provider: "codex",
id: "gpt-5.4",
label: "GPT-5.4",
isDefault: true,
},
{
provider: "codex",
id: "gpt-5.4-mini",
label: "GPT-5.4 Mini",
},
{
provider: "codex",
id: "gpt-5.2-codex",
label: "GPT-5.2 Codex",
},
];
async fetchCatalog() {
return {
models: [
{
provider: "codex",
id: "gpt-5.4",
label: "GPT-5.4",
isDefault: true,
},
{
provider: "codex",
id: "gpt-5.4-mini",
label: "GPT-5.4 Mini",
},
{
provider: "codex",
id: "gpt-5.2-codex",
label: "GPT-5.2 Codex",
},
],
modes: [],
};
}
async resumeSession(
@@ -667,8 +670,11 @@ test("setAgentMode persists the selected mode across session reload", async () =
});
}
async listModels() {
return [{ provider: "codex", id: "gpt-5.4", label: "GPT-5.4", isDefault: true }];
async fetchCatalog() {
return {
models: [{ provider: "codex", id: "gpt-5.4", label: "GPT-5.4", isDefault: true }],
modes: [],
};
}
}
@@ -1454,15 +1460,18 @@ test("resumeAgentFromPersistence keeps metadata config, applies overrides, and p
return new TestAgentSession(config);
}
async listModels() {
return [
{
provider: "codex",
id: "gpt-5.4",
label: "GPT-5.4",
isDefault: true,
},
];
async fetchCatalog() {
return {
models: [
{
provider: "codex",
id: "gpt-5.4",
label: "GPT-5.4",
isDefault: true,
},
],
modes: [],
};
}
async resumeSession(
@@ -5976,8 +5985,8 @@ class RecordingPersistedAgentsClient implements AgentClient {
throw new Error(`unexpected resumeSession for ${this.provider}`);
}
async listModels() {
return [];
async fetchCatalog() {
return { models: [], modes: [] };
}
async listImportableSessions() {

View File

@@ -3644,8 +3644,8 @@ export class AgentManager {
const client = this.clients.get(normalized.provider);
if (client) {
try {
const models = await client.listModels({ cwd: normalized.cwd, force: false });
const defaultModel = models.find((model) => model.isDefault) ?? models[0];
const catalog = await client.fetchCatalog({ cwd: normalized.cwd, force: false });
const defaultModel = catalog.models.find((model) => model.isDefault) ?? catalog.models[0];
if (defaultModel) {
normalized.model = defaultModel.id;
}

View File

@@ -636,14 +636,14 @@ export interface AgentSession {
} | null;
}
export interface ListModelsOptions {
export interface FetchCatalogOptions {
cwd: string;
force: boolean;
}
export interface ListModesOptions {
cwd: string;
force: boolean;
export interface ProviderCatalog {
models: AgentModelDefinition[];
modes: AgentMode[];
}
export interface AgentClient {
@@ -659,8 +659,13 @@ export interface AgentClient {
overrides?: Partial<AgentSessionConfig>,
launchContext?: AgentLaunchContext,
): Promise<AgentSession>;
listModels(options: ListModelsOptions): Promise<AgentModelDefinition[]>;
listModes?(options: ListModesOptions): Promise<AgentMode[]>;
/**
* Discover models and modes together. Implementations may use one upstream
* process, separate upstream calls, static modes, or private helpers; callers
* outside the provider do not get separate runtime model/mode probes.
* The registry is responsible for merging configured model overrides.
*/
fetchCatalog(options: FetchCatalogOptions): Promise<ProviderCatalog>;
resolveCreateConfig?(input: ResolveAgentCreateConfigInput): ResolveAgentCreateConfigResult;
isCreateConfigUnattended?(input: AgentCreateConfigUnattendedInput): boolean;
listCommands?(config: AgentSessionConfig): Promise<AgentSlashCommand[]>;

View File

@@ -163,12 +163,9 @@ function createRecordingAgentClients(): Record<AgentProvider, AgentClient> {
},
resumeSession: async (handle, overrides, launchContext) =>
await client.resumeSession(handle, overrides, launchContext),
listModels: async (options) => await client.listModels(options),
fetchCatalog: async (options) => await client.fetchCatalog(options),
isAvailable: async () => await client.isAvailable(),
};
if (client.listModes) {
wrappedClient.listModes = async (options) => await client.listModes!(options);
}
if (client.resolveCreateConfig) {
wrappedClient.resolveCreateConfig = (input) => client.resolveCreateConfig!(input);
}

View File

@@ -1,12 +1,18 @@
import { beforeEach, describe, expect, test, vi } from "vitest";
import { createTestLogger } from "../../test-utils/test-logger.js";
import type { AgentModelDefinition } from "./agent-sdk-types.js";
import type {
AgentClient,
AgentModelDefinition,
AgentMode,
ProviderCatalog,
} from "./agent-sdk-types.js";
const mockState = vi.hoisted(() => {
interface ConstructorEntry {
runtimeSettings?: unknown;
providerParams?: unknown;
commandsRpcType?: unknown;
}
return {
@@ -76,12 +82,11 @@ vi.mock("./providers/claude/agent.js", () => ({
throw new Error("not implemented");
}
async listModels(): Promise<AgentModelDefinition[]> {
return mockState.runtimeModels.get(this.provider) ?? [];
}
async listModes(): Promise<[]> {
return [];
async fetchCatalog(): Promise<ProviderCatalog> {
return {
models: mockState.runtimeModels.get(this.provider) ?? [],
modes: [],
};
}
async isAvailable(): Promise<boolean> {
@@ -125,12 +130,11 @@ vi.mock("./providers/codex-app-server-agent.js", () => ({
throw new Error("not implemented");
}
async listModels(): Promise<AgentModelDefinition[]> {
return mockState.runtimeModels.get(this.provider) ?? [];
}
async listModes(): Promise<[]> {
return [];
async fetchCatalog(): Promise<ProviderCatalog> {
return {
models: mockState.runtimeModels.get(this.provider) ?? [],
modes: [],
};
}
async isAvailable(): Promise<boolean> {
@@ -176,12 +180,11 @@ vi.mock("./providers/copilot-acp-agent.js", () => ({
throw new Error("not implemented");
}
async listModels(): Promise<AgentModelDefinition[]> {
return mockState.runtimeModels.get(this.provider) ?? [];
}
async listModes(): Promise<[]> {
return [];
async fetchCatalog(): Promise<ProviderCatalog> {
return {
models: mockState.runtimeModels.get(this.provider) ?? [],
modes: [],
};
}
async isAvailable(): Promise<boolean> {
@@ -212,12 +215,20 @@ vi.mock("./providers/pi/agent.js", () => ({
readonly provider = "pi";
readonly runtimeSettings?: unknown;
constructor(options: { runtimeSettings?: unknown; providerParams?: unknown }) {
constructor(options: {
runtimeSettings?: unknown;
providerParams?: unknown;
commandsRpcType?: unknown;
}) {
this.runtimeSettings = options.runtimeSettings;
mockState.constructorArgs.pi.push({
const entry: ConstructorEntry = {
runtimeSettings: options.runtimeSettings,
providerParams: options.providerParams,
});
};
if (options.commandsRpcType !== undefined) {
entry.commandsRpcType = options.commandsRpcType;
}
mockState.constructorArgs.pi.push(entry);
}
async createSession(): Promise<never> {
@@ -228,12 +239,11 @@ vi.mock("./providers/pi/agent.js", () => ({
throw new Error("not implemented");
}
async listModels(): Promise<AgentModelDefinition[]> {
return mockState.runtimeModels.get(this.provider) ?? [];
}
async listModes(): Promise<[]> {
return [];
async fetchCatalog(): Promise<ProviderCatalog> {
return {
models: mockState.runtimeModels.get(this.provider) ?? [],
modes: [],
};
}
async isAvailable(): Promise<boolean> {
@@ -299,12 +309,11 @@ vi.mock("./providers/generic-acp-agent.js", () => ({
throw new Error("not implemented");
}
async listModels(): Promise<AgentModelDefinition[]> {
return mockState.runtimeModels.get(this.provider) ?? [];
}
async listModes(): Promise<[]> {
return [];
async fetchCatalog(): Promise<ProviderCatalog> {
return {
models: mockState.runtimeModels.get(this.provider) ?? [],
modes: [],
};
}
async isAvailable(): Promise<boolean> {
@@ -353,12 +362,11 @@ vi.mock("./providers/cursor-acp-agent.js", () => ({
throw new Error("not implemented");
}
async listModels(): Promise<AgentModelDefinition[]> {
return mockState.runtimeModels.get(this.provider) ?? [];
}
async listModes(): Promise<[]> {
return [];
async fetchCatalog(): Promise<ProviderCatalog> {
return {
models: mockState.runtimeModels.get(this.provider) ?? [],
modes: [],
};
}
async isAvailable(): Promise<boolean> {
@@ -459,6 +467,7 @@ test("OMP is a disabled built-in backed by the Pi adapter", () => {
providerParams: {
sessionDir: "~/.omp/agent/sessions",
},
commandsRpcType: "get_available_commands",
});
});
@@ -489,11 +498,10 @@ test("new provider extending claude appears in registry", () => {
expect(registry.zai.createClient(logger).provider).toBe("zai");
});
test("new provider extending pi passes params to the base provider constructor", () => {
test("built-in OMP override passes params to the Pi adapter constructor", () => {
const registry = buildProviderRegistry(logger, {
providerOverrides: {
omp: {
extends: "pi",
label: "OMP",
command: ["omp"],
params: {
@@ -516,6 +524,7 @@ test("new provider extending pi passes params to the base provider constructor",
providerParams: {
sessionDir: "~/.omp/agent/sessions",
},
commandsRpcType: "get_available_commands",
});
});
@@ -800,7 +809,7 @@ describe("model merging", () => {
},
});
const models = await registry.codex.fetchModels({
const { models } = await registry.codex.fetchCatalog({
cwd: "/tmp/registry-models",
force: false,
});
@@ -835,7 +844,7 @@ describe("model merging", () => {
},
});
const models = await registry.codex.fetchModels({
const { models } = await registry.codex.fetchCatalog({
cwd: "/tmp/registry-models",
force: false,
});
@@ -873,7 +882,7 @@ describe("model merging", () => {
},
});
const models = await registry.codex.fetchModels({
const { models } = await registry.codex.fetchCatalog({
cwd: "/tmp/registry-models",
force: false,
});
@@ -907,7 +916,7 @@ describe("model merging", () => {
},
});
const models = await registry.codex.fetchModels({
const { models } = await registry.codex.fetchCatalog({
cwd: "/tmp/registry-models",
force: false,
});
@@ -949,7 +958,7 @@ describe("model merging", () => {
},
});
const models = await registry.claude.fetchModels({
const { models } = await registry.claude.fetchCatalog({
cwd: "/tmp/registry-models",
force: false,
});
@@ -999,7 +1008,7 @@ describe("model merging", () => {
},
});
const models = await registry.claude.fetchModels({
const { models } = await registry.claude.fetchCatalog({
cwd: "/tmp/registry-models",
force: false,
});
@@ -1046,7 +1055,7 @@ describe("model merging", () => {
},
});
const models = await registry.codex.fetchModels({
const { models } = await registry.codex.fetchCatalog({
cwd: "/tmp/registry-models",
force: false,
});
@@ -1085,7 +1094,7 @@ describe("model merging", () => {
},
});
const models = await registry.claude.fetchModels({
const { models } = await registry.claude.fetchCatalog({
cwd: "/tmp/registry-models",
force: false,
});
@@ -1137,7 +1146,7 @@ describe("model merging", () => {
},
});
const models = await registry.claude.fetchModels({
const { models } = await registry.claude.fetchCatalog({
cwd: "/tmp/registry-models",
force: false,
});
@@ -1175,7 +1184,7 @@ describe("model merging", () => {
]);
const registry = buildProviderRegistry(logger);
const models = await registry.claude.fetchModels({
const { models } = await registry.claude.fetchCatalog({
cwd: "/tmp/registry-models",
force: false,
});
@@ -1190,7 +1199,7 @@ describe("model merging", () => {
]);
});
test("built-in createClient().listModels() honors profile model replacement (issue #579)", async () => {
test("built-in createClient().fetchCatalog() honors profile model replacement (issue #579)", async () => {
mockState.runtimeModels.set("codex", [
{
provider: "codex",
@@ -1215,16 +1224,16 @@ describe("model merging", () => {
});
const client = registry.codex.createClient(logger);
const models = await client.listModels({
const catalog = await client.fetchCatalog({
cwd: "/tmp/registry-models",
force: false,
});
expect(models.map((model) => model.id)).toEqual(["profile-fast"]);
expect(models.find((model) => model.isDefault)?.id).toBe("profile-fast");
expect(catalog.models.map((model) => model.id)).toEqual(["profile-fast"]);
expect(catalog.models.find((model) => model.isDefault)?.id).toBe("profile-fast");
});
test("built-in createClient().listModels() honors additionalModels default (issue #579)", async () => {
test("built-in createClient().fetchCatalog() honors additionalModels default (issue #579)", async () => {
mockState.runtimeModels.set("claude", [
{
provider: "claude",
@@ -1249,12 +1258,12 @@ describe("model merging", () => {
});
const client = registry.claude.createClient(logger);
const models = await client.listModels({
const catalog = await client.fetchCatalog({
cwd: "/tmp/registry-models",
force: false,
});
const defaultModel = models.find((model) => model.isDefault) ?? models[0];
const defaultModel = catalog.models.find((model) => model.isDefault) ?? catalog.models[0];
expect(defaultModel?.id).toBe("profile-default");
});
@@ -1277,7 +1286,7 @@ describe("model merging", () => {
},
});
const models = await registry.claude.fetchModels({
const { models } = await registry.claude.fetchCatalog({
cwd: "/tmp/registry-models",
force: false,
});
@@ -1286,3 +1295,111 @@ describe("model merging", () => {
expect(models.find((model) => model.isDefault)?.id).toBe("MiniMax-M3");
});
});
describe("fetchCatalog", () => {
test("returns merged models and modes from fetchCatalog", async () => {
mockState.runtimeModels.set("codex", [
{ provider: "codex", id: "codex-runtime", label: "Codex Runtime" },
]);
const registry = buildProviderRegistry(logger);
const catalog = await registry.codex.fetchCatalog({
cwd: "/tmp/catalog",
force: false,
});
expect(catalog.models.map((model) => model.id)).toEqual(["codex-runtime"]);
expect(catalog.modes).toEqual([]);
});
test("replacement models skip runtime model discovery but preserve additionalModels", async () => {
mockState.runtimeModels.set("codex", [
{ provider: "codex", id: "codex-runtime", label: "Codex Runtime" },
]);
const registry = buildProviderRegistry(logger, {
providerOverrides: {
codex: {
models: [{ id: "profile-model", label: "Profile Model" }],
additionalModels: [{ id: "extra-model", label: "Extra Model" }],
},
},
});
const catalog = await registry.codex.fetchCatalog({
cwd: "/tmp/catalog",
force: false,
});
expect(catalog.models.map((model) => model.id)).toEqual(["profile-model", "extra-model"]);
});
test("additionalModels can override replacement model fields", async () => {
const registry = buildProviderRegistry(logger, {
providerOverrides: {
codex: {
models: [{ id: "shared-model", label: "Profile Label" }],
additionalModels: [{ id: "shared-model", label: "Additional Label" }],
},
},
});
const catalog = await registry.codex.fetchCatalog({
cwd: "/tmp/catalog",
force: false,
});
expect(catalog.models).toEqual([
{
provider: "codex",
id: "shared-model",
label: "Additional Label",
},
]);
});
test("uses injected client instead of base client when provided", async () => {
const injectedModels: AgentModelDefinition[] = [
{ provider: "codex", id: "injected-model", label: "Injected Model" },
];
const injectedModes: AgentMode[] = [{ id: "agent", label: "Agent" }];
const injectedClient = {
provider: "codex",
capabilities: {},
fetchCatalog: vi.fn(async () => ({ models: injectedModels, modes: injectedModes })),
isAvailable: vi.fn(async () => true),
} satisfies Partial<AgentClient> as AgentClient;
const registry = buildProviderRegistry(logger);
const catalog = await registry.codex.fetchCatalog(
{ cwd: "/tmp/catalog", force: false },
injectedClient,
);
expect(injectedClient.fetchCatalog).toHaveBeenCalledTimes(1);
expect(catalog.models.map((model) => model.id)).toEqual(["injected-model"]);
expect(catalog.modes).toEqual(injectedModes);
});
test("uses injected client fetchCatalog when available", async () => {
const injectedClient = {
provider: "codex",
capabilities: {},
fetchCatalog: vi.fn(async () => ({
models: [{ provider: "codex", id: "catalog-model", label: "Catalog Model" }],
modes: [{ id: "ask", label: "Ask" }],
})),
isAvailable: vi.fn(async () => true),
} satisfies Partial<AgentClient> as AgentClient;
const registry = buildProviderRegistry(logger);
const catalog = await registry.codex.fetchCatalog(
{ cwd: "/tmp/catalog", force: false },
injectedClient,
);
expect(injectedClient.fetchCatalog).toHaveBeenCalledTimes(1);
expect(catalog.models.map((model) => model.id)).toEqual(["catalog-model"]);
expect(catalog.modes.map((mode) => mode.id)).toEqual(["ask"]);
});
});

View File

@@ -10,8 +10,8 @@ import type {
AgentRuntimeInfo,
AgentSession,
AgentStreamEvent,
ListModelsOptions,
ListModesOptions,
FetchCatalogOptions,
ProviderCatalog,
ResolveAgentCreateConfigInput,
ResolveAgentCreateConfigResult,
} from "./agent-sdk-types.js";
@@ -64,8 +64,11 @@ export interface ProviderDefinition extends AgentProviderDefinition {
createClient: (logger: Logger) => AgentClient;
resolveCreateConfig: (input: ResolveAgentCreateConfigInput) => ResolveAgentCreateConfigResult;
isCreateConfigUnattended: (input: AgentCreateConfigUnattendedInput) => boolean;
fetchModels: (options: ListModelsOptions) => Promise<AgentModelDefinition[]>;
fetchModes: (options: ListModesOptions) => Promise<AgentMode[]>;
/**
* Single catalog discovery call used by ProviderSnapshotManager. Should spawn
* at most one provider runtime process and return both models and modes.
*/
fetchCatalog: (options: FetchCatalogOptions, client?: AgentClient) => Promise<ProviderCatalog>;
}
export interface BuildProviderRegistryOptions {
@@ -153,6 +156,7 @@ const PROVIDER_CLIENT_FACTORIES: Record<string, ProviderClientFactory> = {
providerParams: options?.providerParams ?? {
sessionDir: "~/.omp/agent/sessions",
},
commandsRpcType: "get_available_commands",
}),
mock: (logger) => new MockLoadTestAgentClient(logger),
"mock-slow": () => new MockSlowProviderClient(),
@@ -424,11 +428,15 @@ function wrapClientProvider(
launchContext,
),
),
listModels: async (options) =>
mergeModels(provider, profileModels, additionalModels, await inner.listModels(options), {
profileModelsAreAdditive,
}),
listModes: inner.listModes?.bind(inner),
fetchCatalog: async (options) => {
const catalog = await inner.fetchCatalog(options);
return {
models: mergeModels(provider, profileModels, additionalModels, catalog.models, {
profileModelsAreAdditive,
}),
modes: catalog.modes,
};
},
resolveCreateConfig: inner.resolveCreateConfig?.bind(inner),
isCreateConfigUnattended: inner.isCreateConfigUnattended?.bind(inner),
listImportableSessions: listImportableSessions
@@ -473,6 +481,24 @@ function createRegistryEntry(
resolved: ResolvedProvider,
): ProviderDefinition {
const modelClient = resolved.createBaseClient(logger);
const hasReplacementModels =
resolved.profileModels.length > 0 && !resolved.profileModelsAreAdditive;
const replacementModels = hasReplacementModels
? resolved.profileModels.map((model) => mapModel(provider, model))
: [];
const decorateModes = (modes: AgentMode[]): AgentMode[] =>
modes.map((mode) => {
if (mode.icon && mode.colorTier) return mode;
const definitionMode = resolved.definition.modes.find((d) => d.id === mode.id);
if (!definitionMode) return mode;
return Object.assign({}, mode, {
icon: mode.icon ?? definitionMode.icon,
colorTier: mode.colorTier ?? definitionMode.colorTier,
});
});
const hasStaticModes = resolved.definition.modes.length > 0;
return {
...resolved.definition,
@@ -483,29 +509,36 @@ function createRegistryEntry(
resolveCreateConfig: modelClient.resolveCreateConfig ?? resolveDefaultAgentCreateConfig,
isCreateConfigUnattended:
modelClient.isCreateConfigUnattended ?? isDefaultAgentCreateConfigUnattended,
fetchModels: async (options: ListModelsOptions) =>
mergeModels(
provider,
resolved.profileModels,
resolved.additionalModels,
await modelClient.listModels(options),
{
profileModelsAreAdditive: resolved.profileModelsAreAdditive,
},
),
fetchModes: async (options: ListModesOptions) => {
const modes = modelClient.listModes
? await modelClient.listModes(options)
: resolved.definition.modes;
return modes.map((mode) => {
if (mode.icon && mode.colorTier) return mode;
const definitionMode = resolved.definition.modes.find((d) => d.id === mode.id);
if (!definitionMode) return mode;
return Object.assign({}, mode, {
icon: mode.icon ?? definitionMode.icon,
colorTier: mode.colorTier ?? definitionMode.colorTier,
});
});
fetchCatalog: async (options: FetchCatalogOptions, client?: AgentClient) => {
const catalogClient = client ?? modelClient;
if (hasReplacementModels) {
// Replacement models skip runtime model discovery, but additionalModels
// must still be merged on top. If modes are dynamic, probe for modes via
// the single catalog API; otherwise use static/empty modes with no runtime.
const models = mergeModelAdditions(provider, replacementModels, resolved.additionalModels);
if (hasStaticModes) {
return {
models,
modes: decorateModes(resolved.definition.modes),
};
}
const catalog = await catalogClient.fetchCatalog(options);
return { models, modes: decorateModes(catalog.modes) };
}
const catalog = await catalogClient.fetchCatalog(options);
return {
models: mergeModels(
provider,
resolved.profileModels,
resolved.additionalModels,
catalog.models,
{
profileModelsAreAdditive: resolved.profileModelsAreAdditive,
},
),
modes: decorateModes(catalog.modes),
};
},
};
}

View File

@@ -7,7 +7,7 @@ import type {
AgentMode,
AgentModelDefinition,
AgentProvider,
ListModelsOptions,
FetchCatalogOptions,
ResolveAgentCreateConfigInput,
} from "./agent-sdk-types.js";
import type { ManagedAgent } from "./agent-manager.js";
@@ -38,8 +38,8 @@ function createExtraClient(
async resumeSession() {
throw new Error("not implemented");
},
async listModels(_options: ListModelsOptions) {
return [] as AgentModelDefinition[];
async fetchCatalog(_options: FetchCatalogOptions) {
return { models: [] as AgentModelDefinition[], modes: [] as AgentMode[] };
},
async isAvailable() {
return false;
@@ -107,7 +107,10 @@ describe("ProviderSnapshotManager public surface", () => {
test("providerOverrides with enabled:false marks the provider as unavailable without probing", async () => {
const isAvailable = vi.fn(async () => true);
const fetchModels = vi.fn(async () => [] as AgentModelDefinition[]);
const fetchCatalog = vi.fn(async () => ({
models: [] as AgentModelDefinition[],
modes: [] as AgentMode[],
}));
const manager = new ProviderSnapshotManager({
logger: createTestLogger(),
providerOverrides: {
@@ -118,7 +121,7 @@ describe("ProviderSnapshotManager public surface", () => {
pi: { enabled: false },
},
extraClients: {
codex: createExtraClient("codex", { isAvailable, listModels: fetchModels }),
codex: createExtraClient("codex", { isAvailable, fetchCatalog }),
},
});
try {
@@ -126,7 +129,7 @@ describe("ProviderSnapshotManager public surface", () => {
const codex = entries.find((entry) => entry.provider === "codex");
expect(codex).toMatchObject({ provider: "codex", enabled: false, status: "unavailable" });
expect(isAvailable).not.toHaveBeenCalled();
expect(fetchModels).not.toHaveBeenCalled();
expect(fetchCatalog).not.toHaveBeenCalled();
} finally {
manager.destroy();
}
@@ -161,18 +164,20 @@ describe("ProviderSnapshotManager public surface", () => {
test("wait:true returns a warm provider without refreshing it", async () => {
const cwd = "/tmp/project";
const isAvailable = vi.fn(async () => true);
const listModels = vi.fn(async () => [
{
provider: "codex",
id: "gpt-5.4-mini",
label: "GPT 5.4 Mini",
},
]);
const listModes = vi.fn(async () => [] as AgentMode[]);
const fetchCatalog = vi.fn(async () => ({
models: [
{
provider: "codex",
id: "gpt-5.4-mini",
label: "GPT 5.4 Mini",
},
] as AgentModelDefinition[],
modes: [] as AgentMode[],
}));
const manager = new ProviderSnapshotManager({
logger: createTestLogger(),
extraClients: {
codex: createExtraClient("codex", { isAvailable, listModels, listModes }),
codex: createExtraClient("codex", { isAvailable, fetchCatalog }),
},
});
const listener = vi.fn();
@@ -181,16 +186,14 @@ describe("ProviderSnapshotManager public surface", () => {
const [first] = await manager.listProviders({ cwd, providers: ["codex"], wait: true });
expect(first).toMatchObject({ provider: "codex", status: "ready" });
expect(isAvailable).toHaveBeenCalledTimes(1);
expect(listModels).toHaveBeenCalledTimes(1);
expect(listModes).toHaveBeenCalledTimes(1);
expect(fetchCatalog).toHaveBeenCalledTimes(1);
listener.mockClear();
const [second] = await manager.listProviders({ cwd, providers: ["codex"], wait: true });
expect(second).toEqual(first);
expect(isAvailable).toHaveBeenCalledTimes(1);
expect(listModels).toHaveBeenCalledTimes(1);
expect(listModes).toHaveBeenCalledTimes(1);
expect(fetchCatalog).toHaveBeenCalledTimes(1);
expect(listener).not.toHaveBeenCalled();
} finally {
manager.destroy();
@@ -200,35 +203,37 @@ describe("ProviderSnapshotManager public surface", () => {
test("explicit refresh re-probes only the requested warm provider", async () => {
const cwd = "/tmp/project";
const isAvailableCodex = vi.fn(async () => true);
const listCodexModels = vi.fn(async () => [
{
provider: "codex",
id: "gpt-5.4-mini",
label: "GPT 5.4 Mini",
},
]);
const listCodexModes = vi.fn(async () => [] as AgentMode[]);
const fetchCodexCatalog = vi.fn(async () => ({
models: [
{
provider: "codex",
id: "gpt-5.4-mini",
label: "GPT 5.4 Mini",
},
] as AgentModelDefinition[],
modes: [] as AgentMode[],
}));
const isAvailableClaude = vi.fn(async () => true);
const listClaudeModels = vi.fn(async () => [
{
provider: "claude",
id: "claude-opus-4.5",
label: "Claude Opus 4.5",
},
]);
const listClaudeModes = vi.fn(async () => [] as AgentMode[]);
const fetchClaudeCatalog = vi.fn(async () => ({
models: [
{
provider: "claude",
id: "claude-opus-4.5",
label: "Claude Opus 4.5",
},
] as AgentModelDefinition[],
modes: [] as AgentMode[],
}));
const manager = new ProviderSnapshotManager({
logger: createTestLogger(),
extraClients: {
codex: createExtraClient("codex", {
isAvailable: isAvailableCodex,
listModels: listCodexModels,
listModes: listCodexModes,
fetchCatalog: fetchCodexCatalog,
}),
claude: createExtraClient("claude", {
isAvailable: isAvailableClaude,
listModels: listClaudeModels,
listModes: listClaudeModes,
fetchCatalog: fetchClaudeCatalog,
}),
},
});
@@ -237,11 +242,9 @@ describe("ProviderSnapshotManager public surface", () => {
await manager.refreshSnapshotForCwd({ cwd, providers: ["codex"] });
expect(isAvailableCodex).toHaveBeenCalledTimes(2);
expect(listCodexModels).toHaveBeenCalledTimes(2);
expect(listCodexModes).toHaveBeenCalledTimes(2);
expect(fetchCodexCatalog).toHaveBeenCalledTimes(2);
expect(isAvailableClaude).toHaveBeenCalledTimes(1);
expect(listClaudeModels).toHaveBeenCalledTimes(1);
expect(listClaudeModes).toHaveBeenCalledTimes(1);
expect(fetchClaudeCatalog).toHaveBeenCalledTimes(1);
} finally {
manager.destroy();
}
@@ -431,7 +434,7 @@ describe("ProviderSnapshotManager public surface", () => {
}
});
test("getProviderDiagnostic returns the diagnostic from the injected client", async () => {
test("getProviderDiagnostic returns the diagnostic from the injected client and appends snapshot models/status", async () => {
const getDiagnostic = vi.fn(async () => ({ diagnostic: "codex is ready" }));
const client = createExtraClient("codex", { getDiagnostic });
const manager = new ProviderSnapshotManager({
@@ -440,14 +443,44 @@ describe("ProviderSnapshotManager public surface", () => {
});
try {
const result = await manager.getProviderDiagnostic("codex");
expect(result).toEqual({ provider: "codex", diagnostic: "codex is ready" });
expect(result.provider).toBe("codex");
expect(result.diagnostic).toContain("codex is ready");
expect(result.diagnostic).toContain("Models:");
expect(result.diagnostic).toContain("Status:");
expect(getDiagnostic).toHaveBeenCalledTimes(1);
} finally {
manager.destroy();
}
});
test("getProviderDiagnostic falls back to a default message when the client has no getDiagnostic", async () => {
test("getProviderDiagnostic force-refreshes the snapshot via a single fetchCatalog call", async () => {
const catalogModels: AgentModelDefinition[] = [
{ provider: "codex", id: "gpt-5.4-mini", label: "GPT 5.4 Mini" },
];
const catalogModes: AgentMode[] = [{ id: "agent", label: "Agent" }];
const fetchCatalog = vi.fn(async () => ({
models: catalogModels,
modes: catalogModes,
}));
const client = createExtraClient("codex", {
isAvailable: async () => true,
fetchCatalog,
});
const manager = new ProviderSnapshotManager({
logger: createTestLogger(),
extraClients: { codex: client },
});
try {
const result = await manager.getProviderDiagnostic("codex");
expect(fetchCatalog).toHaveBeenCalledTimes(1);
expect(result.diagnostic).toContain("Models: 1");
expect(result.diagnostic).toContain("Status: Ready");
} finally {
manager.destroy();
}
});
test("getProviderDiagnostic falls back to a default message when the client has no getDiagnostic and appends snapshot models/status", async () => {
const manager = new ProviderSnapshotManager({
logger: createTestLogger(),
extraClients: { codex: createExtraClient("codex") },
@@ -456,15 +489,35 @@ describe("ProviderSnapshotManager public surface", () => {
const result = await manager.getProviderDiagnostic("codex");
expect(result.provider).toBe("codex");
expect(result.diagnostic).toMatch(/no diagnostic/i);
expect(result.diagnostic).toContain("Models:");
expect(result.diagnostic).toContain("Status:");
} finally {
manager.destroy();
}
});
test("getProviderDiagnostic throws when no client is configured for the provider", async () => {
test("getProviderDiagnostic materializes the client and proceeds for an unmaterialized configured provider", async () => {
const manager = new ProviderSnapshotManager({
logger: createTestLogger(),
isDev: true,
extraClients: {},
});
try {
const result = await manager.getProviderDiagnostic("mock");
expect(result.provider).toBe("mock");
expect(result.diagnostic).toContain("Models:");
expect(result.diagnostic).toContain("Status:");
} finally {
manager.destroy();
}
});
test("getProviderDiagnostic throws for an unknown provider", async () => {
const manager = new ProviderSnapshotManager({ logger: createTestLogger() });
try {
await expect(manager.getProviderDiagnostic("codex")).rejects.toThrow(/not configured/);
await expect(
manager.getProviderDiagnostic("unknown-provider" as AgentProvider),
).rejects.toThrow(/not configured/);
} finally {
manager.destroy();
}
@@ -509,8 +562,8 @@ describe("ProviderSnapshotManager public surface", () => {
async isAvailable() {
return true;
},
async listModes() {
return childModes;
async fetchCatalog() {
return { models: [] as AgentModelDefinition[], modes: childModes };
},
async resolveCreateConfig(input) {
resolverInputs.push(input);
@@ -524,8 +577,8 @@ describe("ProviderSnapshotManager public surface", () => {
async isAvailable() {
return true;
},
async listModes() {
return parentModes;
async fetchCatalog() {
return { models: [] as AgentModelDefinition[], modes: parentModes };
},
isCreateConfigUnattended(input) {
return input.modeId === "parent-unattended";
@@ -587,8 +640,8 @@ describe("ProviderSnapshotManager public surface", () => {
async isAvailable() {
return true;
},
async listModes() {
return modes;
async fetchCatalog() {
return { models: [] as AgentModelDefinition[], modes };
},
async resolveCreateConfig(input) {
resolverInputs.push(input);
@@ -646,8 +699,8 @@ describe("ProviderSnapshotManager public surface", () => {
async isAvailable() {
return true;
},
async listModes() {
return modes;
async fetchCatalog() {
return { models: [] as AgentModelDefinition[], modes };
},
resolveCreateConfig: openCode.resolveCreateConfig.bind(openCode),
isCreateConfigUnattended: openCode.isCreateConfigUnattended.bind(openCode),

View File

@@ -27,6 +27,7 @@ import {
type ProviderDefinition,
} from "./provider-registry.js";
import { applyMutableProviderConfigToOverrides } from "../daemon-config-store.js";
import { formatProviderDiagnostic } from "./providers/diagnostic-utils.js";
import type { MutableDaemonConfig } from "../daemon-config-store.js";
const DEFAULT_REFRESH_TIMEOUT_MS = 30_000;
@@ -312,13 +313,23 @@ export class ProviderSnapshotManager {
}
async getProviderDiagnostic(provider: AgentProvider): Promise<ProviderDiagnosticResult> {
const client = this.providerClients[provider];
if (!client) {
throw new Error(`Provider ${provider} is not configured`);
}
const diagnostic = client.getDiagnostic
const definition = this.requireProvider(provider);
const client = this.ensureClient(provider, definition);
// Force-refresh the snapshot so Models/Status come from the single catalog authority.
await this.refreshSnapshotForCwd({ cwd: homedir(), providers: [provider] });
const entry = await this.getProvider({ cwd: homedir(), provider, wait: true });
const modelCount = entry.status === "ready" ? String(entry.models?.length ?? 0) : "—";
const status = formatProviderStatus(entry);
const baseDiagnostic = client.getDiagnostic
? (await client.getDiagnostic()).diagnostic
: "No diagnostic available for this provider.";
: formatProviderDiagnostic(definition.label ?? provider, [
{ label: "Diagnostic", value: "No diagnostic available" },
]);
const diagnostic = `${baseDiagnostic}\n Models: ${modelCount}\n Status: ${status}`;
return { provider, diagnostic };
}
@@ -390,8 +401,7 @@ export class ProviderSnapshotManager {
client.resolveCreateConfig?.bind(client) ?? definition.resolveCreateConfig,
isCreateConfigUnattended:
client.isCreateConfigUnattended?.bind(client) ?? definition.isCreateConfigUnattended,
fetchModels: client.listModels.bind(client),
fetchModes: client.listModes?.bind(client) ?? definition.fetchModes,
fetchCatalog: client.fetchCatalog.bind(client),
};
}
@@ -644,11 +654,8 @@ export class ProviderSnapshotManager {
return;
}
const [models, modes] = await withTimeout(
Promise.all([
definition.fetchModels({ cwd, force }),
definition.fetchModes({ cwd, force }),
]),
const catalog = await withTimeout(
definition.fetchCatalog({ cwd, force }, client),
this.refreshTimeoutMs,
`Timed out refreshing ${definition.label} after ${this.refreshTimeoutMs}ms`,
);
@@ -657,8 +664,8 @@ export class ProviderSnapshotManager {
...base,
status: "ready",
enabled: true,
models,
modes,
models: catalog.models,
modes: catalog.modes,
fetchedAt: new Date().toISOString(),
});
} catch (error) {
@@ -806,3 +813,10 @@ function toErrorMessage(error: unknown): string {
}
return "Unknown error";
}
function formatProviderStatus(entry: ProviderSnapshotEntry): string {
if (entry.status === "ready") return "Ready";
if (entry.status === "error") return `Error: ${entry.error ?? "Unknown error"}`;
if (entry.status === "unavailable") return "Unavailable";
return "Loading";
}

View File

@@ -30,6 +30,7 @@ import {
} from "./acp-agent.js";
import type { ProcessTerminator, TreeKillTarget } from "../../../utils/tree-kill.js";
import {
COPILOT_AGENT_FEATURE_OPTION,
COPILOT_ALLOW_ALL_MODE_ID,
COPILOT_MODES,
CopilotACPAgentClient,
@@ -205,12 +206,16 @@ function selectConfigOption(
};
}
function createCopilotSessionWithConfig(modeId?: string | null): ACPAgentSession {
function createCopilotSessionWithConfig(
modeId?: string | null,
featureValues?: Record<string, unknown>,
): ACPAgentSession {
return new ACPAgentSession(
{
provider: "copilot",
cwd: "/tmp/paseo-acp-test",
modeId: modeId ?? undefined,
...(featureValues ? { featureValues } : {}),
},
{
provider: "copilot",
@@ -219,6 +224,7 @@ function createCopilotSessionWithConfig(modeId?: string | null): ACPAgentSession
defaultModes: COPILOT_MODES,
sessionResponseTransformer: transformCopilotSessionResponse,
configOptionsTransformer: transformCopilotConfigOptions,
configFeatureOptions: [COPILOT_AGENT_FEATURE_OPTION],
modeIdTransformer: transformCopilotModeId,
providerModeWriter: writeCopilotProviderMode,
beforeModeWriter: beforeCopilotModeWriter,
@@ -272,6 +278,27 @@ function copilotAllowAllConfigOption(currentValue: "on" | "off"): SessionConfigO
};
}
function copilotAgentConfigOption(currentValue: string): SessionConfigOption {
return {
id: "agent",
name: "Agent",
category: "_agent",
type: "select",
currentValue,
options: [
{
value: "",
name: "",
},
{
value: "Probe Agent",
name: "Probe Agent",
description: "Temporary probe agent",
},
],
};
}
function selectConfigOptionName(category: "mode" | "model" | "thought_level"): string {
if (category === "mode") {
return "Mode";
@@ -1137,6 +1164,90 @@ describe("ACPAgentSession Zed parity", () => {
]);
await expect(session.getCurrentMode()).resolves.toBe(COPILOT_ALLOW_ALL_MODE_ID);
});
test("exposes Copilot custom agents as a select feature", () => {
const session = createCopilotSessionWithConfig();
const internals = asInternals<ACPSessionInternals>(session);
internals.configOptions = [copilotAgentConfigOption("")];
expect(session.features).toEqual([
{
type: "select",
id: "agent",
label: "Agent",
description: "Use a Copilot custom agent profile",
tooltip: "Select Copilot agent",
icon: undefined,
value: "",
options: [
{
id: "",
label: "Default",
description: undefined,
isDefault: true,
metadata: undefined,
},
{
id: "Probe Agent",
label: "Probe Agent",
description: "Temporary probe agent",
isDefault: false,
metadata: undefined,
},
],
},
]);
});
test("applies configured Copilot custom agent before the first turn", async () => {
const setSessionConfigOption = vi.fn(async () => ({
configOptions: [copilotAgentConfigOption("Probe Agent")],
}));
const session = createCopilotSessionWithConfig(null, { agent: "Probe Agent" });
const { internals } = prepareConfiguredOverrideSession(session, {
configOptions: [copilotAgentConfigOption("")],
connection: { setSessionConfigOption },
});
await internals.applyConfiguredOverrides();
expect(setSessionConfigOption).toHaveBeenCalledWith({
sessionId: "session-1",
configId: "agent",
value: "Probe Agent",
});
expect(session.features).toEqual([
expect.objectContaining({
id: "agent",
value: "Probe Agent",
}),
]);
});
test("sets Copilot custom agent through ACP config options", async () => {
const setSessionConfigOption = vi.fn(async () => ({
configOptions: [copilotAgentConfigOption("Probe Agent")],
}));
const session = createCopilotSessionWithConfig();
prepareConfiguredOverrideSession(session, {
configOptions: [copilotAgentConfigOption("")],
connection: { setSessionConfigOption },
});
await session.setFeature("agent", "Probe Agent");
expect(setSessionConfigOption).toHaveBeenCalledWith({
sessionId: "session-1",
configId: "agent",
value: "Probe Agent",
});
expect(session.features).toEqual([
expect.objectContaining({
id: "agent",
value: "Probe Agent",
}),
]);
});
});
describe("deriveModelDefinitionsFromACP", () => {
@@ -1268,16 +1379,64 @@ describe("ACPAgentClient modelTransformer", () => {
modelTransformer: transformPiModels,
});
await expect(client.listModels({ cwd: "/tmp/acp-models", force: false })).resolves.toEqual([
{
provider: "pi",
id: "openrouter/openai/gpt-4.1-mini",
label: "gpt-4.1-mini",
description: "openrouter/openai/gpt-4.1-mini",
isDefault: true,
thinkingOptions: undefined,
defaultThinkingOptionId: undefined,
},
await expect(client.fetchCatalog({ cwd: "/tmp/acp-models", force: false })).resolves.toEqual({
models: [
{
provider: "pi",
id: "openrouter/openai/gpt-4.1-mini",
label: "gpt-4.1-mini",
description: "openrouter/openai/gpt-4.1-mini",
isDefault: true,
thinkingOptions: undefined,
defaultThinkingOptionId: undefined,
},
],
modes: [],
});
});
});
describe("ACPAgentClient config features", () => {
test("derives features from configured ACP select options", async () => {
class TestACPAgentClient extends ACPAgentClient {
protected override async spawnProcess(): Promise<SpawnedACPProcess> {
return {
child: { kill: vi.fn(), exitCode: 0, signalCode: null, once: vi.fn() },
connection: {
newSession: vi.fn().mockResolvedValue({
sessionId: "session-1",
configOptions: [copilotAgentConfigOption("Probe Agent")],
}),
},
initialize: { agentCapabilities: {} },
} as SpawnedACPProcess;
}
protected override async closeProbe(): Promise<void> {}
}
const client = new TestACPAgentClient({
provider: "copilot",
logger: createTestLogger(),
defaultCommand: ["copilot", "--acp"],
configFeatureOptions: [COPILOT_AGENT_FEATURE_OPTION],
});
await expect(
client.listFeatures({
provider: "copilot",
cwd: "/tmp/acp-features",
}),
).resolves.toEqual([
expect.objectContaining({
type: "select",
id: "agent",
value: "Probe Agent",
options: [
expect.objectContaining({ id: "", label: "Default", isDefault: false }),
expect.objectContaining({ id: "Probe Agent", label: "Probe Agent", isDefault: true }),
],
}),
]);
});
});
@@ -1307,7 +1466,7 @@ describe("ACPAgentClient sessionResponseTransformer", () => {
protected override async closeProbe(): Promise<void> {}
}
test("applies sessionResponseTransformer before deriving list probe modes", async () => {
test("applies sessionResponseTransformer before deriving catalog modes", async () => {
const client = new TestACPAgentClient({
provider: "claude-acp",
logger: createTestLogger(),
@@ -1322,18 +1481,21 @@ describe("ACPAgentClient sessionResponseTransformer", () => {
}),
});
await expect(client.listModes({ cwd: "/tmp/acp-modes", force: false })).resolves.toEqual([
{
id: "review",
label: "Review",
description: "After transform",
},
]);
await expect(client.fetchCatalog({ cwd: "/tmp/acp-modes", force: false })).resolves.toEqual({
models: [],
modes: [
{
id: "review",
label: "Review",
description: "After transform",
},
],
});
});
});
describe("ACPAgentClient listModes", () => {
test("passes the requested cwd to list model and mode probes", async () => {
describe("ACPAgentClient fetchCatalog", () => {
test("passes the requested cwd to the catalog probe", async () => {
const newSession = vi.fn().mockResolvedValue({ modes: null, models: null, configOptions: [] });
class TestACPAgentClient extends ACPAgentClient {
@@ -1355,20 +1517,15 @@ describe("ACPAgentClient listModes", () => {
defaultModes: [],
});
await client.listModels({ cwd: "/tmp/acp-model-cwd", force: false });
await client.listModes({ cwd: "/tmp/acp-mode-cwd", force: false });
await client.fetchCatalog({ cwd: "/tmp/acp-catalog-cwd", force: false });
expect(newSession).toHaveBeenNthCalledWith(1, {
cwd: "/tmp/acp-model-cwd",
mcpServers: [],
});
expect(newSession).toHaveBeenNthCalledWith(2, {
cwd: "/tmp/acp-mode-cwd",
expect(newSession).toHaveBeenCalledWith({
cwd: "/tmp/acp-catalog-cwd",
mcpServers: [],
});
});
test("returns an empty array when no ACP modes are reported and fallback modes are empty", async () => {
test("returns an empty modes array when no ACP modes are reported and fallback modes are empty", async () => {
class TestACPAgentClient extends ACPAgentClient {
protected override async spawnProcess(): Promise<SpawnedACPProcess> {
return {
@@ -1406,7 +1563,10 @@ describe("ACPAgentClient listModes", () => {
defaultModes: [],
});
await expect(client.listModes({ cwd: "/tmp/acp-modes", force: false })).resolves.toEqual([]);
await expect(client.fetchCatalog({ cwd: "/tmp/acp-modes", force: false })).resolves.toEqual({
models: [],
modes: [],
});
});
});
@@ -1912,6 +2072,130 @@ describe("ACPAgentSession", () => {
).toHaveLength(1);
});
test("startTurn dedupes ACP user echo chunks without message ids for the submitted message", async () => {
const session = createSession();
const events: AgentStreamEvent[] = [];
const prompt = vi.fn(() => new Promise<PromptResponse>(() => {}));
asInternals<ACPSessionInternals>(session).sessionId = "session-1";
asInternals<ACPSessionInternals>(session).connection = { prompt };
session.subscribe((event) => {
events.push(event);
});
await session.startTurn("hello", { messageId: "msg-client-1" });
await session.sessionUpdate({
sessionId: "session-1",
update: {
sessionUpdate: "user_message_chunk",
content: { type: "text", text: "hello" },
} as SessionUpdate,
});
expect(
events.filter((event) => event.type === "timeline" && event.item.type === "user_message"),
).toEqual([
{
type: "timeline",
provider: "claude-acp",
item: { type: "user_message", text: "hello", messageId: "msg-client-1" },
turnId: expect.any(String),
},
]);
});
test("startTurn dedupes ACP user echo chunks without message ids across turns", async () => {
const session = createSession();
const events: AgentStreamEvent[] = [];
let resolvePrompt!: (value: PromptResponse) => void;
const prompt = vi.fn(
() =>
new Promise<PromptResponse>((resolve) => {
resolvePrompt = resolve;
}),
);
asInternals<ACPSessionInternals>(session).sessionId = "session-1";
asInternals<ACPSessionInternals>(session).connection = { prompt };
session.subscribe((event) => {
events.push(event);
});
await session.startTurn("first", { messageId: "msg-client-1" });
await session.sessionUpdate({
sessionId: "session-1",
update: {
sessionUpdate: "user_message_chunk",
content: { type: "text", text: "first" },
} as SessionUpdate,
});
resolvePrompt({ stopReason: "end_turn" });
await Promise.resolve();
await Promise.resolve();
await session.startTurn("second", { messageId: "msg-client-2" });
await session.sessionUpdate({
sessionId: "session-1",
update: {
sessionUpdate: "user_message_chunk",
content: { type: "text", text: "second" },
} as SessionUpdate,
});
expect(
events.filter((event) => event.type === "timeline" && event.item.type === "user_message"),
).toEqual([
{
type: "timeline",
provider: "claude-acp",
item: { type: "user_message", text: "first", messageId: "msg-client-1" },
turnId: expect.any(String),
},
{
type: "timeline",
provider: "claude-acp",
item: { type: "user_message", text: "second", messageId: "msg-client-2" },
turnId: expect.any(String),
},
]);
});
test("startTurn dedupes ACP user echo chunks with provider-owned ids for the submitted message", async () => {
const session = createSession();
const events: AgentStreamEvent[] = [];
const prompt = vi.fn(() => new Promise<PromptResponse>(() => {}));
asInternals<ACPSessionInternals>(session).sessionId = "session-1";
asInternals<ACPSessionInternals>(session).connection = { prompt };
session.subscribe((event) => {
events.push(event);
});
await session.startTurn("hello", { messageId: "msg-client-1" });
await session.sessionUpdate({
sessionId: "session-1",
update: {
sessionUpdate: "user_message_chunk",
messageId: "msg-provider-1",
content: { type: "text", text: "hello" },
} as SessionUpdate,
});
expect(
events.filter((event) => event.type === "timeline" && event.item.type === "user_message"),
).toEqual([
{
type: "timeline",
provider: "claude-acp",
item: { type: "user_message", text: "hello", messageId: "msg-client-1" },
turnId: expect.any(String),
},
]);
});
test("startTurn converts background prompt rejections into turn_failed events", async () => {
const session = createSession();
const events: Array<{ type: string; turnId?: string; error?: string }> = [];
@@ -2159,7 +2443,7 @@ describe("ACPAgentClient probe cleanup", () => {
terminateProcess: terminator.terminate,
});
await client.listModels({ cwd: "/tmp/acp-models", force: false });
await client.fetchCatalog({ cwd: "/tmp/acp-models", force: false });
expect(terminator.terminated).toContain(child);
expect(child.stdin.destroyed).toBe(true);

View File

@@ -62,6 +62,7 @@ import {
getAgentStreamEventTurnId,
type AgentCapabilityFlags,
type AgentClient,
type AgentFeature,
type AgentLaunchContext,
type AgentMetadata,
type AgentMode,
@@ -81,13 +82,13 @@ import {
type AgentStreamEvent,
type AgentTimelineItem,
type AgentUsage,
type FetchCatalogOptions,
type ImportableProviderSession,
type ImportProviderSessionContext,
type ImportProviderSessionInput,
type ListImportableSessionsOptions,
type ListModesOptions,
type ListModelsOptions,
type McpServerConfig,
type ProviderCatalog,
type ToolCallDetail,
type ToolCallTimelineItem,
} from "../agent-sdk-types.js";
@@ -318,6 +319,7 @@ interface ACPAgentClientOptions {
modelTransformer?: (models: AgentModelDefinition[]) => AgentModelDefinition[];
sessionResponseTransformer?: (response: SessionStateResponse) => SessionStateResponse;
configOptionsTransformer?: (configOptions: SessionConfigOption[]) => SessionConfigOption[];
configFeatureOptions?: ACPConfigFeatureOption[];
modeIdTransformer?: (modeId: string) => string | null;
toolSnapshotTransformer?: (snapshot: ACPToolSnapshot) => ACPToolSnapshot;
providerModeWriter?: (
@@ -344,6 +346,7 @@ interface ACPAgentSessionOptions {
modelTransformer?: (models: AgentModelDefinition[]) => AgentModelDefinition[];
sessionResponseTransformer?: (response: SessionStateResponse) => SessionStateResponse;
configOptionsTransformer?: (configOptions: SessionConfigOption[]) => SessionConfigOption[];
configFeatureOptions?: ACPConfigFeatureOption[];
modeIdTransformer?: (modeId: string) => string | null;
toolSnapshotTransformer?: (snapshot: ACPToolSnapshot) => ACPToolSnapshot;
providerModeWriter?: (
@@ -393,6 +396,12 @@ interface MessageAssemblyState {
text: string;
}
interface SubmittedUserMessageEcho {
messageId: string;
text: string;
turnId: string;
}
export type SessionStateResponse = NewSessionResponse | LoadSessionResponse | ResumeSessionResponse;
interface TerminalExit {
@@ -420,6 +429,17 @@ interface ConfigOptionSelector {
metadata?: AgentMetadata;
}
export interface ACPConfigFeatureOption {
id: string;
configId: string;
category: string;
label: string;
description?: string;
tooltip?: string;
icon?: string;
emptyOptionLabel?: string;
}
type SelectConfigOption = Extract<SessionConfigOption, { type: "select" }>;
interface SelectConfigChoice {
value: string;
@@ -579,6 +599,31 @@ export function deriveModelDefinitionsFromACP(
}));
}
export function deriveFeaturesFromACP(
configOptions: SessionConfigOption[] | null | undefined,
featureOptions: ACPConfigFeatureOption[],
): AgentFeature[] {
return featureOptions.flatMap((featureOption) => {
const option = findSelectConfigFeatureOption(configOptions, featureOption);
if (!option) {
return [];
}
return [
{
type: "select",
id: featureOption.id,
label: featureOption.label,
description: featureOption.description,
tooltip: featureOption.tooltip,
icon: featureOption.icon,
value: option.currentValue ?? null,
options: deriveConfigFeatureSelectOptions(option, featureOption),
},
];
});
}
export class ACPAgentClient implements AgentClient {
readonly provider: string;
readonly capabilities: AgentCapabilityFlags;
@@ -594,6 +639,7 @@ export class ACPAgentClient implements AgentClient {
private readonly configOptionsTransformer?: (
configOptions: SessionConfigOption[],
) => SessionConfigOption[];
private readonly configFeatureOptions: ACPConfigFeatureOption[];
private readonly modeIdTransformer?: (modeId: string) => string | null;
private readonly toolSnapshotTransformer?: (snapshot: ACPToolSnapshot) => ACPToolSnapshot;
private readonly providerModeWriter?: (
@@ -625,6 +671,7 @@ export class ACPAgentClient implements AgentClient {
this.modelTransformer = options.modelTransformer;
this.sessionResponseTransformer = options.sessionResponseTransformer;
this.configOptionsTransformer = options.configOptionsTransformer;
this.configFeatureOptions = options.configFeatureOptions ?? [];
this.modeIdTransformer = options.modeIdTransformer;
this.toolSnapshotTransformer = options.toolSnapshotTransformer;
this.providerModeWriter = options.providerModeWriter;
@@ -650,6 +697,7 @@ export class ACPAgentClient implements AgentClient {
modelTransformer: this.modelTransformer,
sessionResponseTransformer: this.sessionResponseTransformer,
configOptionsTransformer: this.configOptionsTransformer,
configFeatureOptions: this.configFeatureOptions,
modeIdTransformer: this.modeIdTransformer,
toolSnapshotTransformer: this.toolSnapshotTransformer,
providerModeWriter: this.providerModeWriter,
@@ -696,6 +744,7 @@ export class ACPAgentClient implements AgentClient {
modelTransformer: this.modelTransformer,
sessionResponseTransformer: this.sessionResponseTransformer,
configOptionsTransformer: this.configOptionsTransformer,
configFeatureOptions: this.configFeatureOptions,
modeIdTransformer: this.modeIdTransformer,
toolSnapshotTransformer: this.toolSnapshotTransformer,
providerModeWriter: this.providerModeWriter,
@@ -712,7 +761,7 @@ export class ACPAgentClient implements AgentClient {
return session;
}
async listModels(options: ListModelsOptions): Promise<AgentModelDefinition[]> {
async fetchCatalog(options: FetchCatalogOptions): Promise<ProviderCatalog> {
const { cwd } = options;
const probe = await this.spawnProcess(PROBE_ENV);
try {
@@ -728,29 +777,36 @@ export class ACPAgentClient implements AgentClient {
transformed.models,
transformed.configOptions,
);
return this.modelTransformer ? this.modelTransformer(models) : models;
} finally {
await this.closeProbe(probe);
}
}
async listModes(options: ListModesOptions): Promise<AgentMode[]> {
const { cwd } = options;
const probe = await this.spawnProcess(PROBE_ENV);
try {
const response = await this.runACPRequest(() =>
probe.connection.newSession({
cwd,
mcpServers: [],
}),
);
const transformed = this.transformSessionResponse(response);
const modeInfo = deriveModesFromACP(
this.defaultModes,
transformed.modes,
transformed.configOptions,
);
return modeInfo.modes;
return {
models: this.modelTransformer ? this.modelTransformer(models) : models,
modes: modeInfo.modes,
};
} finally {
await this.closeProbe(probe);
}
}
async listFeatures(config: AgentSessionConfig): Promise<AgentFeature[]> {
if (this.configFeatureOptions.length === 0) {
return [];
}
this.assertProvider(config);
const probe = await this.spawnProcess(PROBE_ENV);
try {
const response = await this.runACPRequest(() =>
probe.connection.newSession({
cwd: config.cwd,
mcpServers: [],
}),
);
const transformed = this.transformSessionResponse(response);
return deriveFeaturesFromACP(transformed.configOptions, this.configFeatureOptions);
} finally {
await this.closeProbe(probe);
}
@@ -973,6 +1029,7 @@ export class ACPAgentSession implements AgentSession, ACPClient {
private readonly configOptionsTransformer?: (
configOptions: SessionConfigOption[],
) => SessionConfigOption[];
private readonly configFeatureOptions: ACPConfigFeatureOption[];
private readonly modeIdTransformer?: (modeId: string) => string | null;
private readonly toolSnapshotTransformer?: (snapshot: ACPToolSnapshot) => ACPToolSnapshot;
private readonly providerModeWriter?: (
@@ -992,6 +1049,7 @@ export class ACPAgentSession implements AgentSession, ACPClient {
private readonly pendingPermissions = new Map<string, PendingPermission>();
private readonly messageAssemblies = new Map<string, MessageAssemblyState>();
private readonly submittedUserMessageIds = new Set<string>();
private activeSubmittedUserMessage: SubmittedUserMessageEcho | null = null;
private readonly toolCalls = new Map<string, ACPToolSnapshot>();
private readonly terminalEntries = new Map<string, TerminalEntry>();
private readonly persistedHistory: AgentTimelineItem[] = [];
@@ -1034,6 +1092,7 @@ export class ACPAgentSession implements AgentSession, ACPClient {
this.modelTransformer = options.modelTransformer;
this.sessionResponseTransformer = options.sessionResponseTransformer;
this.configOptionsTransformer = options.configOptionsTransformer;
this.configFeatureOptions = options.configFeatureOptions ?? [];
this.modeIdTransformer = options.modeIdTransformer;
this.toolSnapshotTransformer = options.toolSnapshotTransformer;
this.providerModeWriter = options.providerModeWriter;
@@ -1150,6 +1209,7 @@ export class ACPAgentSession implements AgentSession, ACPClient {
const turnId = randomUUID();
const messageId = options?.messageId ?? randomUUID();
this.activeForegroundTurnId = turnId;
this.activeSubmittedUserMessage = null;
this.emitBootstrapThreadEvent();
this.pushEvent({ type: "turn_started", provider: this.provider, turnId });
this.emitSubmittedUserMessage(prompt, messageId, turnId);
@@ -1217,6 +1277,10 @@ export class ACPAgentSession implements AgentSession, ACPClient {
return this.currentMode;
}
get features(): AgentFeature[] {
return deriveFeaturesFromACP(this.configOptions, this.configFeatureOptions);
}
private ensureCommandsReadyDeferred(): void {
if (this.commandsReadyDeferred || this.commandsReadySettled || this.cachedCommands.length > 0) {
return;
@@ -1549,6 +1613,44 @@ export class ACPAgentSession implements AgentSession, ACPClient {
});
}
async setFeature(featureId: string, value: unknown): Promise<void> {
if (!this.connection || !this.sessionId) {
throw new Error("ACP session not initialized");
}
const featureOption = this.configFeatureOptions.find((option) => option.id === featureId);
if (!featureOption) {
throw new Error(`Unknown ${this.provider} feature: ${featureId}`);
}
const option = findSelectConfigFeatureOption(this.configOptions, featureOption);
if (!option) {
throw new Error(`${this.provider} does not expose ACP feature '${featureId}'`);
}
const requestedValue = normalizeConfigFeatureValue(value);
const choice = findSelectConfigChoice({ option, value: requestedValue });
if (!choice) {
throw new Error(
`${this.provider} feature '${featureId}' does not include option '${requestedValue}'`,
);
}
const response = await this.connection.setSessionConfigOption({
sessionId: this.sessionId,
configId: option.id,
value: requestedValue,
});
const currentValue = this.applyConfigOptionResponse({
response,
configId: option.id,
category: featureOption.category,
requestedValue,
label: featureOption.label,
});
this.config.featureValues = { ...this.config.featureValues, [featureId]: currentValue };
}
private applyConfigOptionResponse({
response,
configId,
@@ -2009,6 +2111,13 @@ export class ACPAgentSession implements AgentSession, ACPClient {
if (this.config.thinkingOptionId && this.config.thinkingOptionId !== this.thinkingOptionId) {
await this.setThinkingOption(this.config.thinkingOptionId);
}
const configuredFeatureValues = this.config.featureValues ?? {};
for (const featureOption of this.configFeatureOptions) {
if (!Object.prototype.hasOwnProperty.call(configuredFeatureValues, featureOption.id)) {
continue;
}
await this.setFeature(featureOption.id, configuredFeatureValues[featureOption.id]);
}
}
private warnInvalidSelection(value: string, message: string): void {
@@ -2030,7 +2139,10 @@ export class ACPAgentSession implements AgentSession, ACPClient {
if (!item) {
return [];
}
if (update.messageId && this.submittedUserMessageIds.has(update.messageId)) {
if (item.type !== "user_message") {
return [this.wrapTimeline(item)];
}
if (this.isSubmittedUserMessageEcho(item)) {
return [];
}
return [this.wrapTimeline(item)];
@@ -2113,7 +2225,7 @@ export class ACPAgentSession implements AgentSession, ACPClient {
if (!chunkText) {
return null;
}
const key = `${type}:${update.messageId ?? "default"}`;
const key = this.messageAssemblyKey(type, update.messageId);
const state = this.messageAssemblies.get(key) ?? { text: "" };
state.text += chunkText;
this.messageAssemblies.set(key, state);
@@ -2127,6 +2239,15 @@ export class ACPAgentSession implements AgentSession, ACPClient {
return { type: "reasoning", text: chunkText };
}
private messageAssemblyKey(
type: "user_message" | "assistant_message" | "reasoning",
messageId: string | null | undefined,
): string {
const fallbackId =
type === "user_message" ? (this.activeForegroundTurnId ?? "default") : "default";
return `${type}:${messageId ?? fallbackId}`;
}
private handleCurrentModeUpdate(update: CurrentModeUpdate): void {
this.currentMode = this.transformModeId(update.currentModeId);
}
@@ -2245,6 +2366,7 @@ export class ACPAgentSession implements AgentSession, ACPClient {
return;
}
this.submittedUserMessageIds.add(messageId);
this.activeSubmittedUserMessage = { messageId, text, turnId };
this.pushEvent({
type: "timeline",
provider: this.provider,
@@ -2271,9 +2393,27 @@ export class ACPAgentSession implements AgentSession, ACPClient {
event: Extract<AgentStreamEvent, { type: "turn_completed" | "turn_failed" | "turn_canceled" }>,
): void {
this.activeForegroundTurnId = null;
if (this.activeSubmittedUserMessage?.turnId === event.turnId) {
this.activeSubmittedUserMessage = null;
}
this.pushEvent(event);
}
private isSubmittedUserMessageEcho(
item: Extract<AgentTimelineItem, { type: "user_message" }>,
): boolean {
const active = this.activeSubmittedUserMessage;
if (!active || active.turnId !== this.activeForegroundTurnId) {
return false;
}
if (item.messageId) {
if (this.submittedUserMessageIds.has(item.messageId)) {
return true;
}
}
return active.text.startsWith(item.text);
}
private emitBootstrapThreadEvent(): void {
if (!this.bootstrapThreadEventPending || !this.sessionId) {
return;
@@ -2337,6 +2477,19 @@ function findSelectConfigOption({
return option ?? null;
}
function findSelectConfigFeatureOption(
configOptions: SessionConfigOption[] | null | undefined,
featureOption: ACPConfigFeatureOption,
): SelectConfigOption | null {
const option = configOptions?.find(
(entry): entry is SelectConfigOption =>
entry.type === "select" &&
entry.id === featureOption.configId &&
entry.category === featureOption.category,
);
return option ?? null;
}
function findSelectConfigChoice({
option,
value,
@@ -2364,6 +2517,43 @@ function flattenSelectOptions(options: SelectConfigOption["options"]): SelectCon
return flattened;
}
function deriveConfigFeatureSelectOptions(
option: SelectConfigOption,
featureOption: ACPConfigFeatureOption,
): ConfigOptionSelector[] {
return flattenSelectOptions(option.options).map((choice) => ({
id: choice.value,
label: normalizeConfigFeatureOptionLabel(choice, featureOption),
description: choice.description ?? undefined,
isDefault: choice.value === option.currentValue,
metadata: choice.group ? { group: choice.group } : undefined,
}));
}
function normalizeConfigFeatureOptionLabel(
choice: SelectConfigChoice,
featureOption: ACPConfigFeatureOption,
): string {
const name = choice.name.trim();
if (name) {
return name;
}
if (choice.value === "" && featureOption.emptyOptionLabel) {
return featureOption.emptyOptionLabel;
}
return choice.value;
}
function normalizeConfigFeatureValue(value: unknown): string {
if (typeof value === "string") {
return value;
}
if (value === null) {
return "";
}
throw new Error(`ACP feature value must be a string`);
}
function deriveSelectorOptions(
configOptions: SessionConfigOption[] | null | undefined,
category: string,

View File

@@ -395,7 +395,7 @@ describe("convertClaudeHistoryEntry", () => {
// "interrupting message should produce coherent text without garbling from race condition"
// in daemon.e2e.test.ts which exercises the full flow through the WebSocket API.
describe("ClaudeAgentClient.listModels", () => {
describe("ClaudeAgentClient.fetchCatalog", () => {
const logger = createTestLogger();
test("returns hardcoded claude models", async () => {
@@ -406,7 +406,7 @@ describe("ClaudeAgentClient.listModels", () => {
resolveBinary: async () => "/test/claude/bin",
configDir: emptyConfigDir,
});
const models = await client.listModels({ cwd: "/tmp/claude-models", force: false });
const { models } = await client.fetchCatalog({ cwd: "/tmp/claude-models", force: false });
expect(models.map((m) => m.id)).toEqual([
"claude-fable-5",
@@ -441,7 +441,7 @@ describe("ClaudeAgentClient.listModels", () => {
resolveBinary: async () => "/test/claude/bin",
configDir: emptyConfigDir,
});
const models = await client.listModels({ cwd: "/tmp/claude-models", force: false });
const { models } = await client.fetchCatalog({ cwd: "/tmp/claude-models", force: false });
const getThinkingIds = (modelId: string) => {
return models.find((model) => model.id === modelId)?.thinkingOptions?.map(({ id }) => id);
};
@@ -1046,7 +1046,7 @@ describe("ClaudeAgentSession context window usage", () => {
const logger = createTestLogger();
interface QueryFactoryForTurnsOptions {
currentContextUsageByTurn?: Array<Record<string, unknown> | undefined>;
getContextUsage?: ReturnType<typeof vi.fn>;
model?: string;
}
@@ -1091,8 +1091,8 @@ describe("ClaudeAgentSession context window usage", () => {
const queuedMessages: Array<Record<string, unknown>> = [];
const waiters: Array<() => void> = [];
let turnIndex = 0;
let contextUsageIndex = 0;
const closedRef = { value: false };
const getContextUsage = options?.getContextUsage ?? vi.fn(async () => undefined);
function wakeNextWaiter() {
const waiter = waiters.shift();
@@ -1140,11 +1140,7 @@ describe("ClaudeAgentSession context window usage", () => {
}),
setPermissionMode: vi.fn(async () => undefined),
setModel: vi.fn(async () => undefined),
getContextUsage: vi.fn(async () => {
const usage = options?.currentContextUsageByTurn?.[contextUsageIndex];
contextUsageIndex += 1;
return usage;
}),
getContextUsage,
supportedModels: vi.fn(async () => []),
supportedCommands: vi.fn(async () => []),
rewindFiles: vi.fn(async () => ({ canRewind: true })),
@@ -1165,26 +1161,6 @@ describe("ClaudeAgentSession context window usage", () => {
};
}
function createClaudeCurrentContextUsage(
totalTokens: number,
maxTokens: number,
): Record<string, unknown> {
return {
categories: [],
totalTokens,
maxTokens,
rawMaxTokens: maxTokens,
percentage: totalTokens / maxTokens,
gridRows: [],
model: "claude-sonnet-4-6",
memoryFiles: [],
mcpTools: [],
agents: [],
isAutoCompactEnabled: true,
apiUsage: null,
};
}
function createSuccessResult(overrides: Record<string, unknown> = {}): Record<string, unknown> {
return {
type: "result",
@@ -1282,6 +1258,21 @@ describe("ClaudeAgentSession context window usage", () => {
};
}
function createCompactBoundary(overrides: Record<string, unknown> = {}): Record<string, unknown> {
return {
type: "system",
subtype: "compact_boundary",
compact_metadata: {
trigger: "manual",
pre_tokens: 14_990,
post_tokens: 704,
},
uuid: "compact-boundary-1",
session_id: "session-1",
...overrides,
};
}
test("passes persistSession through to the Claude SDK query options", async () => {
const createResultTurn = (sessionId: string) => [
{
@@ -1556,7 +1547,10 @@ describe("ClaudeAgentSession context window usage", () => {
}
});
test("reports Claude's current context usage after an Agent subagent runs", async () => {
test("does not probe current context usage after an Agent subagent runs", async () => {
const getContextUsage = vi.fn(async () => {
throw new Error("getContextUsage should not be called during result handling");
});
const session = await createSessionForTurns(
[
[
@@ -1575,21 +1569,20 @@ describe("ClaudeAgentSession context window usage", () => {
}),
],
],
{
currentContextUsageByTurn: [createClaudeCurrentContextUsage(12_345, 200_000)],
},
{ getContextUsage },
);
try {
const result = await session.run("turn");
expect(getContextUsage).not.toHaveBeenCalled();
expect(result.usage).toEqual({
inputTokens: 9_000,
cachedInputTokens: 700,
outputTokens: 400,
totalCostUsd: 0.25,
contextWindowMaxTokens: 200_000,
contextWindowUsedTokens: 12_345,
contextWindowUsedTokens: 175,
});
} finally {
await session.close();
@@ -1637,6 +1630,121 @@ describe("ClaudeAgentSession context window usage", () => {
}
});
test("uses parent request usage after a real subagent tool result", async () => {
const getContextUsage = vi.fn(async () => {
throw new Error("getContextUsage should not be called during result handling");
});
const session = await createSessionForTurns(
[
[
createInitMessage(),
createMessageStartEvent({
input_tokens: 3,
cache_creation_input_tokens: 16_999,
cache_read_input_tokens: 0,
}),
createAgentToolStartEvent(),
createMessageDeltaEvent(163),
{
type: "assistant",
parent_tool_use_id: "toolu-agent-1",
message: {
role: "assistant",
content: [{ type: "text", text: "SUBAGENT_OK" }],
usage: {
input_tokens: 3,
cache_creation_input_tokens: 1_182,
cache_read_input_tokens: 0,
output_tokens: 8,
},
},
uuid: "subagent-assistant-1",
session_id: "session-1",
},
{
...createSubagentTaskNotification(),
status: "completed",
summary: "Probe subagent test",
usage: {
total_tokens: 1_193,
tool_uses: 0,
},
},
{
type: "user",
parent_tool_use_id: null,
message: {
role: "user",
content: [
{
type: "tool_result",
tool_use_id: "toolu-agent-1",
content: [
{ type: "text", text: "SUBAGENT_OK" },
{
type: "text",
text: "agentId: subagent-1\n<usage>subagent_tokens: 1194\ntool_uses: 0</usage>",
},
],
},
],
},
uuid: "subagent-tool-result-1",
session_id: "session-1",
},
createMessageStartEvent({
input_tokens: 1,
cache_creation_input_tokens: 253,
cache_read_input_tokens: 16_999,
}),
createMessageDeltaEvent(8),
createSuccessResult({
usage: {
input_tokens: 4,
cache_creation_input_tokens: 17_252,
cache_read_input_tokens: 16_999,
output_tokens: 171,
iterations: [
{
input_tokens: 1,
cache_creation_input_tokens: 253,
cache_read_input_tokens: 16_999,
output_tokens: 8,
},
],
},
modelUsage: {
"claude-sonnet-4-6": {
inputTokens: 7,
outputTokens: 180,
cacheReadInputTokens: 16_999,
cacheCreationInputTokens: 18_434,
contextWindow: 200_000,
},
},
}),
],
],
{ getContextUsage },
);
try {
const result = await session.run("turn");
expect(getContextUsage).not.toHaveBeenCalled();
expect(result.usage).toEqual({
inputTokens: 4,
cachedInputTokens: 16_999,
outputTokens: 171,
totalCostUsd: 0.25,
contextWindowMaxTokens: 200_000,
contextWindowUsedTokens: 17_261,
});
} finally {
await session.close();
}
});
test("falls back to the active result iteration when current and stream usage are unavailable", async () => {
const session = await createSessionForTurns([
[
@@ -1842,6 +1950,165 @@ describe("ClaudeAgentSession context window usage", () => {
}
});
test("manual compact boundary updates context usage from post tokens", async () => {
const session = await createSessionForTurns([
[
createInitMessage(),
createMessageStartEvent(),
createMessageDeltaEvent(25),
createCompactBoundary(),
createSuccessResult({
total_cost_usd: 0.04,
usage: {
input_tokens: 0,
cache_creation_input_tokens: 0,
cache_read_input_tokens: 0,
output_tokens: 0,
iterations: [],
},
}),
],
]);
try {
const events = await collectStreamEvents(session, "/compact");
expect(events).toContainEqual(
expect.objectContaining({
type: "usage_updated",
provider: "claude",
usage: {
contextWindowUsedTokens: 704,
},
}),
);
expect(events).toContainEqual(
expect.objectContaining({
type: "turn_completed",
provider: "claude",
usage: {
inputTokens: 0,
cachedInputTokens: 0,
outputTokens: 0,
totalCostUsd: 0.04,
contextWindowMaxTokens: 200_000,
contextWindowUsedTokens: 704,
},
}),
);
} finally {
await session.close();
}
});
test("zero-token stream events after compact keep post-token usage", async () => {
const session = await createSessionForTurns([
[
createInitMessage(),
createMessageStartEvent(),
createMessageDeltaEvent(25),
createCompactBoundary(),
createMessageStartEvent({
input_tokens: 0,
cache_creation_input_tokens: 0,
cache_read_input_tokens: 0,
}),
createMessageDeltaEvent(0),
createSuccessResult({
total_cost_usd: 0.04,
usage: {
input_tokens: 0,
cache_creation_input_tokens: 0,
cache_read_input_tokens: 0,
output_tokens: 0,
iterations: [],
},
}),
],
]);
try {
const events = await collectStreamEvents(session, "/compact");
expect(
events.filter(
(event) => event.type === "usage_updated" && event.usage.contextWindowUsedTokens === 0,
),
).toEqual([]);
expect(events).toContainEqual(
expect.objectContaining({
type: "turn_completed",
provider: "claude",
usage: {
inputTokens: 0,
cachedInputTokens: 0,
outputTokens: 0,
totalCostUsd: 0.04,
contextWindowMaxTokens: 200_000,
contextWindowUsedTokens: 704,
},
}),
);
} finally {
await session.close();
}
});
test("starting a new turn clears interrupted compact usage", async () => {
const session = await createSessionForTurns([
[
createSuccessResult({
total_cost_usd: 0.04,
usage: {
input_tokens: 0,
cache_creation_input_tokens: 0,
cache_read_input_tokens: 0,
output_tokens: 0,
iterations: [],
},
}),
],
]);
try {
const compactEvents = (session as unknown as TestClaudeSession).translateMessageToEvents(
createCompactBoundary(),
);
expect(compactEvents).toContainEqual(
expect.objectContaining({
type: "usage_updated",
provider: "claude",
usage: {
contextWindowUsedTokens: 704,
},
}),
);
const events = await collectStreamEvents(session, "next turn");
expect(events).toContainEqual(
expect.objectContaining({
type: "turn_completed",
provider: "claude",
usage: expect.objectContaining({
inputTokens: 0,
cachedInputTokens: 0,
outputTokens: 0,
totalCostUsd: 0.04,
}),
}),
);
expect(
events.some(
(event) =>
event.type === "turn_completed" && event.usage.contextWindowUsedTokens !== undefined,
),
).toBe(false);
} finally {
await session.close();
}
});
test("result.result is surfaced as an assistant message when no model output was produced", async () => {
const session = await createSessionForTest();

View File

@@ -11,7 +11,6 @@ import {
type PermissionResult,
type PermissionUpdate,
type Query,
type SDKControlGetContextUsageResponse,
type SDKMessage,
type SDKPartialAssistantMessage,
type SDKResultMessage,
@@ -36,10 +35,8 @@ import { buildClaudeFeatures, claudeModelSupportsFastMode } from "./feature-defi
import {
buildBinaryDiagnosticRows,
buildCommandResolutionDiagnosticRows,
formatDiagnosticStatus,
formatProviderDiagnostic,
formatProviderDiagnosticError,
toDiagnosticErrorMessage,
} from "../diagnostic-utils.js";
import { appendOrReplaceGrowingAssistantMessage, runProviderTurn } from "../provider-runner.js";
import { renderPromptAttachmentAsText } from "../../prompt-attachments.js";
@@ -59,7 +56,6 @@ import {
type AgentLaunchContext,
type AgentMetadata,
type AgentMode,
type AgentModelDefinition,
type AgentPermissionRequest,
type AgentPermissionRequestKind,
type AgentPermissionResponse,
@@ -76,12 +72,13 @@ import {
type AgentTimelineItem,
type AgentUsage,
type AgentRuntimeInfo,
type FetchCatalogOptions,
type ImportableProviderSession,
type ImportProviderSessionContext,
type ImportProviderSessionInput,
type ListImportableSessionsOptions,
type ListModelsOptions,
type McpServerConfig,
type ProviderCatalog,
} from "../../agent-sdk-types.js";
import { importSessionFromPersistence } from "../../provider-session-import.js";
import {
@@ -1421,9 +1418,10 @@ export class ClaudeAgentClient implements AgentClient {
});
}
async listModels(_options: ListModelsOptions): Promise<AgentModelDefinition[]> {
async fetchCatalog(_options: FetchCatalogOptions): Promise<ProviderCatalog> {
// Claude exposes a global catalog here; cwd/force are intentionally irrelevant.
return await getClaudeModelsWithSettings(this.logger, this.configDir);
const models = await getClaudeModelsWithSettings(this.logger, this.configDir);
return { models, modes: DEFAULT_MODES };
}
async listFeatures(config: AgentSessionConfig): Promise<AgentFeature[]> {
@@ -1477,28 +1475,9 @@ export class ClaudeAgentClient implements AgentClient {
defaultBinary: "claude",
});
const availability = await checkProviderLaunchAvailable(launch);
const available = availability.available;
const auth = available
const auth = availability.available
? await resolveClaudeAuth(launch, availability, this.runtimeSettings)
: null;
let modelsValue = "Not checked";
let status = formatDiagnosticStatus(available);
if (available) {
try {
const models = await this.listModels({
cwd: os.homedir(),
force: false,
});
modelsValue = String(models.length);
} catch (error) {
modelsValue = `Error - ${toDiagnosticErrorMessage(error)}`;
status = formatDiagnosticStatus(available, {
source: "model fetch",
cause: error,
});
}
}
return {
diagnostic: formatProviderDiagnostic("Claude Code", [
@@ -1507,8 +1486,6 @@ export class ClaudeAgentClient implements AgentClient {
})),
...(await buildBinaryDiagnosticRows(launch, availability)),
...(auth ? [{ label: "Auth", value: auth }] : []),
{ label: "Models", value: modelsValue },
{ label: "Status", value: status },
]),
};
} catch (error) {
@@ -1704,31 +1681,6 @@ function readLegacyResultUsageTokens(usage: unknown): number | undefined {
return usageRecord ? readUsageTokenTotal(usageRecord) : undefined;
}
interface ClaudeCurrentContextUsage {
totalTokens: number;
maxTokens?: number;
}
function readCurrentContextUsage(
value: SDKControlGetContextUsageResponse | unknown,
): ClaudeCurrentContextUsage | undefined {
const record = toObjectRecord(value);
if (!record) {
return undefined;
}
const totalTokens = record.totalTokens;
if (typeof totalTokens !== "number" || !Number.isFinite(totalTokens) || totalTokens < 0) {
return undefined;
}
const maxTokens = record.maxTokens;
return {
totalTokens,
...(typeof maxTokens === "number" && Number.isFinite(maxTokens) && maxTokens > 0
? { maxTokens }
: {}),
};
}
function isClaudeSubagentToolName(name: string | undefined): boolean {
return name === "Task" || name === "Agent";
}
@@ -1737,6 +1689,7 @@ class ClaudeContextUsageState {
private contextWindowMaxTokens: number | undefined;
private streamRequestInputTokens: number | undefined;
private streamRequestOutputTokens: number | undefined;
private compactedContextWindowUsedTokens: number | undefined;
private completedResultTurns = 0;
constructor(initialContextWindowMaxTokens?: number) {
@@ -1746,6 +1699,7 @@ class ClaudeContextUsageState {
beginTurn(): void {
this.streamRequestInputTokens = undefined;
this.streamRequestOutputTokens = undefined;
this.compactedContextWindowUsedTokens = undefined;
}
setInitialContextWindowMaxTokens(contextWindowMaxTokens: number | undefined): void {
@@ -1760,12 +1714,6 @@ class ClaudeContextUsageState {
return this.contextWindowMaxTokens;
}
recordCurrentContextUsage(usage: ClaudeCurrentContextUsage | undefined): void {
if (usage?.maxTokens !== undefined) {
this.contextWindowMaxTokens = usage.maxTokens;
}
}
buildStreamUsageEvent(event: unknown): AgentStreamEvent | null {
const streamEvent = toObjectRecord(event);
if (!streamEvent) {
@@ -1796,11 +1744,7 @@ class ClaudeContextUsageState {
return this.createUsageUpdatedEvent(usedTokens);
}
buildResultUsage(
message: SDKResultMessage,
modelUsage: unknown,
currentContextUsage: ClaudeCurrentContextUsage | undefined,
): AgentUsage | undefined {
buildResultUsage(message: SDKResultMessage, modelUsage: unknown): AgentUsage | undefined {
try {
if (!message.usage) {
return undefined;
@@ -1813,7 +1757,6 @@ class ClaudeContextUsageState {
};
const modelContextWindowMaxTokens = this.recordModelUsage(modelUsage ?? message.modelUsage);
this.recordCurrentContextUsage(currentContextUsage);
if (this.contextWindowMaxTokens !== undefined) {
usage.contextWindowMaxTokens = this.contextWindowMaxTokens;
} else if (modelContextWindowMaxTokens !== undefined) {
@@ -1824,12 +1767,13 @@ class ClaudeContextUsageState {
readActiveUsageTokens(message.usage) ??
(this.completedResultTurns === 0 ? readLegacyResultUsageTokens(message.usage) : undefined);
const usedTokens =
currentContextUsage?.totalTokens ?? this.streamUsedTokens() ?? activeResultUsageTokens;
this.streamUsedTokens() ?? activeResultUsageTokens ?? this.compactedContextWindowUsedTokens;
if (usedTokens !== undefined) {
usage.contextWindowUsedTokens = usedTokens;
}
return usage;
} finally {
this.compactedContextWindowUsedTokens = undefined;
this.completedResultTurns += 1;
}
}
@@ -1841,7 +1785,8 @@ class ClaudeContextUsageState {
) {
return undefined;
}
return this.streamRequestInputTokens + this.streamRequestOutputTokens;
const usedTokens = this.streamRequestInputTokens + this.streamRequestOutputTokens;
return usedTokens > 0 ? usedTokens : undefined;
}
private createUsageUpdatedEvent(contextWindowUsedTokens: number): AgentStreamEvent {
@@ -1857,6 +1802,24 @@ class ClaudeContextUsageState {
usage,
};
}
buildCompactionUsageEvent(postTokens: number | undefined): AgentStreamEvent {
this.streamRequestInputTokens = undefined;
this.streamRequestOutputTokens = undefined;
this.compactedContextWindowUsedTokens = postTokens;
const usage: AgentUsage = {};
if (this.contextWindowMaxTokens !== undefined) {
usage.contextWindowMaxTokens = this.contextWindowMaxTokens;
}
if (postTokens !== undefined) {
usage.contextWindowUsedTokens = postTokens;
}
return {
type: "usage_updated",
provider: "claude",
usage,
};
}
}
class ClaudeAgentSession implements AgentSession {
@@ -3304,7 +3267,7 @@ class ClaudeAgentSession implements AgentSession {
if (await this.handleMissingResumedConversation(message, activeQuery)) {
return true;
}
await this.routeSdkMessageFromPump(message, activeQuery);
await this.routeSdkMessageFromPump(message);
return false;
};
const drainActiveQuery = async (): Promise<boolean> => {
@@ -3380,7 +3343,7 @@ class ClaudeAgentSession implements AgentSession {
);
}
private async routeSdkMessageFromPump(message: SDKMessage, activeQuery: Query): Promise<void> {
private async routeSdkMessageFromPump(message: SDKMessage): Promise<void> {
if (this.shouldSuppressStaleResult(message)) {
return;
}
@@ -3410,12 +3373,7 @@ class ClaudeAgentSession implements AgentSession {
"provider.claude.parsed_event",
);
const events = await this.buildPumpedMessageEvents(
message,
activeQuery,
identifiers.messageId,
turnId,
);
const events = await this.buildPumpedMessageEvents(message, identifiers.messageId, turnId);
if (events.length === 0) {
return;
@@ -3452,18 +3410,12 @@ class ClaudeAgentSession implements AgentSession {
private async buildPumpedMessageEvents(
message: SDKMessage,
activeQuery: Query,
messageIdHint: string | null,
turnId: string | null,
): Promise<AgentStreamEvent[]> {
const currentContextUsage =
message.type === "result" && message.subtype === "success"
? await this.queryCurrentContextUsage(activeQuery)
: undefined;
const messageEvents = this.translateMessageToEvents(message, {
suppressAssistantText: true,
suppressReasoning: true,
currentContextUsage,
});
const assistantTimelineEvents = this.timelineAssembler
.consume({
@@ -3483,18 +3435,6 @@ class ClaudeAgentSession implements AgentSession {
return [...messageEvents, ...assistantTimelineEvents];
}
private async queryCurrentContextUsage(
activeQuery: Query,
): Promise<ClaudeCurrentContextUsage | undefined> {
try {
const usage = await withTimeout(activeQuery.getContextUsage(), 3_000, "timeout");
return readCurrentContextUsage(usage);
} catch (error) {
this.logger.debug({ err: error }, "Claude context usage query failed");
return undefined;
}
}
private async handleMissingResumedConversation(
message: SDKMessage,
activeQuery: Query,
@@ -3562,7 +3502,6 @@ class ClaudeAgentSession implements AgentSession {
options?: {
suppressAssistantText?: boolean;
suppressReasoning?: boolean;
currentContextUsage?: ClaudeCurrentContextUsage;
},
): AgentStreamEvent[] {
const parentToolUseId =
@@ -3613,9 +3552,7 @@ class ClaudeAgentSession implements AgentSession {
this.appendStreamEventEvents(message, events, options);
break;
case "result":
this.appendResultEvents(message, events, {
currentContextUsage: options?.currentContextUsage,
});
this.appendResultEvents(message, events);
break;
default:
break;
@@ -3689,6 +3626,7 @@ class ClaudeAgentSession implements AgentSession {
},
provider: "claude",
});
events.push(this.contextUsage.buildCompactionUsageEvent(compactMetadata?.postTokens));
return;
}
if (message.subtype === "task_notification") {
@@ -3816,9 +3754,8 @@ class ClaudeAgentSession implements AgentSession {
private appendResultEvents(
message: Extract<SDKMessage, { type: "result" }>,
events: AgentStreamEvent[],
options?: { currentContextUsage?: ClaudeCurrentContextUsage },
): void {
const usage = this.convertUsage(message, message.modelUsage, options?.currentContextUsage);
const usage = this.convertUsage(message, message.modelUsage);
if (message.subtype === "success") {
// Built-in slash commands (e.g. /voice, /usage, "Unknown command: …")
// run client-side in the Claude CLI with no model turn — output_tokens
@@ -3985,12 +3922,8 @@ class ClaudeAgentSession implements AgentSession {
return null;
}
private convertUsage(
message: SDKResultMessage,
modelUsage?: unknown,
currentContextUsage?: ClaudeCurrentContextUsage,
): AgentUsage | undefined {
return this.contextUsage.buildResultUsage(message, modelUsage, currentContextUsage);
private convertUsage(message: SDKResultMessage, modelUsage?: unknown): AgentUsage | undefined {
return this.contextUsage.buildResultUsage(message, modelUsage);
}
private handlePermissionRequest: CanUseTool = async (
@@ -4891,7 +4824,9 @@ function hasToolLikeBlock(block?: ClaudeContentChunk | null): boolean {
return type.includes("tool");
}
function readCompactionMetadata(source: unknown): { trigger?: string; preTokens?: number } | null {
function readCompactionMetadata(
source: unknown,
): { trigger?: string; preTokens?: number; postTokens?: number } | null {
const sourceRecord = toObjectRecord(source);
if (!sourceRecord) {
return null;
@@ -4909,7 +4844,9 @@ function readCompactionMetadata(source: unknown): { trigger?: string; preTokens?
const trigger = typeof metadata.trigger === "string" ? metadata.trigger : undefined;
const preTokensRaw = metadata.preTokens ?? metadata.pre_tokens;
const preTokens = typeof preTokensRaw === "number" ? preTokensRaw : undefined;
return { trigger, preTokens };
const postTokensRaw = metadata.postTokens ?? metadata.post_tokens;
const postTokens = typeof postTokensRaw === "number" ? postTokensRaw : undefined;
return { trigger, preTokens, postTokens };
}
return null;
}

View File

@@ -63,7 +63,7 @@ describe("getClaudeModels", () => {
});
});
describe("ClaudeAgentClient.listModels", () => {
describe("ClaudeAgentClient.fetchCatalog", () => {
it("appends concrete models from Claude settings.json", async () => {
const configDir = await createClaudeConfigDir({
model: "us.anthropic.claude-opus-4-7[1m]",
@@ -78,7 +78,7 @@ describe("ClaudeAgentClient.listModels", () => {
vi.stubEnv("CLAUDE_CONFIG_DIR", configDir);
const client = new ClaudeAgentClient({ logger: createTestLogger() });
const models = await client.listModels({ cwd: os.tmpdir(), force: true });
const { models } = await client.fetchCatalog({ cwd: os.tmpdir(), force: true });
expect(models).toEqual([
...getClaudeModels(),
@@ -127,7 +127,7 @@ describe("ClaudeAgentClient.listModels", () => {
vi.stubEnv("CLAUDE_CONFIG_DIR", configDir);
const client = new ClaudeAgentClient({ logger: createTestLogger() });
const models = await client.listModels({ cwd: os.tmpdir(), force: true });
const { models } = await client.fetchCatalog({ cwd: os.tmpdir(), force: true });
expect(models).toEqual(getClaudeModels());
});
@@ -137,7 +137,7 @@ describe("ClaudeAgentClient.listModels", () => {
vi.stubEnv("CLAUDE_CONFIG_DIR", configDir);
const client = new ClaudeAgentClient({ logger: createTestLogger() });
const models = await client.listModels({ cwd: os.tmpdir(), force: true });
const { models } = await client.fetchCatalog({ cwd: os.tmpdir(), force: true });
expect(models).toEqual(getClaudeModels());
});
@@ -153,7 +153,7 @@ describe("ClaudeAgentClient.listModels", () => {
vi.stubEnv("CLAUDE_CONFIG_DIR", configDir);
const client = new ClaudeAgentClient({ logger: createTestLogger() });
const models = await client.listModels({ cwd: os.tmpdir(), force: true });
const { models } = await client.fetchCatalog({ cwd: os.tmpdir(), force: true });
expect(models).toEqual(getClaudeModels());
});
@@ -169,7 +169,7 @@ describe("ClaudeAgentClient.listModels", () => {
vi.stubEnv("CLAUDE_CONFIG_DIR", configDir);
const client = new ClaudeAgentClient({ logger: createTestLogger() });
const models = await client.listModels({ cwd: os.tmpdir(), force: true });
const { models } = await client.fetchCatalog({ cwd: os.tmpdir(), force: true });
expect(models.map((model) => model.id)).toEqual([
...getClaudeModels().map((model) => model.id),

View File

@@ -26,7 +26,7 @@ describe("Codex app-server provider (real)", () => {
test("lists models and runs a simple prompt", async () => {
const client = createRealProviderClient("codex", createTestLogger());
const cwd = mkdtempSync(path.join(os.tmpdir(), "codex-app-server-e2e-"));
const models = await client.listModels({ cwd, force: false });
const { models } = await client.fetchCatalog({ cwd, force: false });
expect(models.length).toBeGreaterThan(0);
const session = await client.createSession({

View File

@@ -6,7 +6,7 @@ import { createTestLogger } from "../../../test-utils/test-logger.js";
describe("CodexAppServerAgentClient spawn error handling", () => {
const logger = createTestLogger();
test("listModels rejects gracefully when the codex binary does not exist", async () => {
test("fetchCatalog rejects gracefully when the codex binary does not exist", async () => {
const client = new CodexAppServerAgentClient(logger, {
command: {
mode: "replace",
@@ -21,7 +21,9 @@ describe("CodexAppServerAgentClient spawn error handling", () => {
process.on("uncaughtException", onUncaught);
try {
await expect(client.listModels({ cwd: "/tmp/codex-models", force: false })).rejects.toThrow();
await expect(
client.fetchCatalog({ cwd: "/tmp/codex-models", force: false }),
).rejects.toThrow();
// Drain microtask queue to ensure no deferred uncaught errors
await new Promise((resolve) => setTimeout(resolve, 100));
expect(uncaughtErrors).toHaveLength(0);

View File

@@ -26,15 +26,15 @@ import {
type AgentTimelineItem,
type ToolCallTimelineItem,
type AgentUsage,
type FetchCatalogOptions,
type ImportableProviderSession,
type ImportProviderSessionContext,
type ImportProviderSessionInput,
type ListImportableSessionsOptions,
type ListModelsOptions,
type ProviderCatalog,
} from "../agent-sdk-types.js";
import { importSessionFromPersistence } from "../provider-session-import.js";
import type { Logger } from "pino";
import { homedir } from "node:os";
import type { ChildProcess, ChildProcessWithoutNullStreams } from "node:child_process";
import { randomUUID } from "node:crypto";
@@ -84,13 +84,11 @@ import {
} from "./provider-image-output.js";
import { normalizeProviderReplayTimestamp } from "../provider-history-timestamps.js";
import {
formatDiagnosticStatus,
formatProviderDiagnostic,
formatProviderDiagnosticError,
buildBinaryDiagnosticRows,
buildCommandResolutionDiagnosticRows,
resolveBinaryVersion,
toDiagnosticErrorMessage,
} from "./diagnostic-utils.js";
import { runProviderTurn } from "./provider-runner.js";
import { SETTING_APPLIES_NEXT_TURN_NOTICE } from "../provider-notices.js";
@@ -5561,7 +5559,12 @@ export class CodexAppServerAgentClient implements AgentClient {
});
}
async listModels(_options: ListModelsOptions): Promise<AgentModelDefinition[]> {
async fetchCatalog(_options: FetchCatalogOptions): Promise<ProviderCatalog> {
const models = await this.fetchModelsFromAppServer();
return { models, modes: CODEX_MODES };
}
private async fetchModelsFromAppServer(): Promise<AgentModelDefinition[]> {
// Codex model/list is global to the app server in this flow; cwd/force are intentionally ignored.
const child = await this.spawnAppServer();
const client = new CodexAppServerClient(child, this.logger);
@@ -5645,34 +5648,12 @@ export class CodexAppServerAgentClient implements AgentClient {
try {
const launch = await resolveCodexLaunch(this.runtimeSettings);
const availability = await checkCodexLaunchAvailable(launch);
const available = availability.available;
const entries: Array<{ label: string; value: string }> = [
...(await buildCommandResolutionDiagnosticRows(launch, {
knownBinaryNames: ["codex"],
})),
...(await buildBinaryDiagnosticRows(launch, availability)),
];
let status = formatDiagnosticStatus(available);
if (!available) {
entries.push({ label: "Models", value: "Not checked" });
} else {
try {
const models = await this.listModels({ cwd: homedir(), force: false });
entries.push({ label: "Models", value: String(models.length) });
} catch (error) {
entries.push({
label: "Models",
value: `Error - ${toDiagnosticErrorMessage(error)}`,
});
status = formatDiagnosticStatus(available, {
source: "model fetch",
cause: error,
});
}
}
entries.push({ label: "Status", value: status });
return {
diagnostic: formatProviderDiagnostic("Codex", entries),

View File

@@ -1,5 +1,4 @@
import type { Logger } from "pino";
import { homedir } from "node:os";
import type { SessionConfigOption } from "@agentclientprotocol/sdk";
import type { AgentCapabilityFlags, AgentMode } from "../agent-sdk-types.js";
@@ -10,18 +9,17 @@ import {
} from "../provider-launch-config.js";
import {
ACPAgentClient,
type ACPConfigFeatureOption,
type ACPBeforeModeWriteResult,
type ACPProviderModeWriteResult,
type ACPProviderModeWriterContext,
type SessionStateResponse,
} from "./acp-agent.js";
import {
formatDiagnosticStatus,
formatProviderDiagnostic,
formatProviderDiagnosticError,
buildBinaryDiagnosticRows,
buildCommandResolutionDiagnosticRows,
toDiagnosticErrorMessage,
} from "./diagnostic-utils.js";
const COPILOT_CAPABILITIES: AgentCapabilityFlags = {
@@ -47,6 +45,16 @@ const COPILOT_ALLOW_ALL_ON = "on";
const COPILOT_ALLOW_ALL_OFF = "off";
type SelectConfigOption = Extract<SessionConfigOption, { type: "select" }>;
export const COPILOT_AGENT_FEATURE_OPTION: ACPConfigFeatureOption = {
id: "agent",
configId: "agent",
category: "_agent",
label: "Agent",
description: "Use a Copilot custom agent profile",
tooltip: "Select Copilot agent",
emptyOptionLabel: "Default",
};
export const COPILOT_MODES: AgentMode[] = [
{
id: COPILOT_AGENT_MODE_ID,
@@ -80,6 +88,7 @@ export class CopilotACPAgentClient extends ACPAgentClient {
defaultModes: COPILOT_MODES,
sessionResponseTransformer: transformCopilotSessionResponse,
configOptionsTransformer: transformCopilotConfigOptions,
configFeatureOptions: [COPILOT_AGENT_FEATURE_OPTION],
modeIdTransformer: transformCopilotModeId,
providerModeWriter: writeCopilotProviderMode,
beforeModeWriter: beforeCopilotModeWriter,
@@ -98,33 +107,6 @@ export class CopilotACPAgentClient extends ACPAgentClient {
defaultBinary: "copilot",
});
const availability = await checkProviderLaunchAvailable(launch);
const available = availability.available;
let modelsValue = "Not checked";
let status = formatDiagnosticStatus(available);
if (available) {
try {
const models = await this.listModels({ cwd: homedir(), force: false });
modelsValue = String(models.length);
} catch (error) {
modelsValue = `Error - ${toDiagnosticErrorMessage(error)}`;
status = formatDiagnosticStatus(available, {
source: "model fetch",
cause: error,
});
}
if (!modelsValue.startsWith("Error -")) {
try {
await this.listModes({ cwd: homedir(), force: false });
} catch (error) {
status = formatDiagnosticStatus(available, {
source: "mode fetch",
cause: error,
});
}
}
}
return {
diagnostic: formatProviderDiagnostic("Copilot", [
@@ -132,8 +114,6 @@ export class CopilotACPAgentClient extends ACPAgentClient {
knownBinaryNames: ["copilot"],
})),
...(await buildBinaryDiagnosticRows(launch, availability)),
{ label: "Models", value: modelsValue },
{ label: "Status", value: status },
]),
};
} catch (error) {

View File

@@ -45,17 +45,20 @@ describe("CursorACPAgentClient model discovery", () => {
configOptions: [],
});
await expect(client.listModels({ cwd: "/tmp/cursor", force: false })).resolves.toEqual([
{
provider: "acp",
id: "gpt-5.4[context=272k,reasoning=medium,fast=false]",
label: "gpt-5.4",
description: undefined,
isDefault: true,
thinkingOptions: undefined,
defaultThinkingOptionId: undefined,
},
]);
await expect(client.fetchCatalog({ cwd: "/tmp/cursor", force: false })).resolves.toEqual({
models: [
{
provider: "acp",
id: "gpt-5.4[context=272k,reasoning=medium,fast=false]",
label: "gpt-5.4",
description: undefined,
isDefault: true,
thinkingOptions: undefined,
defaultThinkingOptionId: undefined,
},
],
modes: [],
});
});
test("does not fall back to cursor-agent models when ACP reports zero models", async () => {
@@ -65,6 +68,9 @@ describe("CursorACPAgentClient model discovery", () => {
configOptions: [],
});
await expect(client.listModels({ cwd: "/tmp/cursor", force: false })).resolves.toEqual([]);
await expect(client.fetchCatalog({ cwd: "/tmp/cursor", force: false })).resolves.toEqual({
models: [],
modes: [],
});
});
});

View File

@@ -1,8 +1,7 @@
import { describe, expect, test, vi } from "vitest";
import { describe, expect, test } from "vitest";
import { createTestLogger } from "../../../test-utils/test-logger.js";
import { buildVersionProbeCommand, GenericACPAgentClient } from "./generic-acp-agent.js";
import type { SpawnedACPProcess } from "./acp-agent.js";
describe("GenericACPAgentClient diagnostics", () => {
test("probes npx-backed agent packages instead of npx itself", () => {
@@ -17,45 +16,8 @@ describe("GenericACPAgentClient diagnostics", () => {
});
});
test("reports command, binary, ACP initialize, session, models, and modes", async () => {
class TestGenericACPAgentClient extends GenericACPAgentClient {
protected override async spawnProcess(): Promise<SpawnedACPProcess> {
return {
child: { kill: vi.fn(), exitCode: 0, signalCode: null, once: vi.fn() },
initialize: {
protocolVersion: 1,
agentInfo: { name: "Cursor Agent", version: "2026.05.09" },
agentCapabilities: {},
},
connection: {
newSession: vi.fn().mockResolvedValue({
sessionId: "session-1",
models: {
currentModelId: "composer-2[fast=true]",
availableModels: [
{
modelId: "composer-2[fast=true]",
name: "Composer 2",
},
],
},
modes: {
currentModeId: "ask",
availableModes: [
{ id: "agent", name: "Agent" },
{ id: "ask", name: "Ask" },
],
},
configOptions: [],
}),
},
} as SpawnedACPProcess;
}
protected override async closeProbe(): Promise<void> {}
}
const client = new TestGenericACPAgentClient({
test("reports command, binary, and version command without spawning ACP", async () => {
const client = new GenericACPAgentClient({
logger: createTestLogger(),
command: [process.execPath, "acp"],
providerId: "cursor",
@@ -69,88 +31,10 @@ describe("GenericACPAgentClient diagnostics", () => {
expect(diagnostic).toContain(`Configured command: ${process.execPath} acp`);
expect(diagnostic).toContain(`Launcher binary: ${process.execPath}`);
expect(diagnostic).toContain(`Version command: ${process.execPath} --version`);
expect(diagnostic).toContain("ACP initialize: ok (protocol 1, Cursor Agent 2026.05.09)");
expect(diagnostic).toContain("ACP session/new: ok (session-1)");
expect(diagnostic).toContain("Models: 1");
expect(diagnostic).toContain("Modes: Agent, Ask");
expect(diagnostic).toContain("Status: Available");
});
test("counts models and modes exposed as ACP config options", async () => {
class ConfigOptionGenericACPAgentClient extends GenericACPAgentClient {
protected override async spawnProcess(): Promise<SpawnedACPProcess> {
return {
child: { kill: vi.fn(), exitCode: 0, signalCode: null, once: vi.fn() },
initialize: {
protocolVersion: 1,
agentInfo: { name: "Devin", version: "2026.5.6" },
agentCapabilities: {},
},
connection: {
newSession: vi.fn().mockResolvedValue({
sessionId: "session-1",
models: null,
modes: null,
configOptions: [
{
id: "mode",
name: "Session Mode",
category: "mode",
type: "select",
currentValue: "ask",
options: [
{ value: "accept-edits", name: "Code" },
{ value: "ask", name: "Ask" },
],
},
{
id: "model",
name: "Model",
category: "model",
type: "select",
currentValue: "swe-1-6-slow",
options: [{ value: "swe-1-6-slow", name: "SWE-1.6 Slow" }],
},
],
}),
},
} as SpawnedACPProcess;
}
protected override async closeProbe(): Promise<void> {}
}
const client = new ConfigOptionGenericACPAgentClient({
logger: createTestLogger(),
command: [process.execPath, "acp"],
providerId: "devin",
label: "Devin",
});
const { diagnostic } = await client.getDiagnostic();
expect(diagnostic).toContain("Models: 1");
expect(diagnostic).toContain("Modes: Code, Ask");
});
test("reports ACP probe failures instead of falling back to no diagnostic", async () => {
class FailingGenericACPAgentClient extends GenericACPAgentClient {
protected override async spawnProcess(): Promise<SpawnedACPProcess> {
throw new Error("initialize timed out");
}
}
const client = new FailingGenericACPAgentClient({
logger: createTestLogger(),
command: [process.execPath, "acp"],
providerId: "cursor",
label: "Cursor",
});
const { diagnostic } = await client.getDiagnostic();
expect(diagnostic).toContain("Cursor (ACP)");
expect(diagnostic).toContain("ACP initialize: Error - initialize timed out");
expect(diagnostic).toContain("Status: Error (ACP probe failed: initialize timed out)");
expect(diagnostic).not.toContain("ACP initialize");
expect(diagnostic).not.toContain("ACP session/new");
expect(diagnostic).not.toContain("Models:");
expect(diagnostic).not.toContain("Modes:");
expect(diagnostic).not.toContain("Status:");
});
});

View File

@@ -1,27 +1,15 @@
import { homedir } from "node:os";
import type { Logger } from "pino";
import { z } from "zod";
import type { AgentCapabilityFlags, AgentProvider } from "../agent-sdk-types.js";
import type { AgentCapabilityFlags } from "../agent-sdk-types.js";
import { checkProviderLaunchAvailable, resolveProviderLaunch } from "../provider-launch-config.js";
import { ACPAgentClient, DEFAULT_ACP_CAPABILITIES } from "./acp-agent.js";
import {
ACPAgentClient,
DEFAULT_ACP_CAPABILITIES,
deriveModelDefinitionsFromACP,
deriveModesFromACP,
type SessionStateResponse,
} from "./acp-agent.js";
import {
formatDiagnosticStatus,
formatProviderDiagnostic,
formatProviderDiagnosticError,
buildBinaryDiagnosticRows,
toDiagnosticErrorMessage,
} from "./diagnostic-utils.js";
const ACP_DIAGNOSTIC_INITIALIZE_TIMEOUT_MS = 8_000;
const ACP_DIAGNOSTIC_SESSION_TIMEOUT_MS = 8_000;
export const GenericACPProviderParamsSchema = z
.object({
supportsMcpServers: z.boolean().optional(),
@@ -83,17 +71,7 @@ export class GenericACPAgentClient extends ACPAgentClient {
try {
const launch = await this.resolveConfiguredLaunch();
const availability = await checkProviderLaunchAvailable(launch);
const available = availability.available;
const versionProbe = buildVersionProbeCommand(this.command);
const probeResult = available
? await this.runDiagnosticACPProbe()
: {
status: formatDiagnosticStatus(false),
initialize: "Not checked",
session: "Not checked",
models: "Not checked",
modes: "Not checked",
};
return {
diagnostic: formatProviderDiagnostic(providerName, [
@@ -111,11 +89,6 @@ export class GenericACPAgentClient extends ACPAgentClient {
label: "Version command",
value: formatCommand(versionProbe.command, versionProbe.args),
},
{ label: "ACP initialize", value: probeResult.initialize },
{ label: "ACP session/new", value: probeResult.session },
{ label: "Models", value: probeResult.models },
{ label: "Modes", value: probeResult.modes },
{ label: "Status", value: probeResult.status },
]),
};
} catch (error) {
@@ -131,58 +104,6 @@ export class GenericACPAgentClient extends ACPAgentClient {
defaultBinary: this.command[0],
});
}
private async runDiagnosticACPProbe(): Promise<ACPDiagnosticProbeResult> {
let initializeValue = "Not checked";
let sessionValue = "Not checked";
try {
const probe = await this.spawnProcess(
{
NO_BROWSER: "true",
NO_OPEN_BROWSER: "1",
GEMINI_CLI_NO_BROWSER: "true",
CI: "1",
},
{
initializeTimeoutMs: ACP_DIAGNOSTIC_INITIALIZE_TIMEOUT_MS,
},
);
try {
initializeValue = formatInitializeResult(probe.initialize);
const response = await withTimeout(
probe.connection.newSession({
cwd: homedir(),
mcpServers: [],
}),
ACP_DIAGNOSTIC_SESSION_TIMEOUT_MS,
"ACP session/new",
);
sessionValue = response.sessionId ? `ok (${response.sessionId})` : "ok";
const transformed = this.transformSessionResponse(response);
return {
status: formatDiagnosticStatus(true),
initialize: initializeValue,
session: sessionValue,
...summarizeSessionState(this.provider, transformed),
};
} finally {
await this.closeProbe(probe);
}
} catch (error) {
return {
status: formatDiagnosticStatus(true, {
source: "ACP probe",
cause: error,
}),
initialize: formatProbeError(initializeValue, error),
session:
initializeValue === "Not checked" ? "Not checked" : formatProbeError(sessionValue, error),
models: "Not checked",
modes: "Not checked",
};
}
}
}
function buildGenericACPCapabilities(options: GenericACPAgentClientOptions): AgentCapabilityFlags {
@@ -197,14 +118,6 @@ function parseGenericACPProviderParams(params: unknown): GenericACPProviderParam
return GenericACPProviderParamsSchema.parse(params ?? {});
}
interface ACPDiagnosticProbeResult {
status: string;
initialize: string;
session: string;
models: string;
modes: string;
}
export interface CommandInvocation {
command: string;
args: string[];
@@ -271,60 +184,3 @@ function takePackageSpecPrefix(args: string[]): string[] {
}
return prefix;
}
function formatInitializeResult(initialize: {
protocolVersion: number;
agentInfo?: unknown;
}): string {
const agentInfo = isAgentInfo(initialize.agentInfo)
? `${initialize.agentInfo.name}${initialize.agentInfo.version ? ` ${initialize.agentInfo.version}` : ""}`
: "ok";
return `ok (protocol ${initialize.protocolVersion}, ${agentInfo})`;
}
function isAgentInfo(value: unknown): value is { name: string; version?: string } {
return (
typeof value === "object" &&
value !== null &&
"name" in value &&
typeof Reflect.get(value, "name") === "string"
);
}
function summarizeSessionState(
provider: AgentProvider,
response: SessionStateResponse,
): Pick<ACPDiagnosticProbeResult, "models" | "modes"> {
const models = deriveModelDefinitionsFromACP(provider, response.models, response.configOptions);
const { modes } = deriveModesFromACP([], response.modes, response.configOptions);
return {
models: `${models.length}`,
modes:
modes.length > 0 ? modes.map((mode) => mode.label || mode.id).join(", ") : "none reported",
};
}
function formatProbeError(currentValue: string, error: unknown): string {
if (currentValue !== "Not checked") {
return currentValue;
}
return `Error - ${toDiagnosticErrorMessage(error)}`;
}
async function withTimeout<T>(promise: Promise<T>, timeoutMs: number, label: string): Promise<T> {
let timeout: ReturnType<typeof setTimeout> | null = null;
try {
return await Promise.race([
promise,
new Promise<never>((_, reject) => {
timeout = setTimeout(() => {
reject(new Error(`${label} timed out after ${timeoutMs}ms`));
}, timeoutMs);
}),
]);
} finally {
if (timeout) {
clearTimeout(timeout);
}
}
}

View File

@@ -33,7 +33,7 @@ describe("MockLoadTestAgentClient", () => {
test("default model is a five minute foreground stream with token-rate intervals", async () => {
const client = new MockLoadTestAgentClient();
const models = await client.listModels({ cwd: "/tmp/mock-models", force: false });
const { models } = await client.fetchCatalog({ cwd: "/tmp/mock-models", force: false });
expect(models[0]).toMatchObject({
id: MOCK_LOAD_TEST_DEFAULT_MODEL_ID,

View File

@@ -20,11 +20,11 @@ import type {
AgentSessionConfig,
AgentStreamEvent,
AgentTimelineItem,
FetchCatalogOptions,
ImportableProviderSession,
ImportProviderSessionContext,
ImportProviderSessionInput,
ListModesOptions,
ListModelsOptions,
ProviderCatalog,
ToolCallDetail,
ToolCallTimelineItem,
} from "../agent-sdk-types.js";
@@ -531,12 +531,11 @@ export class MockLoadTestAgentClient implements AgentClient {
});
}
async listModels(_options: ListModelsOptions): Promise<AgentModelDefinition[]> {
return MODELS;
}
async listModes(_options: ListModesOptions): Promise<AgentMode[]> {
return getAgentProviderDefinition(MOCK_LOAD_TEST_PROVIDER_ID).modes;
async fetchCatalog(_options: FetchCatalogOptions): Promise<ProviderCatalog> {
return {
models: MODELS,
modes: getAgentProviderDefinition(MOCK_LOAD_TEST_PROVIDER_ID).modes,
};
}
async listImportableSessions(): Promise<ImportableProviderSession[]> {

View File

@@ -2,14 +2,12 @@ import type {
AgentCapabilityFlags,
AgentClient,
AgentLaunchContext,
AgentMode,
AgentModelDefinition,
AgentPersistenceHandle,
AgentProvider,
AgentSession,
AgentSessionConfig,
ListModelsOptions,
ListModesOptions,
FetchCatalogOptions,
ProviderCatalog,
} from "../agent-sdk-types.js";
export const MOCK_SLOW_PROVIDER_ID = "mock-slow";
@@ -38,18 +36,14 @@ export class MockSlowProviderClient implements AgentClient {
return process.env.PASEO_ENABLE_MOCK_SLOW === "true";
}
listModels(_options: ListModelsOptions): Promise<AgentModelDefinition[]> {
return neverResolves<AgentModelDefinition[]>();
}
listModes(_options: ListModesOptions): Promise<AgentMode[]> {
return neverResolves<AgentMode[]>();
async fetchCatalog(_options: FetchCatalogOptions): Promise<ProviderCatalog> {
return neverResolves<ProviderCatalog>();
}
async getDiagnostic(): Promise<{ diagnostic: string }> {
return {
diagnostic:
"Mock slow provider: dev-only. listModels() never resolves so the snapshot manager will time out.",
"Mock slow provider: dev-only. fetchCatalog() never resolves so the snapshot manager will time out.",
};
}

View File

@@ -6,8 +6,8 @@ import { OpenCodeAgentClient } from "./opencode-agent.js";
import {
idleEvent,
TestOpenCodeClient,
TestOpenCodeRuntime,
} from "./opencode/test-utils/test-opencode-runtime.js";
TestOpenCodeHarness,
} from "./opencode/test-utils/test-opencode-harness.js";
interface MockOpenCodeClientOptions {
agents?: unknown[];
@@ -15,10 +15,16 @@ interface MockOpenCodeClientOptions {
}
function mockOpenCodeClient(options: MockOpenCodeClientOptions = {}) {
const runtime = new TestOpenCodeRuntime();
const runtime = new TestOpenCodeHarness();
const openCodeClient = new TestOpenCodeClient();
openCodeClient.appAgentsResponse = { data: options.agents ?? [] };
openCodeClient.sessionPromptAsyncEvents = options.events ?? [idleEvent()];
openCodeClient.providerListResponse = {
data: {
connected: ["openai"],
all: [{ id: "openai", source: "env", models: {} }],
},
};
runtime.enqueueClient(openCodeClient);
return { openCodeClient, runtime };
@@ -71,8 +77,11 @@ describe("OpenCode auto_accept feature", () => {
],
});
const client = new OpenCodeAgentClient(createTestLogger(), undefined, { runtime });
const modes = await client.listModes({ cwd: "/tmp/project", force: false });
const client = new OpenCodeAgentClient(createTestLogger(), undefined, {
serverManager: runtime,
createClient: runtime.createClient,
});
const { modes } = await client.fetchCatalog({ cwd: "/tmp/project", force: false });
expect(modes.map((mode) => mode.id)).toEqual(["build", "paseo-custom"]);
});
@@ -80,8 +89,11 @@ describe("OpenCode auto_accept feature", () => {
test("falls back to default OpenCode modes when discovery returns no modes", async () => {
const { runtime } = mockOpenCodeClient({ agents: [] });
const client = new OpenCodeAgentClient(createTestLogger(), undefined, { runtime });
const modes = await client.listModes({ cwd: "/tmp/project", force: false });
const client = new OpenCodeAgentClient(createTestLogger(), undefined, {
serverManager: runtime,
createClient: runtime.createClient,
});
const { modes } = await client.fetchCatalog({ cwd: "/tmp/project", force: false });
expect(modes.map((mode) => mode.id)).toEqual(["build", "plan"]);
});
@@ -89,7 +101,10 @@ describe("OpenCode auto_accept feature", () => {
test("lists auto accept as a provider feature", async () => {
const { runtime } = mockOpenCodeClient();
const client = new OpenCodeAgentClient(createTestLogger(), undefined, { runtime });
const client = new OpenCodeAgentClient(createTestLogger(), undefined, {
serverManager: runtime,
createClient: runtime.createClient,
});
const enabledFeatures = await client.listFeatures({
provider: "opencode",
cwd: "/tmp/project",
@@ -115,7 +130,10 @@ describe("OpenCode auto_accept feature", () => {
test("keeps legacy full-access as an alias for build plus auto accept", async () => {
const { openCodeClient, runtime } = mockOpenCodeClient();
const client = new OpenCodeAgentClient(createTestLogger(), undefined, { runtime });
const client = new OpenCodeAgentClient(createTestLogger(), undefined, {
serverManager: runtime,
createClient: runtime.createClient,
});
const session = await client.createSession({
provider: "opencode",
cwd: "/tmp/project",
@@ -245,7 +263,10 @@ describe("OpenCode auto_accept feature", () => {
});
const receivedEvents: AgentStreamEvent[] = [];
const client = new OpenCodeAgentClient(createTestLogger(), undefined, { runtime });
const client = new OpenCodeAgentClient(createTestLogger(), undefined, {
serverManager: runtime,
createClient: runtime.createClient,
});
const session = await client.createSession({
provider: "opencode",
cwd: "/tmp/project",
@@ -273,7 +294,10 @@ describe("OpenCode auto_accept feature", () => {
});
const receivedEvents: AgentStreamEvent[] = [];
const client = new OpenCodeAgentClient(createTestLogger(), undefined, { runtime });
const client = new OpenCodeAgentClient(createTestLogger(), undefined, {
serverManager: runtime,
createClient: runtime.createClient,
});
const session = await client.createSession({
provider: "opencode",
cwd: "/tmp/project",
@@ -316,7 +340,10 @@ describe("OpenCode auto_accept feature", () => {
});
const receivedEvents: AgentStreamEvent[] = [];
const client = new OpenCodeAgentClient(createTestLogger(), undefined, { runtime });
const client = new OpenCodeAgentClient(createTestLogger(), undefined, {
serverManager: runtime,
createClient: runtime.createClient,
});
const session = await client.createSession({
provider: "opencode",
cwd: "/tmp/project",

View File

@@ -4,8 +4,8 @@ import { createTestLogger } from "../../../test-utils/test-logger.js";
import { OpenCodeAgentClient } from "./opencode-agent.js";
import {
TestOpenCodeClient,
TestOpenCodeRuntime,
} from "./opencode/test-utils/test-opencode-runtime.js";
TestOpenCodeHarness,
} from "./opencode/test-utils/test-opencode-harness.js";
afterEach(() => {
vi.useRealTimers();
@@ -14,7 +14,7 @@ afterEach(() => {
test("allows a slow provider.list call to succeed instead of failing after 10 seconds", async () => {
vi.useFakeTimers();
const runtime = new TestOpenCodeRuntime();
const runtime = new TestOpenCodeHarness();
const openCodeClient = new TestOpenCodeClient();
openCodeClient.providerListImplementation = () =>
new Promise((resolve) => {
@@ -40,23 +40,28 @@ test("allows a slow provider.list call to succeed instead of failing after 10 se
});
runtime.enqueueClient(openCodeClient);
const client = new OpenCodeAgentClient(createTestLogger(), undefined, { runtime });
const modelsPromise = client.listModels({ cwd: "/tmp/opencode-models", force: false });
const client = new OpenCodeAgentClient(createTestLogger(), undefined, {
serverManager: runtime,
createClient: runtime.createClient,
});
const modelsPromise = client.fetchCatalog({ cwd: "/tmp/opencode-models", force: false });
await vi.advanceTimersByTimeAsync(15_000);
await expect(modelsPromise).resolves.toMatchObject([
{
provider: "opencode",
id: "zai/glm-5.1",
label: "GLM 5.1",
},
]);
await expect(modelsPromise).resolves.toMatchObject({
models: [
{
provider: "opencode",
id: "zai/glm-5.1",
label: "GLM 5.1",
},
],
});
expect(openCodeClient.calls.providerList).toHaveLength(1);
});
test("passes explicit refresh force through server acquisition", async () => {
const runtime = new TestOpenCodeRuntime();
test("uses a new server for explicit catalog refresh", async () => {
const runtime = new TestOpenCodeHarness();
const openCodeClient = new TestOpenCodeClient();
openCodeClient.providerListResponse = {
data: {
@@ -66,17 +71,20 @@ test("passes explicit refresh force through server acquisition", async () => {
};
runtime.enqueueClient(openCodeClient);
const client = new OpenCodeAgentClient(createTestLogger(), undefined, { runtime });
const client = new OpenCodeAgentClient(createTestLogger(), undefined, {
serverManager: runtime,
createClient: runtime.createClient,
});
await client.listModels({ cwd: "/tmp/opencode-models", force: true });
await client.fetchCatalog({ cwd: "/tmp/opencode-models", force: true });
expect(runtime.acquisitions).toEqual([{ force: true, releaseCount: 1 }]);
expect(runtime.acquisitions).toEqual([{ kind: "new", releaseCount: 1 }]);
});
test("includes models from api-source providers not in connected", async () => {
// Providers with source "api" are managed by the OpenCode console/subscription.
// They don't appear in `connected` but are fully usable.
const runtime = new TestOpenCodeRuntime();
const runtime = new TestOpenCodeHarness();
const openCodeClient = new TestOpenCodeClient();
openCodeClient.providerListResponse = {
data: {
@@ -98,8 +106,11 @@ test("includes models from api-source providers not in connected", async () => {
};
runtime.enqueueClient(openCodeClient);
const client = new OpenCodeAgentClient(createTestLogger(), undefined, { runtime });
const models = await client.listModels({ cwd: "/tmp/opencode-models", force: false });
const client = new OpenCodeAgentClient(createTestLogger(), undefined, {
serverManager: runtime,
createClient: runtime.createClient,
});
const { models } = await client.fetchCatalog({ cwd: "/tmp/opencode-models", force: false });
expect(models).toMatchObject([
{
@@ -111,7 +122,7 @@ test("includes models from api-source providers not in connected", async () => {
});
test("throws when no providers are accessible (neither connected nor api-source)", async () => {
const runtime = new TestOpenCodeRuntime();
const runtime = new TestOpenCodeHarness();
const openCodeClient = new TestOpenCodeClient();
openCodeClient.providerListResponse = {
data: {
@@ -130,15 +141,18 @@ test("throws when no providers are accessible (neither connected nor api-source)
};
runtime.enqueueClient(openCodeClient);
const client = new OpenCodeAgentClient(createTestLogger(), undefined, { runtime });
const client = new OpenCodeAgentClient(createTestLogger(), undefined, {
serverManager: runtime,
createClient: runtime.createClient,
});
await expect(client.listModels({ cwd: "/tmp/opencode-models", force: false })).rejects.toThrow(
await expect(client.fetchCatalog({ cwd: "/tmp/opencode-models", force: false })).rejects.toThrow(
"OpenCode has no connected providers",
);
});
test("does not throw when only api-source providers are present with no connected providers", async () => {
const runtime = new TestOpenCodeRuntime();
const runtime = new TestOpenCodeHarness();
const openCodeClient = new TestOpenCodeClient();
openCodeClient.providerListResponse = {
data: {
@@ -157,9 +171,20 @@ test("does not throw when only api-source providers are present with no connecte
};
runtime.enqueueClient(openCodeClient);
const client = new OpenCodeAgentClient(createTestLogger(), undefined, { runtime });
const client = new OpenCodeAgentClient(createTestLogger(), undefined, {
serverManager: runtime,
createClient: runtime.createClient,
});
await expect(
client.listModels({ cwd: "/tmp/opencode-models", force: false }),
).resolves.toHaveLength(1);
client.fetchCatalog({ cwd: "/tmp/opencode-models", force: false }),
).resolves.toMatchObject({
models: [
{
provider: "opencode",
id: "pi/pi-model-1",
label: "Pi Model 1",
},
],
});
});

View File

@@ -5,11 +5,11 @@ import { OpenCodeAgentClient } from "./opencode-agent.js";
import {
idleEvent,
TestOpenCodeClient,
TestOpenCodeRuntime,
} from "./opencode/test-utils/test-opencode-runtime.js";
TestOpenCodeHarness,
} from "./opencode/test-utils/test-opencode-harness.js";
function mockOpenCodeClient(events: unknown[]) {
const runtime = new TestOpenCodeRuntime();
const runtime = new TestOpenCodeHarness();
const openCodeClient = new TestOpenCodeClient();
openCodeClient.sessionPromptAsyncEvents = events;
runtime.enqueueClient(openCodeClient);
@@ -35,7 +35,10 @@ function toolPermissionEvent(): unknown {
describe("OpenCode permission actions", () => {
test("allow always sends OpenCode's always reply", async () => {
const { openCodeClient, runtime } = mockOpenCodeClient([toolPermissionEvent(), idleEvent()]);
const client = new OpenCodeAgentClient(createTestLogger(), undefined, { runtime });
const client = new OpenCodeAgentClient(createTestLogger(), undefined, {
serverManager: runtime,
createClient: runtime.createClient,
});
const session = await client.createSession({
provider: "opencode",
cwd: "/tmp/project",
@@ -63,7 +66,10 @@ describe("OpenCode permission actions", () => {
test("plain allow keeps the backward-compatible once reply", async () => {
const { openCodeClient, runtime } = mockOpenCodeClient([toolPermissionEvent(), idleEvent()]);
const client = new OpenCodeAgentClient(createTestLogger(), undefined, { runtime });
const client = new OpenCodeAgentClient(createTestLogger(), undefined, {
serverManager: runtime,
createClient: runtime.createClient,
});
const session = await client.createSession({
provider: "opencode",
cwd: "/tmp/project",

View File

@@ -5,8 +5,8 @@ import { OpenCodeAgentClient } from "./opencode-agent.js";
import {
idleEvent,
TestOpenCodeClient,
TestOpenCodeRuntime,
} from "./opencode/test-utils/test-opencode-runtime.js";
TestOpenCodeHarness,
} from "./opencode/test-utils/test-opencode-harness.js";
function createDeferred<T>(): {
promise: Promise<T>;
@@ -24,11 +24,14 @@ function createDeferred<T>(): {
describe("OpenCodeAgentSession slash command timeout handling", () => {
test("lists only OpenCode built-in slash commands Paseo can execute", async () => {
const runtime = new TestOpenCodeRuntime();
const runtime = new TestOpenCodeHarness();
const openCodeClient = createOpenCodeClientWithConnectedProvider();
runtime.enqueueClient(openCodeClient);
const client = new OpenCodeAgentClient(createTestLogger(), undefined, { runtime });
const client = new OpenCodeAgentClient(createTestLogger(), undefined, {
serverManager: runtime,
createClient: runtime.createClient,
});
const session = await client.createSession({ provider: "opencode", cwd: "/tmp" });
await expect(session.listCommands?.()).resolves.toEqual(
@@ -49,11 +52,14 @@ describe("OpenCodeAgentSession slash command timeout handling", () => {
});
test("executes compact through the OpenCode summarize endpoint", async () => {
const runtime = new TestOpenCodeRuntime();
const runtime = new TestOpenCodeHarness();
const openCodeClient = createOpenCodeClientWithConnectedProvider();
runtime.enqueueClient(openCodeClient);
const client = new OpenCodeAgentClient(createTestLogger(), undefined, { runtime });
const client = new OpenCodeAgentClient(createTestLogger(), undefined, {
serverManager: runtime,
createClient: runtime.createClient,
});
const session = await client.createSession({ provider: "opencode", cwd: "/tmp" });
await expect(session.run("/compact")).resolves.toMatchObject({
@@ -70,7 +76,7 @@ describe("OpenCodeAgentSession slash command timeout handling", () => {
test("waits for SSE completion when slash commands hit a header timeout", async () => {
const idleEventGate = createDeferred<void>();
const runtime = new TestOpenCodeRuntime();
const runtime = new TestOpenCodeHarness();
const openCodeClient = createOpenCodeClientWithConnectedProvider();
openCodeClient.sessionCommandError = new Error("fetch failed: Headers Timeout Error");
openCodeClient.commandListResponse = {
@@ -85,7 +91,10 @@ describe("OpenCodeAgentSession slash command timeout handling", () => {
})();
runtime.enqueueClient(openCodeClient);
const client = new OpenCodeAgentClient(createTestLogger(), undefined, { runtime });
const client = new OpenCodeAgentClient(createTestLogger(), undefined, {
serverManager: runtime,
createClient: runtime.createClient,
});
const session = await client.createSession({ provider: "opencode", cwd: "/tmp" });
const runPromise = session.run("/help");
@@ -101,7 +110,7 @@ describe("OpenCodeAgentSession slash command timeout handling", () => {
});
test("leaves successful slash command turns open until OpenCode emits idle", async () => {
const runtime = new TestOpenCodeRuntime();
const runtime = new TestOpenCodeHarness();
const openCodeClient = createOpenCodeClientWithConnectedProvider();
openCodeClient.sessionCommandEvents = [];
openCodeClient.commandListResponse = {
@@ -109,7 +118,10 @@ describe("OpenCodeAgentSession slash command timeout handling", () => {
};
runtime.enqueueClient(openCodeClient);
const client = new OpenCodeAgentClient(createTestLogger(), undefined, { runtime });
const client = new OpenCodeAgentClient(createTestLogger(), undefined, {
serverManager: runtime,
createClient: runtime.createClient,
});
const session = await client.createSession({ provider: "opencode", cwd: "/tmp" });
const runPromise = session.run("/help");

View File

@@ -13,8 +13,8 @@ import {
import { streamSession } from "./test-utils/session-stream-adapter.js";
import {
TestOpenCodeClient,
TestOpenCodeRuntime,
} from "./opencode/test-utils/test-opencode-runtime.js";
TestOpenCodeHarness,
} from "./opencode/test-utils/test-opencode-harness.js";
import type {
AgentSessionConfig,
AgentStreamEvent,
@@ -182,9 +182,12 @@ describe("OpenCodeAgentClient adapter smoke tests", () => {
test("creates a session with valid id and provider", async () => {
const cwd = tmpCwd();
const runtime = new TestOpenCodeRuntime();
const runtime = new TestOpenCodeHarness();
runtime.enqueueClient(new TestOpenCodeClient());
const client = new OpenCodeAgentClient(logger, undefined, { runtime });
const client = new OpenCodeAgentClient(logger, undefined, {
serverManager: runtime,
createClient: runtime.createClient,
});
const session = await client.createSession(buildConfig(cwd));
expect(typeof session.id).toBe("string");
@@ -197,11 +200,14 @@ describe("OpenCodeAgentClient adapter smoke tests", () => {
test("single turn completes with streaming deltas", async () => {
const cwd = tmpCwd();
const runtime = new TestOpenCodeRuntime();
const runtime = new TestOpenCodeHarness();
const openCodeClient = new TestOpenCodeClient();
openCodeClient.sessionPromptAsyncEvents = assistantTurnEvents();
runtime.enqueueClient(openCodeClient);
const client = new OpenCodeAgentClient(logger, undefined, { runtime });
const client = new OpenCodeAgentClient(logger, undefined, {
serverManager: runtime,
createClient: runtime.createClient,
});
const session = await client.createSession(buildConfig(cwd));
const iterator = streamSession(session, "Say hello");
@@ -230,11 +236,14 @@ describe("OpenCodeAgentClient adapter smoke tests", () => {
test("manual compact hides the generated summary text", async () => {
const cwd = tmpCwd();
const runtime = new TestOpenCodeRuntime();
const runtime = new TestOpenCodeHarness();
const openCodeClient = new TestOpenCodeClient();
openCodeClient.sessionSummarizeEvents = manualCompactEvents();
runtime.enqueueClient(openCodeClient);
const client = new OpenCodeAgentClient(logger, undefined, { runtime });
const client = new OpenCodeAgentClient(logger, undefined, {
serverManager: runtime,
createClient: runtime.createClient,
});
const session = await client.createSession({
provider: "opencode",
cwd,
@@ -263,8 +272,8 @@ describe("OpenCodeAgentClient adapter smoke tests", () => {
rmSync(cwd, { recursive: true, force: true });
}, 120_000);
test("listModels returns models with required fields", async () => {
const runtime = new TestOpenCodeRuntime();
test("fetchCatalog returns models with required fields", async () => {
const runtime = new TestOpenCodeHarness();
const openCodeClient = new TestOpenCodeClient();
openCodeClient.providerListResponse = {
data: {
@@ -286,15 +295,27 @@ describe("OpenCodeAgentClient adapter smoke tests", () => {
],
},
};
openCodeClient.appAgentsResponse = {
data: [
{
name: "build",
mode: "primary",
hidden: false,
},
],
};
runtime.enqueueClient(openCodeClient);
const client = new OpenCodeAgentClient(logger, undefined, { runtime });
const client = new OpenCodeAgentClient(logger, undefined, {
serverManager: runtime,
createClient: runtime.createClient,
});
const cwd = os.homedir();
const models = await client.listModels({ cwd, force: false });
const catalog = await client.fetchCatalog({ cwd, force: false });
expect(Array.isArray(models)).toBe(true);
expect(models).toHaveLength(1);
expect(Array.isArray(catalog.models)).toBe(true);
expect(catalog.models).toHaveLength(1);
for (const model of models) {
for (const model of catalog.models) {
expect(model.provider).toBe("opencode");
expect(typeof model.id).toBe("string");
expect(model.id.length).toBeGreaterThan(0);
@@ -309,7 +330,7 @@ describe("OpenCodeAgentClient adapter smoke tests", () => {
});
expect(typeof model.metadata?.contextWindowMaxTokens).toBe("number");
}
expect(models[0]).toMatchObject({
expect(catalog.models[0]).toMatchObject({
id: TEST_MODEL,
label: "Big Pickle",
metadata: {
@@ -322,7 +343,7 @@ describe("OpenCodeAgentClient adapter smoke tests", () => {
}, 60_000);
test("limits concurrent OpenCode metadata requests across clients", async () => {
const runtime = new TestOpenCodeRuntime();
const runtime = new TestOpenCodeHarness();
let activeProviderListCalls = 0;
let maxActiveProviderListCalls = 0;
const response = {
@@ -355,10 +376,13 @@ describe("OpenCodeAgentClient adapter smoke tests", () => {
runtime.enqueueClient(openCodeClient);
}
const client = new OpenCodeAgentClient(logger, undefined, { runtime });
const client = new OpenCodeAgentClient(logger, undefined, {
serverManager: runtime,
createClient: runtime.createClient,
});
await Promise.all(
Array.from({ length: 12 }, (_, index) =>
client.listModels({ cwd: path.join(os.tmpdir(), `opencode-cwd-${index}`), force: false }),
client.fetchCatalog({ cwd: path.join(os.tmpdir(), `opencode-cwd-${index}`), force: false }),
),
);
@@ -367,9 +391,12 @@ describe("OpenCodeAgentClient adapter smoke tests", () => {
test("available modes include build and plan", async () => {
const cwd = tmpCwd();
const runtime = new TestOpenCodeRuntime();
const runtime = new TestOpenCodeHarness();
runtime.enqueueClient(new TestOpenCodeClient());
const client = new OpenCodeAgentClient(logger, undefined, { runtime });
const client = new OpenCodeAgentClient(logger, undefined, {
serverManager: runtime,
createClient: runtime.createClient,
});
const session = await client.createSession(buildConfig(cwd));
const modes = await session.getAvailableModes();
@@ -383,7 +410,7 @@ describe("OpenCodeAgentClient adapter smoke tests", () => {
test("custom agents defined in opencode.json appear in available modes", async () => {
const cwd = tmpCwd();
const runtime = new TestOpenCodeRuntime();
const runtime = new TestOpenCodeHarness();
const openCodeClient = new TestOpenCodeClient();
openCodeClient.appAgentsResponse = {
data: [
@@ -399,7 +426,10 @@ describe("OpenCodeAgentClient adapter smoke tests", () => {
};
runtime.enqueueClient(openCodeClient);
const client = new OpenCodeAgentClient(logger, undefined, { runtime });
const client = new OpenCodeAgentClient(logger, undefined, {
serverManager: runtime,
createClient: runtime.createClient,
});
const session = await client.createSession(buildConfig(cwd));
const modes = await session.getAvailableModes();
@@ -422,7 +452,7 @@ describe("OpenCodeAgentClient adapter smoke tests", () => {
test("plan and build modes are sent to OpenCode as distinct runtime agents", async () => {
const cwd = tmpCwd();
const runtime = new TestOpenCodeRuntime();
const runtime = new TestOpenCodeHarness();
const planOpenCodeClient = new TestOpenCodeClient();
planOpenCodeClient.sessionPromptAsyncEvents = assistantTurnEvents({ text: "Plan response" });
const buildOpenCodeClient = new TestOpenCodeClient();
@@ -459,7 +489,10 @@ describe("OpenCodeAgentClient adapter smoke tests", () => {
];
runtime.enqueueClient(planOpenCodeClient);
runtime.enqueueClient(buildOpenCodeClient);
const client = new OpenCodeAgentClient(logger, undefined, { runtime });
const client = new OpenCodeAgentClient(logger, undefined, {
serverManager: runtime,
createClient: runtime.createClient,
});
const planSession = await client.createSession({
...buildConfig(cwd),
@@ -852,11 +885,14 @@ describe("OpenCode adapter context-window normalization", () => {
describe("OpenCode adapter startTurn error handling", () => {
test("dynamically adds injected MCP servers without config-backed connect", async () => {
const runtime = new TestOpenCodeRuntime();
const runtime = new TestOpenCodeHarness();
const openCodeClient = new TestOpenCodeClient();
runtime.enqueueClient(openCodeClient);
const cwd = tmpCwd();
const client = new OpenCodeAgentClient(createTestLogger(), undefined, { runtime });
const client = new OpenCodeAgentClient(createTestLogger(), undefined, {
serverManager: runtime,
createClient: runtime.createClient,
});
try {
const session = await client.createSession({
@@ -892,7 +928,7 @@ describe("OpenCode adapter startTurn error handling", () => {
});
test("fails the turn when OpenCode reports MCP add failure in data payload", async () => {
const runtime = new TestOpenCodeRuntime();
const runtime = new TestOpenCodeHarness();
const openCodeClient = new TestOpenCodeClient();
openCodeClient.mcpAddResponse = {
data: {
@@ -904,7 +940,10 @@ describe("OpenCode adapter startTurn error handling", () => {
};
runtime.enqueueClient(openCodeClient);
const cwd = tmpCwd();
const client = new OpenCodeAgentClient(createTestLogger(), undefined, { runtime });
const client = new OpenCodeAgentClient(createTestLogger(), undefined, {
serverManager: runtime,
createClient: runtime.createClient,
});
try {
const session = await client.createSession({
@@ -1656,11 +1695,14 @@ describe("OpenCode adapter startTurn error handling", () => {
describe("OpenCodeAgentClient env", () => {
test("passes launch-context env to env-specific server acquisition", async () => {
const runtime = new TestOpenCodeRuntime();
const runtime = new TestOpenCodeHarness();
const openCodeClient = new TestOpenCodeClient();
runtime.enqueueClient(openCodeClient);
const cwd = tmpCwd();
const client = new OpenCodeAgentClient(createTestLogger(), undefined, { runtime });
const client = new OpenCodeAgentClient(createTestLogger(), undefined, {
serverManager: runtime,
createClient: runtime.createClient,
});
try {
const session = await client.createSession(
@@ -1677,7 +1719,7 @@ describe("OpenCodeAgentClient env", () => {
await session.close();
expect(runtime.acquisitions[0]).toMatchObject({
force: false,
kind: "dedicated",
env: {
CHUNK14_PROBE: "expected",
},
@@ -1839,7 +1881,7 @@ describe("OpenCode persisted sessions", () => {
});
test("listImportableSessions returns rows without hydrating session messages", async () => {
const runtime = new TestOpenCodeRuntime();
const runtime = new TestOpenCodeHarness();
const openCodeClient = new TestOpenCodeClient();
const cwd = "/workspace/repo";
const otherCwd = "/workspace/other";
@@ -1936,7 +1978,10 @@ describe("OpenCode persisted sessions", () => {
};
runtime.enqueueClient(openCodeClient);
const client = new OpenCodeAgentClient(createTestLogger(), undefined, { runtime });
const client = new OpenCodeAgentClient(createTestLogger(), undefined, {
serverManager: runtime,
createClient: runtime.createClient,
});
const sessions = await client.listImportableSessions({ cwd, limit: 1 });
expect(sessions).toHaveLength(1);
@@ -1956,7 +2001,7 @@ describe("OpenCode persisted sessions", () => {
});
test("importSession reads only the selected OpenCode session without listing", async () => {
const runtime = new TestOpenCodeRuntime();
const runtime = new TestOpenCodeHarness();
const metadataClient = new TestOpenCodeClient();
const resumedClient = new TestOpenCodeClient();
const cwd = "/workspace/repo";
@@ -1995,7 +2040,10 @@ describe("OpenCode persisted sessions", () => {
runtime.enqueueClient(metadataClient);
runtime.enqueueClient(resumedClient);
const client = new OpenCodeAgentClient(createTestLogger(), undefined, { runtime });
const client = new OpenCodeAgentClient(createTestLogger(), undefined, {
serverManager: runtime,
createClient: runtime.createClient,
});
const imported = await client.importSession(
{ providerHandleId: "ses_selected", cwd },
{
@@ -2032,7 +2080,7 @@ describe("OpenCode persisted sessions", () => {
});
test("listImportableSessions matches Windows cwd paths with forward slashes", async () => {
const runtime = new TestOpenCodeRuntime();
const runtime = new TestOpenCodeHarness();
const openCodeClient = new TestOpenCodeClient();
const requestedCwd = "C:/Users/Administrator/GhostFactory";
const storedCwd = "C:\\Users\\Administrator\\GhostFactory";
@@ -2055,7 +2103,10 @@ describe("OpenCode persisted sessions", () => {
};
runtime.enqueueClient(openCodeClient);
const client = new OpenCodeAgentClient(createTestLogger(), undefined, { runtime });
const client = new OpenCodeAgentClient(createTestLogger(), undefined, {
serverManager: runtime,
createClient: runtime.createClient,
});
const sessions = await client.listImportableSessions({ cwd: requestedCwd, limit: 1 });
expect(sessions).toHaveLength(1);

View File

@@ -1,11 +1,12 @@
import { homedir } from "node:os";
import {
createOpencodeClient,
type AssistantMessage as OpenCodeAssistantMessage,
type Event as OpenCodeEvent,
type FilePartInput as OpenCodeFilePartInput,
type GlobalSession as OpenCodeGlobalSession,
type Message as OpenCodeMessage,
type OpencodeClient,
type OpencodeClientConfig,
type Part as OpenCodePart,
type Session as OpenCodeSession,
type TextPartInput as OpenCodeTextPartInput,
@@ -38,15 +39,15 @@ import {
type AgentStreamEvent,
type AgentTimelineItem,
type AgentUsage,
type FetchCatalogOptions,
type ImportableProviderSession,
type ImportProviderSessionContext,
type ImportProviderSessionInput,
type ListImportableSessionsOptions,
type ResolveAgentCreateConfigInput,
type ResolveAgentCreateConfigResult,
type ListModelsOptions,
type ListModesOptions,
type McpServerConfig,
type ProviderCatalog,
type ToolCallDetail,
type ToolCallTimelineItem,
} from "../agent-sdk-types.js";
@@ -65,9 +66,11 @@ import { withTimeout } from "../../../utils/promise-timeout.js";
import { execCommand } from "../../../utils/spawn.js";
import { buildToolCallDisplayModel } from "@getpaseo/protocol/tool-call-display";
import { mapOpencodeToolCall } from "./opencode/tool-call-mapper.js";
import { OpenCodeServerManager } from "./opencode/server-manager.js";
import {
formatDiagnosticStatus,
OpenCodeServerManager,
type OpenCodeServerManagerLike,
} from "./opencode/server-manager.js";
import {
formatProviderDiagnostic,
formatProviderDiagnosticError,
buildBinaryDiagnosticRows,
@@ -77,11 +80,6 @@ import {
import { runProviderTurn } from "./provider-runner.js";
import { renderPromptAttachmentAsText } from "../prompt-attachments.js";
import { composeSystemPromptParts } from "../system-prompt.js";
import {
createSdkOpenCodeClient,
type OpenCodeRuntime,
type OpenCodeServerAcquisition,
} from "./opencode/runtime.js";
import { normalizeProviderReplayTimestamp } from "../provider-history-timestamps.js";
import { revertOpenCodeConversationAndFiles } from "./opencode/rewind.js";
import type { ManagedProcessRegistry } from "../../managed-processes/managed-processes.js";
@@ -1214,31 +1212,15 @@ export const __openCodeInternals = {
};
interface OpenCodeAgentClientDeps {
runtime?: OpenCodeRuntime;
serverManager?: OpenCodeServerManagerLike;
createClient?: OpenCodeClientFactory;
managedProcesses?: ManagedProcessRegistry;
}
class ProductionOpenCodeRuntime implements OpenCodeRuntime {
constructor(private readonly serverManager: OpenCodeServerManager) {}
type OpenCodeClientFactory = (options: { baseUrl: string; directory: string }) => OpencodeClient;
async acquireServer(options: {
force: boolean;
env?: Record<string, string>;
}): Promise<OpenCodeServerAcquisition> {
return this.serverManager.acquire(options);
}
async ensureServerRunning(): Promise<{ port: number; url: string }> {
return this.serverManager.ensureRunning();
}
createClient(options: { baseUrl: string; directory: string }): OpencodeClient {
return createSdkOpenCodeClient(options);
}
async shutdown(): Promise<void> {
await this.serverManager.shutdown();
}
function createSdkOpenCodeClient(options: { baseUrl: string; directory: string }): OpencodeClient {
return createOpencodeClient(options satisfies OpencodeClientConfig & { directory: string });
}
export class OpenCodeAgentClient implements AgentClient {
@@ -1247,7 +1229,8 @@ export class OpenCodeAgentClient implements AgentClient {
readonly resolveCreateConfig = resolveOpenCodeCreateConfig;
readonly isCreateConfigUnattended = isOpenCodeCreateConfigUnattended;
private readonly runtime: OpenCodeRuntime;
private readonly serverManager: OpenCodeServerManagerLike;
private readonly createOpenCodeClient: OpenCodeClientFactory;
private readonly logger: Logger;
private readonly runtimeSettings?: ProviderRuntimeSettings;
private readonly modelContextWindows = new Map<string, number>();
@@ -1259,13 +1242,12 @@ export class OpenCodeAgentClient implements AgentClient {
) {
this.logger = logger.child({ module: "agent", provider: "opencode" });
this.runtimeSettings = runtimeSettings;
this.runtime =
deps.runtime ??
new ProductionOpenCodeRuntime(
OpenCodeServerManager.getInstance(this.logger, runtimeSettings, {
managedProcesses: deps.managedProcesses,
}),
);
this.serverManager =
deps.serverManager ??
OpenCodeServerManager.getInstance(this.logger, runtimeSettings, {
managedProcesses: deps.managedProcesses,
});
this.createOpenCodeClient = deps.createClient ?? createSdkOpenCodeClient;
}
async createSession(
@@ -1274,12 +1256,11 @@ export class OpenCodeAgentClient implements AgentClient {
options?: AgentCreateSessionOptions,
): Promise<AgentSession> {
const openCodeConfig = this.assertConfig(config);
const acquisition = await this.runtime.acquireServer({
force: false,
env: launchContext?.env,
});
const acquisition = launchContext?.env
? await this.serverManager.acquireDedicated(launchContext.env)
: await this.serverManager.acquireCurrent();
const { url } = acquisition.server;
const client = this.runtime.createClient({
const client = this.createOpenCodeClient({
baseUrl: url,
directory: openCodeConfig.cwd,
});
@@ -1336,9 +1317,9 @@ export class OpenCodeAgentClient implements AgentClient {
cwd,
};
const openCodeConfig = this.assertConfig(config);
const acquisition = await this.runtime.acquireServer({ force: false });
const acquisition = await this.serverManager.acquireCurrent();
const { url } = acquisition.server;
const client = this.runtime.createClient({
const client = this.createOpenCodeClient({
baseUrl: url,
directory: openCodeConfig.cwd,
});
@@ -1362,101 +1343,20 @@ export class OpenCodeAgentClient implements AgentClient {
}
}
async listModels(options: ListModelsOptions): Promise<AgentModelDefinition[]> {
const acquisition = await this.runtime.acquireServer({ force: options.force });
const { url } = acquisition.server;
const client = this.runtime.createClient({
baseUrl: url,
directory: options.cwd,
});
try {
// Background model discovery can be legitimately slow while OpenCode refreshes
// provider state, so allow longer than turn execution paths.
const response = await openCodeMetadataLimit(() =>
withTimeout(
client.provider.list({ directory: options.cwd }),
OPENCODE_PROVIDER_LIST_TIMEOUT_MS,
`OpenCode provider.list timed out after ${OPENCODE_PROVIDER_LIST_TIMEOUT_MS / 1000}s - server may not be authenticated or connected to any providers`,
),
);
if (response.error) {
throw new Error(`Failed to fetch OpenCode providers: ${JSON.stringify(response.error)}`);
}
const providers = response.data;
if (!providers) {
return [];
}
const connectedProviderIds = new Set(providers.connected);
// Providers with source "api" are managed by the OpenCode console/subscription (e.g. Pi
// coding agent). They do not appear in `connected` (which only lists env/config providers)
// but are fully usable — OpenCode authenticates them internally via the console session.
const isAccessible = (provider: { id: string; source: string }): boolean =>
connectedProviderIds.has(provider.id) || provider.source === "api";
// Fail fast if no providers are accessible at all
if (!providers.all.some(isAccessible)) {
throw new Error(
"OpenCode has no connected providers. Please authenticate with at least one provider " +
"(e.g., openai, anthropic), set appropriate environment variables (e.g., OPENAI_API_KEY), " +
"or log in to OpenCode Go via the console.",
);
}
const models: AgentModelDefinition[] = [];
this.modelContextWindows.clear();
for (const provider of providers.all) {
if (!isAccessible(provider)) {
continue;
}
for (const [modelId, model] of Object.entries(provider.models)) {
const definition = buildOpenCodeModelDefinition(provider, modelId, model);
const contextWindowMaxTokens = extractOpenCodeModelContextWindow(model);
if (contextWindowMaxTokens !== undefined) {
this.modelContextWindows.set(
buildOpenCodeModelLookupKey(provider.id, modelId),
contextWindowMaxTokens,
);
}
models.push(definition);
}
}
return models;
} finally {
acquisition.release();
}
}
async listModes(options: ListModesOptions): Promise<AgentMode[]> {
const acquisition = await this.runtime.acquireServer({ force: options.force });
async fetchCatalog(options: FetchCatalogOptions): Promise<ProviderCatalog> {
const acquisition = options.force
? await this.serverManager.acquireNew()
: await this.serverManager.acquireCurrent();
const { url } = acquisition.server;
const directory = options.cwd;
const client = this.runtime.createClient({ baseUrl: url, directory });
const client = this.createOpenCodeClient({ baseUrl: url, directory });
try {
const response = await openCodeMetadataLimit(() =>
withTimeout(
client.app.agents({ directory }),
10_000,
"OpenCode app.agents timed out after 10s",
),
);
if (response.error || !response.data) {
return DEFAULT_MODES;
}
const discovered = response.data
.filter(isSelectableOpenCodeAgent)
.map(mapOpenCodeAgentToMode);
return mergeOpenCodeModes(discovered);
const [models, modes] = await Promise.all([
this.fetchModelsFromClient(client, directory),
this.fetchModesFromClient(client, directory),
]);
return { models, modes };
} finally {
acquisition.release();
}
@@ -1464,9 +1364,9 @@ export class OpenCodeAgentClient implements AgentClient {
async listCommands(config: AgentSessionConfig): Promise<AgentSlashCommand[]> {
const openCodeConfig = this.assertConfig(config);
const acquisition = await this.runtime.acquireServer({ force: false });
const acquisition = await this.serverManager.acquireCurrent();
const { url } = acquisition.server;
const client = this.runtime.createClient({
const client = this.createOpenCodeClient({
baseUrl: url,
directory: openCodeConfig.cwd,
});
@@ -1485,9 +1385,9 @@ export class OpenCodeAgentClient implements AgentClient {
async listImportableSessions(
options?: ListImportableSessionsOptions,
): Promise<ImportableProviderSession[]> {
const acquisition = await this.runtime.acquireServer({ force: false });
const acquisition = await this.serverManager.acquireCurrent();
const { url } = acquisition.server;
const client = this.runtime.createClient({
const client = this.createOpenCodeClient({
baseUrl: url,
directory: options?.cwd ?? "",
});
@@ -1500,9 +1400,9 @@ export class OpenCodeAgentClient implements AgentClient {
}
async importSession(input: ImportProviderSessionInput, context: ImportProviderSessionContext) {
const acquisition = await this.runtime.acquireServer({ force: false });
const acquisition = await this.serverManager.acquireCurrent();
const { url } = acquisition.server;
const client = this.runtime.createClient({
const client = this.createOpenCodeClient({
baseUrl: url,
directory: input.cwd,
});
@@ -1545,7 +1445,7 @@ export class OpenCodeAgentClient implements AgentClient {
}
async shutdown(): Promise<void> {
await this.runtime.shutdown();
await this.serverManager.shutdown();
}
async getDiagnostic(): Promise<{ diagnostic: string }> {
@@ -1555,17 +1455,6 @@ export class OpenCodeAgentClient implements AgentClient {
defaultBinary: "opencode",
});
const availability = await checkProviderLaunchAvailable(launch);
const available = availability.available;
let serverStatus = "Not running";
let modelsValue = "Not checked";
let status = formatDiagnosticStatus(available);
try {
const { url } = await this.runtime.ensureServerRunning();
serverStatus = `Running (${url})`;
} catch (error) {
serverStatus = `Unavailable (${toDiagnosticErrorMessage(error)})`;
}
let authValue = "Not checked";
const authCommand = availability.available
@@ -1588,40 +1477,13 @@ export class OpenCodeAgentClient implements AgentClient {
}
}
if (available) {
try {
const models = await this.listModels({ cwd: homedir(), force: false });
modelsValue = String(models.length);
} catch (error) {
modelsValue = `Error - ${toDiagnosticErrorMessage(error)}`;
status = formatDiagnosticStatus(available, {
source: "model fetch",
cause: error,
});
}
if (!modelsValue.startsWith("Error -")) {
try {
await this.listModes({ cwd: homedir(), force: false });
} catch (error) {
status = formatDiagnosticStatus(available, {
source: "mode fetch",
cause: error,
});
}
}
}
return {
diagnostic: formatProviderDiagnostic("OpenCode", [
...(await buildCommandResolutionDiagnosticRows(launch, {
knownBinaryNames: ["opencode"],
})),
...(await buildBinaryDiagnosticRows(launch, availability)),
{ label: "Server", value: serverStatus },
{ label: "Auth", value: authValue },
{ label: "Models", value: modelsValue },
{ label: "Status", value: status },
]),
};
} catch (error) {
@@ -1630,6 +1492,83 @@ export class OpenCodeAgentClient implements AgentClient {
};
}
}
private async fetchModelsFromClient(
client: OpencodeClient,
directory: string,
): Promise<AgentModelDefinition[]> {
const response = await openCodeMetadataLimit(() =>
withTimeout(
client.provider.list({ directory }),
OPENCODE_PROVIDER_LIST_TIMEOUT_MS,
`OpenCode provider.list timed out after ${OPENCODE_PROVIDER_LIST_TIMEOUT_MS / 1000}s - server may not be authenticated or connected to any providers`,
),
);
if (response.error) {
throw new Error(`Failed to fetch OpenCode providers: ${JSON.stringify(response.error)}`);
}
const providers = response.data;
if (!providers) {
return [];
}
const connectedProviderIds = new Set(providers.connected);
const isAccessible = (provider: { id: string; source: string }): boolean =>
connectedProviderIds.has(provider.id) || provider.source === "api";
if (!providers.all.some(isAccessible)) {
throw new Error(
"OpenCode has no connected providers. Please authenticate with at least one provider " +
"(e.g., openai, anthropic), set appropriate environment variables (e.g., OPENAI_API_KEY), " +
"or log in to OpenCode Go via the console.",
);
}
const models: AgentModelDefinition[] = [];
this.modelContextWindows.clear();
for (const provider of providers.all) {
if (!isAccessible(provider)) {
continue;
}
for (const [modelId, model] of Object.entries(provider.models)) {
const definition = buildOpenCodeModelDefinition(provider, modelId, model);
const contextWindowMaxTokens = extractOpenCodeModelContextWindow(model);
if (contextWindowMaxTokens !== undefined) {
this.modelContextWindows.set(
buildOpenCodeModelLookupKey(provider.id, modelId),
contextWindowMaxTokens,
);
}
models.push(definition);
}
}
return models;
}
private async fetchModesFromClient(
client: OpencodeClient,
directory: string,
): Promise<AgentMode[]> {
const response = await openCodeMetadataLimit(() =>
withTimeout(
client.app.agents({ directory }),
10_000,
"OpenCode app.agents timed out after 10s",
),
);
if (response.error || !response.data) {
return DEFAULT_MODES;
}
const discovered = response.data.filter(isSelectableOpenCodeAgent).map(mapOpenCodeAgentToMode);
return mergeOpenCodeModes(discovered);
}
private assertConfig(config: AgentSessionConfig): OpenCodeAgentConfig {
if (config.provider !== "opencode") {
throw new Error(`OpenCodeAgentClient received config for provider '${config.provider}'`);

View File

@@ -1,6 +1,6 @@
import type { ChildProcess } from "node:child_process";
import { EventEmitter } from "node:events";
import { describe, expect, test } from "vitest";
import { afterEach, describe, expect, test, vi } from "vitest";
import { createTestLogger } from "../../../test-utils/test-logger.js";
import type {
@@ -17,12 +17,16 @@ import {
type OpenCodeServerProcessSpawner,
} from "./opencode/server-manager.js";
afterEach(() => {
vi.useRealTimers();
});
describe("OpenCodeServerManager generations", () => {
test("rotation creates a new current server without killing a referenced old server", async () => {
const { manager, runtime } = createTestManager([4101, 4102]);
const oldAcquisition = await manager.acquire({ force: false });
const newAcquisition = await manager.acquire({ force: true });
const oldAcquisition = await manager.acquireCurrent();
const newAcquisition = await manager.acquireNew();
expect(oldAcquisition.server.url).toBe("http://127.0.0.1:4101");
expect(newAcquisition.server.url).toBe("http://127.0.0.1:4102");
@@ -37,11 +41,11 @@ describe("OpenCodeServerManager generations", () => {
test("new acquisitions after rotation use the new server", async () => {
const { manager, runtime } = createTestManager([4201, 4202]);
const oldAcquisition = await manager.acquire({ force: false });
const rotatedAcquisition = await manager.acquire({ force: true });
const oldAcquisition = await manager.acquireCurrent();
const rotatedAcquisition = await manager.acquireNew();
rotatedAcquisition.release();
const nextAcquisition = await manager.acquire({ force: false });
const nextAcquisition = await manager.acquireCurrent();
expect(nextAcquisition.server.url).toBe("http://127.0.0.1:4202");
expect(runtime.terminatedPorts).toEqual([]);
@@ -50,15 +54,15 @@ describe("OpenCodeServerManager generations", () => {
oldAcquisition.release();
});
test("concurrent forced acquisitions share one fresh generation", async () => {
test("concurrent new-server acquisitions share one fresh generation", async () => {
const { manager, runtime } = createTestManager([4251, 4252, 4253]);
const initialAcquisition = await manager.acquire({ force: false });
const initialAcquisition = await manager.acquireCurrent();
initialAcquisition.release();
const [modelsAcquisition, modesAcquisition] = await Promise.all([
manager.acquire({ force: true }),
manager.acquire({ force: true }),
manager.acquireNew(),
manager.acquireNew(),
]);
expect(modelsAcquisition.server.url).toBe("http://127.0.0.1:4252");
@@ -72,8 +76,8 @@ describe("OpenCodeServerManager generations", () => {
test("release is idempotent", async () => {
const { manager, runtime } = createTestManager([4301, 4302]);
const oldAcquisition = await manager.acquire({ force: false });
const newAcquisition = await manager.acquire({ force: true });
const oldAcquisition = await manager.acquireCurrent();
const newAcquisition = await manager.acquireNew();
newAcquisition.release();
oldAcquisition.release();
@@ -85,8 +89,8 @@ describe("OpenCodeServerManager generations", () => {
test("shutdown kills current and retired servers", async () => {
const { manager, runtime } = createTestManager([4401, 4402]);
await manager.acquire({ force: false });
await manager.acquire({ force: true });
await manager.acquireCurrent();
await manager.acquireNew();
await manager.shutdown();
@@ -96,7 +100,7 @@ describe("OpenCodeServerManager generations", () => {
test("shutdown still signals a process after an earlier kill signal if it has not exited", async () => {
const { manager, runtime } = createTestManager([4451]);
await manager.acquire({ force: false });
await manager.acquireCurrent();
runtime.processForPort(4451).markKillSignalSent();
await manager.shutdown();
@@ -104,13 +108,64 @@ describe("OpenCodeServerManager generations", () => {
expect(runtime.terminatedPorts).toEqual([4451]);
});
test("startup timeout kills the spawned server and removes its managed-process record", async () => {
vi.useFakeTimers();
const { manager, runtime } = createTestManager([4471], { autoAnnounce: false });
const acquisition = manager.acquireCurrent();
const failure = expect(acquisition).rejects.toThrow("OpenCode server startup timeout");
await runtime.settle();
await vi.advanceTimersByTimeAsync(30_000);
await failure;
expect(runtime.terminatedPorts).toEqual([4471]);
expect(await runtime.managedProcesses.list()).toEqual([]);
});
test("shutdown kills a server that is still starting", async () => {
const { manager, runtime } = createTestManager([4472], { autoAnnounce: false });
const acquisition = manager.acquireCurrent();
await runtime.settle();
await manager.shutdown();
await expect(acquisition).rejects.toThrow("OpenCode server exited with code null");
expect(runtime.terminatedPorts).toEqual([4472]);
expect(await runtime.managedProcesses.list()).toEqual([]);
});
test("dedicated server startup is protected from retired cleanup", async () => {
const { manager, runtime } = createTestManager([4473, 4474], { autoAnnounce: false });
const currentStart = manager.acquireCurrent();
await runtime.settle();
runtime.processForPort(4473).announceListening();
const currentAcquisition = await currentStart;
const dedicatedStart = manager.acquireDedicated({ TEST_ENV: "custom" });
await runtime.settle();
currentAcquisition.release();
expect(runtime.terminatedPorts).toEqual([]);
runtime.processForPort(4474).announceListening();
const dedicatedAcquisition = await dedicatedStart;
expect(dedicatedAcquisition.server.url).toBe("http://127.0.0.1:4474");
dedicatedAcquisition.release();
expect(runtime.terminatedPorts).toEqual([4474]);
});
test("repeated rotations leave zero unreferenced retired servers", async () => {
const { manager, runtime } = createTestManager([4501, 4502, 4503]);
const firstAcquisition = await manager.acquire({ force: false });
const secondAcquisition = await manager.acquire({ force: true });
const firstAcquisition = await manager.acquireCurrent();
const secondAcquisition = await manager.acquireNew();
secondAcquisition.release();
const thirdAcquisition = await manager.acquire({ force: true });
const thirdAcquisition = await manager.acquireNew();
thirdAcquisition.release();
firstAcquisition.release();
@@ -122,7 +177,7 @@ describe("OpenCodeServerManager managed process ledger", () => {
test("records helper server starts and removes the record on process exit", async () => {
const { manager, runtime } = createTestManager([4601]);
await manager.acquire({ force: false });
await manager.acquireCurrent();
expect(await runtime.managedProcesses.list()).toEqual([
{
@@ -146,7 +201,7 @@ describe("OpenCodeServerManager managed process ledger", () => {
test("removes helper server records on shutdown", async () => {
const { manager, runtime } = createTestManager([4602]);
await manager.acquire({ force: false });
await manager.acquireCurrent();
await manager.shutdown();
@@ -155,11 +210,16 @@ describe("OpenCodeServerManager managed process ledger", () => {
});
});
function createTestManager(ports: number[]): {
function createTestManager(
ports: number[],
options: { autoAnnounce?: boolean } = {},
): {
manager: OpenCodeServerManager;
runtime: FakeOpenCodeServerRuntime;
} {
const runtime = new FakeOpenCodeServerRuntime(ports);
const runtime = new FakeOpenCodeServerRuntime(ports, {
autoAnnounce: options.autoAnnounce ?? true,
});
return {
manager: new OpenCodeServerManager({
logger: createTestLogger(),
@@ -177,11 +237,13 @@ class FakeOpenCodeServerRuntime {
readonly managedProcesses = new FakeManagedProcesses();
readonly terminatedPorts: number[] = [];
private readonly ports: number[];
private readonly autoAnnounce: boolean;
private readonly processesByChild = new Map<ChildProcess, FakeOpenCodeProcess>();
private readonly processesByPort = new Map<number, FakeOpenCodeProcess>();
constructor(ports: number[]) {
constructor(ports: number[], options: { autoAnnounce: boolean }) {
this.ports = [...ports];
this.autoAnnounce = options.autoAnnounce;
}
get launchedPorts(): number[] {
@@ -206,7 +268,9 @@ class FakeOpenCodeServerRuntime {
const process = new FakeOpenCodeProcess({ port, pid: 10_000 + port });
this.processesByChild.set(process.child, process);
this.processesByPort.set(port, process);
queueMicrotask(() => process.announceListening());
if (this.autoAnnounce) {
queueMicrotask(() => process.announceListening());
}
return process.child;
};

Some files were not shown because too many files have changed in this diff Show More