Compare commits

..
Author SHA1 Message Date
Mohamed Boudra 94f7701155 fix(app): set aria-checked directly on Switch so toBeChecked() assertions work
RNW's createDOMProps maps direct aria-* props to the DOM but does not
read from accessibilityState.checked. The Switch was relying on the
latter, so no aria-checked attribute was ever rendered — making the
component opaque to screen readers and breaking Playwright's
toBeChecked() assertions in E2E tests. Passing aria-checked={value}
alongside the existing accessibilityState fixes both.
2026-05-05 08:36:20 +07:00
Mohamed Boudra ba68e2599b fix(app/e2e): address PR review blockers on desktop-updates spec
Blocker 1 — ARIA for install button: replace index-based testId locators
in clickInstallUpdate and expectInstallInProgress with getByRole("button")
using the accessible name ("Install & restart" / "Installing...").

Blocker 2 — Electron dialog path: add dialog.ask to the mock bridge so
confirmDialog() hits the Electron code path instead of falling back to
window.confirm. The mock stores captured args on window.__capturedDialogCall;
interceptDaemonManagementConfirmDialog reads them via waitForFunction+evaluate.
Add confirmShouldAccept config flag so tests control accept/dismiss without
a Playwright dialog event. Update all daemon management tests to set the flag.

Also: console.warn on PID file read failure, comment explaining the no-Electron-
runner approach, rename dialog → dialogArgs at call sites.
2026-05-05 08:27:41 +07:00
Mohamed Boudra 7631505a79 test(app/e2e): add desktop-updates spec covering update banner and daemon lifecycle (cluster G4)
Adds a new Electron-only E2E spec and companion helper module covering:
- Update callout renders with correct version and shows Installing… on click
- Daemon management toggle confirm dialog copy, cancel, and confirm flows
- Daemon status panel seeded from the real running E2E daemon (version, PID, log path)
- Stopping then re-enabling management observes a fresh PID from the stateful IPC mock

Exports E2E_PASEO_HOME from globalSetup so tests can read the paseo.pid lock file
and derive the daemon log path without hardcoding paths.
2026-05-05 08:13:35 +07:00
Mohamed Boudra ed308bc64c refactor(app/e2e): eliminate raw locators from all offending spec bodies (cluster #13) (#727)
Rewrites 7 spec files to use DSL helpers throughout — zero raw
page.locator/getByText/getByTestId in test() bodies. Adds 30+ new
helper primitives across 7 existing helper modules.
2026-05-05 01:02:50 +08:00
Mohamed Boudra a72d5214ce refactor(app/e2e): rewrite settings-navigation spec to zero raw locators (#726)
Replace 21 raw page.locator/getByTestId/getByText calls in the spec body
with named DSL operations in helpers/settings.ts. Each test body now reads
as prose: open settings, navigate to section, expect content.

New helpers: openCompactSettings, expectCompactSettingsList,
expectSettingsSidebarVisible/Hidden/Sections, goBackInSettings,
expectSettingsBackButton, clickSettingsBackToWorkspace,
verifyLegacyHostSettingsRedirect, openCompactSettingsHost,
expectHostSettingsUrl, expectAddHostMethodOptions, fillDirectHostUri,
expectDirectHostFormValues, expectDirectHostSslEnabled,
expectDirectHostUriValue/Hidden, expectDiagnosticsContent,
expectAboutContent, expectGeneralContent.

Export requireServerId from sidebar.ts so helpers encapsulate serverId
logic — spec has no direct env reads.

Also fix pre-existing typecheck error in use-keyboard-shortcuts.ts:
cast action.route to the expo-router Href type at call sites.
2026-05-05 00:52:58 +08:00
Mohamed Boudra dea127bfe5 refactor(app): replace screen test slop batch 2 with proper coverage (#725)
Deletes four screen-level .test.tsx files (2600+ lines of vi.mock + JSDOM
slop) and replaces each with verified coverage:

- sessions-screen.test.tsx: covered by archive-tab.spec.ts which exercises
  the sessions screen via openSessions() with real agents
- workspace-draft-agent-tab.test.tsx: covered by new-workspace.spec.ts
  "redirects to optimistic draft tab before agent creation resolves"
- new-workspace-screen.test.tsx: covered by new-workspace.spec.ts (main
  submit, branch selection, PR selection, optimistic draft tab) plus
  new-workspace-picker-item.test.ts (pickerItemToCheckoutRequest)
- project-settings-screen.test.tsx: covered by project-config-form.test.ts
  (all round-trip string/array lifecycle semantics) and projects-settings.spec.ts
  (save flow with passthrough field preservation)

Extracts syncPickerPrAttachment from new-workspace-screen.tsx into a new
pure module new-workspace-picker-state.ts with 5 zero-mock unit tests
covering: initial PR selection, branch selection without change, PR
replacement, PR removal on branch switch, and no-duplicate guard.
2026-05-05 00:33:24 +08:00
Mohamed Boudra 1f21974a76 refactor(app): replace 4 component test slop files with pure module extractions (#724)
Delete message-input.test.tsx, left-sidebar.test.tsx,
agent-stream-view.test.tsx, and agent-panel.test.tsx — all heavy
vi.mock + JSDOM + toHaveBeenCalledWith slop.

Coverage preserved by extracting the testable derivations:

- agent-stream-view-data.ts: isSameAssistantBlockGroup,
  getAssistantBlockSpacing, resolveInlineWorkingIndicatorItemId —
  14 pure unit tests, zero mocks
- message-input-state.ts: computeCanStartDictation (dictation readiness
  gate) — 7 pure unit tests, zero mocks

Remaining behaviors confirmed covered by existing E2E:
- left-sidebar subscription-while-hidden → sidebar-workspace.spec.ts
- agent-panel render isolation + archived agent store hygiene → archive-tab.spec.ts
- message-input attachment menu / submit icon → workspace-setup-streaming.spec.ts
2026-05-05 00:33:20 +08:00
Mohamed Boudra ef9140df01 chore(app): clean up e2e helpers — delete duplicates, dead code, and redundant seeding (#723)
- Delete clickNewTabButton (duplicate of clickNewChat) and clickNewTerminalButton (duplicate of clickNewTerminal); update all call sites
- Rename clickTerminal → clickNewTerminal to match clickNewChat naming pattern
- Delete waitForLauncherPanel (deprecated no-op)
- Delete waitForAgentFinishUI and getToolCallCount (dead exports never imported)
- Remove ensureE2EStorageSeeded, assertE2EUsesSeededTestDaemon, and related helpers from app.ts — the paseoE2ESetup auto-fixture seeds via addInitScript on every navigation, making these redundant
- Simplify gotoAppShell to a one-liner; simplify gotoHome to use .or() instead of three-way if-else chain
- Strip try/catch self-heal from ensureHostSelected — the fixture guarantees preferences alignment, so the workaround is never needed
2026-05-05 00:33:16 +08:00
Mohamed Boudra 27f5896ebe test(app): triage desktop test files — delete 16-mock component slop, tighten attachment store (#721)
* test(app): triage desktop test files — delete 16-mock component slop, tighten attachment store assertion

Removes desktop-updates-section.test.tsx (16 internal mocks, JSDOM, toHaveBeenCalledWith chains on component renders).
Tightens desktop-attachment-store.test.ts: toHaveBeenCalled() → toHaveBeenCalledWith(attachment).

* refactor(app): extract daemon management toggle coordinator with unit tests

Fills the coverage gap left by deleting desktop-updates-section.test.tsx.
Extracts executeDaemonManagementToggle from useDaemonManagementToggle — pure
async coordinator with injected deps (no vi.mock). Unit tests verify the three
key invariants: settings persist before stop, stop is skipped when not
desktop-managed, and start/stop are invoked on enable/disable.
2026-05-04 23:53:59 +08:00
Mohamed Boudra 3bd193b5ba refactor(app/e2e): migrate workspace-cwd spec to withWorkspace fixture (#722)
Drops the per-test manual boilerplate (connectWorkspaceSetupClient,
createTempGitRepo, seedProjectForWorkspaceSetup, openProject,
openHomeWithProject, navigateToWorkspaceViaSidebar, try/finally cleanup)
in favour of the withWorkspace fixture landed in #717.

Both tests — main checkout and worktree — verified green locally.
2026-05-04 23:53:48 +08:00
Mohamed Boudra 7dd6ddef53 refactor(app): extract composer-actions module with pure tests (#720)
Move composer cancel/queue/attachment/dispatch orchestration out of
composer.tsx into a React-free actions module. The module takes its
dependencies (send client, queue writer, stream writer, attachment
persister, image picker) as injected ports, so the new
composer-actions.test.ts can drive every action with inline fakes —
zero vi.mock of internal modules, zero JSDOM, zero React.

The old composer.test.tsx (heavy mocked component test) is removed
and replaced with composer-actions.test.ts (31 unit tests).

Also extract isWorkspaceAttachment / userAttachmentsOnly /
workspaceAttachmentToSubmitAttachment from composer-workspace-attachments.tsx
into a sibling .ts so the actions module (and composer-attachments.ts)
can use them without dragging React/RN/lucide into a pure test graph.
2026-05-04 23:43:32 +08:00
Mohamed Boudra 6197336f60 refactor(app): extract resolveAgentForm pure reducer from use-agent-form-state (#719)
Moves all preference-resolution logic into a pure `resolveAgentForm(state, action)` reducer
with a discriminated-union action type. The hook becomes a thin React container: it holds state
via `useReducer`, dispatches actions, and owns the hydration-ref timing logic.

Removes the `__private__` export and the vi.mock-heavy live test in favour of direct pure-function
unit tests (53 tests, zero vi.mock of internal modules).
2026-05-04 23:43:18 +08:00
Mohamed Boudra d97a5ec036 refactor(app): extract keyboard shortcut routing into a pure function (#718)
Replace 8-mock hook test (expo-router, layout, platform, navigation,
4 stores) with a pure unit test of the routing decision. The hook now
reads pathname/layout/key, calls routeKeyboardShortcut, and dispatches
the resulting ShortcutAction — no behaviour change.
2026-05-04 23:33:12 +08:00
Mohamed Boudra 13adfee3a4 refactor(app): make use-agent-input-draft storage injectable (#716)
Extract DraftStorage interface and createAgentInputDraftCore factory
to a new use-agent-input-draft-core.ts. Pure helper functions (resolveDraftKey,
resolveEffectiveComposerModelId, etc.) move there too, breaking the test
file's transitive dependency on AsyncStorage and useAgentFormState.

The test now imports directly from the core module, uses a Map-backed
in-memory storage, and has zero vi.mock calls. Tests assert behavior
("save then load returns the same draft") rather than call counts.
2026-05-04 23:23:36 +08:00
Mohamed Boudra 5387a16bb6 feat(app/e2e): introduce withWorkspace fixture and DSL helpers (#717)
Adds a `withWorkspace` Playwright fixture plus composable helpers
(permissions, sidebar, composer, agent-stream, settings) so specs read
as user-level intent. Migrates workspace-lifecycle and settings-host-page
to the new DSL as proof.
2026-05-04 23:22:59 +08:00
Mohamed Boudra 6d7848059e test(app): tighten weak assertions in utils tests (#714)
Replace toBeDefined/toBeTruthy guards with concrete shape assertions
using toContainEqual(expect.objectContaining(...)) in diff-highlighter
tests, and toMatch(/^script-draft-\d+$/) for the ID check in
project-config-form tests.
2026-05-04 23:13:09 +08:00
Mohamed Boudra a324ac5d71 refactor(app): extract pr-pane derivations into pure utils and unit tests (#713)
Move getStateLabel and getActivityVerb out of pr-pane.tsx into pr-pane-data.ts
so they can be tested without JSDOM, React, or vi.mock. Add exhaustive unit tests
for both helpers alongside the existing mapPrPaneData/formatAge/deriveAvatarColor
coverage. Delete pr-pane.test.tsx — the component test was asserting on derived
label/icon strings that are now covered by pure function tests.
2026-05-04 23:13:05 +08:00
Mohamed Boudra 6f04d33b18 refactor(app): assert store state rather than mock call counts in store tests (#715)
Rewrites two store tests to verify observable state instead of implementation-level call counts.

navigation-active-workspace-store: the "persists" test now uses a functional in-memory AsyncStorage mock that retains written values across vi.resetModules(), then rehydrates a fresh store instance and asserts the resulting selection state matches what was written.

checkout-git-actions-store: removes toHaveBeenCalledWith/toHaveBeenCalledTimes assertions on client methods; replaces with getStatus() assertions that reflect the store's actual state after each action completes or fails.
2026-05-04 23:06:41 +08:00
Mohamed Boudra 1526532ff4 refactor(app): fix type-aware lint errors in UI components (#710) 2026-05-04 22:14:41 +08:00
Mohamed Boudra dbff649d88 perf(cli): run CLI E2E tests in parallel (#708)
* perf(cli): run E2E tests in parallel via worker pool

The custom CLI test runner ran 35 tsx test files sequentially, making the
cli-tests CI job the longest in main CI (~17 minutes). Each test file
already isolates its own daemon (ephemeral port + tmp PASEO_HOME), so
parallelism was just gated by the runner.

Replace the sequential recursion with a fixed-size worker pool (default
concurrency=4 to match GitHub Actions standard runners; override via
PASEO_CLI_TEST_CONCURRENCY). Buffer per-test stdout/stderr and flush as
a contiguous block on completion so concurrent output stays readable,
and report per-test wall clock plus the five slowest tests.

Local wall clock drops from ~12-15 minutes serial to ~2:49 with
concurrency=4. The slowest single test (05-agent-run, 49s) is now the
floor; CI should land near 4-5 minutes.

* fix(cli-tests): deflake 30-chat under load and shard CI across 3 runners

`chat wait` reads the latest message id and then subscribes for newer
messages. Under CI load the subprocess takes >1s to bootstrap, so the
old test's 250ms head start before posting "second message" raced
against that read. When the post landed first, the subprocess saw the
second message as latest and timed out waiting for a newer one. Replace
the brittle delay with a post-and-race loop: every iteration posts a
fresh "second message" and races a 250ms tick against the wait promise,
so whichever message lands after the snapshot wakes wait deterministically.

CI was also slower than local (10.3min vs 2.8min). On 4-vCPU GHA
runners, 35 sequential-CPU-time of ~1850s caps wall clock around 8 min
even at concurrency=4. Shard the suite across 3 GHA runners via matrix
strategy. The runner now reads PASEO_CLI_TEST_SHARD/SHARD_TOTAL and
distributes files into buckets — known long-pole tests
(05/06/11/13/14-...) round-robin first so they spread across shards
instead of clustering by their numeric prefixes, then the remainder
fills in the reverse direction to balance light load.

Local run is unchanged (single shard by default). Expected per-shard
wall on CI: ~2:30 + setup ≈ ~4:30 total.
2026-05-04 22:14:35 +08:00
Mohamed Boudra a6ad4ff430 refactor(agent-providers): fix type-aware lint errors in diagnostic-utils and generic-acp-agent (#705)
* refactor(agent-providers): add type guards and fix type-aware lint errors in diagnostic-utils, generic-acp-agent, and tool-call-detail-primitives

* fix(server): defer PTY onExit to allow pending data events to fire

On Linux, node-pty's onExit callback can arrive before the last buffered
PTY data chunk is delivered via onData. Wrapping finish() in setImmediate
gives libuv's I/O poll phase a chance to flush remaining PTY reads before
the promise resolves, preventing tail bytes from being silently dropped.

Fixes a flaky worktree-bootstrap test that reliably reproduced on Linux CI.

* unslop: remove dead guard, fix import order, trim comment

- generic-acp-agent: remove isNonEmptyStringArray and its guard; the
  constructor parameter is already typed [string, ...string[]] so the
  check can never fire
- provider-registry: move isNonEmptyStringArray below the import block
  instead of between two import groups
- worktree: condense 3-line comment to one line per project convention
2026-05-04 21:50:23 +08:00
Mohamed Boudra 165fc11955 refactor(app): fix type-aware lint errors in voice, tabs store, host-connection, audio recorder (#706)
Clear all type-aware lint errors in four app state/runtime files:

- voice-context.tsx: bind runtime methods before passing to useSyncExternalStore
  and object spreads to satisfy the unbound-method rule
- workspace-tabs-store.ts: replace unsafe `as` casts with isPlainRecord type
  predicate + toObjectRecord; add coerceWorkspaceTabTarget to bridge unknown
  storage data to WorkspaceTabTarget without assertions
- host-connection.ts: same isPlainRecord/toObjectRecord pattern; replace
  String(record.x ?? "") calls with typeof guards to fix no-base-to-string
- use-audio-recorder.native.ts: construct real Blob instead of casting an
  object literal; restructure useEffect for consistent-return; drop await on
  void recorder.record(); use instanceof Error for message extraction; remove
  redundant Boolean() wrapping
2026-05-04 21:50:19 +08:00
Mohamed Boudra 426b81e5d2 refactor(claude-agent): replace unsafe type assertions with type guards (#707)
Adds isObjectRecord, isUnknownArray, isChildProcessWithStreams, and
isImageMimeType type predicates to eliminate all no-unsafe-type-assertion
violations surfaced by typeAware lint mode. Fixes floating promise, adds
throw after exhaustive switch, and widens readCompactionMetadata to accept
unknown so callers need no casts.
2026-05-04 21:50:14 +08:00
Mohamed Boudra 2c90498cb8 refactor(server): add getErrorMessage helper and fix error-related type-aware lint errors (#704)
* refactor(server): add getErrorMessage helper and fix error-related type-aware lint errors in session.ts

* fix build: correct parseClientCapabilities type handling
2026-05-04 21:23:44 +08:00
Mohamed Boudra c42d48a8ff refactor(agent-providers): add toObjectRecord helper and fix type-aware lint errors in codex and claude agents (#699) 2026-05-04 20:30:24 +08:00
Mohamed Boudra 54b1f524b7 refactor(speech): add ONNX type augmentation and fix type-aware lint errors (#701) 2026-05-04 20:29:37 +08:00
Mohamed Boudra 85db9711b3 fix(app): make e2e setup an auto fixture so first-of-spec tests get setup (#702)
* fix(app): make e2e setup an auto fixture so it runs for the first test of every spec

The fixtures.ts beforeEach was declared at the top level of a non-test
fixture file. Playwright sometimes skipped it for the first test of a
subsequent spec when multiple specs ran in the same worker — that test
hit page.evaluate without the seed nonce or daemon registry in
localStorage and failed (then passed on retry, masking the bug behind
retries: 1). Reproduced locally by running sidebar-workspace then
startup-loading: the first startup-loading test got no fixture setup
and threw "Expected e2e seed nonce".

Move the setup into an `auto: true` fixture, the canonical Playwright
pattern for running setup for every test in a workspace. The afterEach
console-attachment is folded into the same fixture's teardown.

* refactor(server): simplify nullish handling in workspace-git-metadata

Drop the explicit `null` arg in `deriveProjectSlug(input.cwd, null)` —
the second parameter already defaults to `null`. Inline the
`repoName && repoName.length > 0` check in `parseGitHubRepoNameFromRemote`
since `pop()` on a non-empty array returns a non-empty string here, and
`|| null` covers the empty-string case directly.
2026-05-04 20:10:26 +08:00
Mohamed Boudra 3c2a98b09b refactor(relay): fix type-aware lint errors in WebSocket and crypto handling (#698) 2026-05-04 19:39:33 +08:00
Mohamed Boudra 9e3ebd9665 schedule: add paseo schedule update to edit schedules in place (#694)
* schedule: add `paseo schedule update` to edit schedules in place

Editing a schedule today requires delete+recreate, losing run history
and the schedule id. This adds an additive RPC and CLI command that
patches name, prompt, cadence, new-agent target fields, max-runs, and
expires-in without touching runs or in-flight executions. nextRunAt is
recomputed only when the cadence actually changes.

* schedule(cli): share cadence flag parser between create and update

Both paths were turning --every/--cron into a ScheduleCadence with
near-identical code. Extract `parseCadenceFromFlags` so the literals
and the exclusivity check live in one place; create wraps it to
require a value, update lets it stay optional.
2026-05-04 19:39:13 +08:00
Mohamed Boudra a1606ea515 fix(server): derive non-GitHub project display names from remote owner/repo (#697)
Reconciliation overwrote correctly-set project display names with the
directory name for non-GitHub remotes (e.g. gitlab.com/acme/app), because
buildWorkspaceGitMetadataFromSnapshot only handled GitHub URLs while the
registry-model layer used the more general deriveProjectGroupingKey path.
Reuse that path here so both layers agree on the owner/repo display name.
2026-05-04 19:38:58 +08:00
Mohamed Boudra fa8fab75aa refactor(app): remove unnecessary String() and Boolean() conversions from type-aware lint fixes (#696) 2026-05-04 18:09:48 +08:00
Mohamed Boudra a3f350a7dc refactor(server): remove unnecessary String() conversions from type-aware lint fixes (#695) 2026-05-04 18:09:43 +08:00
Mohamed Boudra 6a8f507d10 refactor(server): remove redundant null from unknown union types (#693) 2026-05-04 17:23:30 +08:00
Mohamed Boudra ea0bd47c1d refactor(app): remove redundant type constituents from type definitions (#692) 2026-05-04 17:23:09 +08:00
Mohamed Boudra f4dcb3954a refactor(server): replace JSON.parse type assertions with Zod validation (#691)
Replace unsafe 'JSON.parse(content) as Type' patterns with proper Zod schema
validation. This fixes type-aware lint errors and provides runtime safety.

- pid-lock.ts: Add pidLockInfoSchema and parsePidLockInfo helper
- package-version.ts: Add packageJsonSchema and parsePackageJson helper
- relay-transport.ts: Add isRecord type guard to avoid unsafe type assertion
2026-05-04 16:53:40 +08:00
Mathias KurzandMathias Kurz 7fc4dd1e86 Feat/open projects config to any (#681)
* feat(server): derive owner/repo display name for any remote host

Generalize deriveProjectGroupingName so any remote:<host>/<segments...>
project key returns the last two path segments (owner/repo) instead of
just the trailing segment. Path-fallback for non-remote keys is unchanged.

Brings GitLab, Gitea, Bitbucket, and self-hosted remotes to parity with
the prior github.com-only behavior — no separate special-case needed.

* feat(app): show projects from any git remote in Projects settings

Remove the isSupportedProjectKey filter so workspaces with non-GitHub
remotes (GitLab, Gitea, Bitbucket, self-hosted, ssh-style) appear in the
Projects list and route to the project settings screen. The daemon RPCs,
config schema, and registry were already host-agnostic; this lifts a
client-side filter that hid them.

Also remove the now-vestigial hiddenUnsupportedRemoteCount field from
ProjectSummary, BuildProjectsResult, and UseProjectsResult — once the
filter is gone, the count is always zero and the field is dead state.
This is an internal app-package type, not a wire schema, so deletion is
safe.

* chore(app): simplify Projects empty state to "No projects yet"

Drop the "Non-GitHub remote projects aren't supported yet" empty-state
branch — it can no longer fire now that any git remote is shown. The
empty state is unconditional now.

* test(app): cover non-GitHub remote in projects-settings e2e

Add a fixture that creates a temp git repo with origin pointing at a
gitlab.com URL and exercises the same paseo.json read/edit/save flow
already covered for local repos. Verifies the project surfaces with the
"acme/app" display name and that the round-trip persists correctly.

Extracted the remote-setup branch in createTempGitRepo into a small
configureRemote helper to keep the main function under the cyclomatic
complexity limit.

---------

Co-authored-by: Mathias Kurz <mkurz@stamus-networks.com>
2026-05-04 16:51:33 +08:00
Mohamed Boudra 78bb56d90d schedule: fire --every now by default, add run-once for cron-style triggers (#689)
Interval schedules used to wait the full interval before their first run, which
made `--every 1d` feel broken for fresh schedules. Now `--every` fires
immediately on creation; `--cron` keeps waiting for the next slot. `--no-run-now`
and `--run-now` override the defaults, and the parser rejects redundant combos.

Adds `paseo schedule run-once <id>` to manually trigger a single run without
advancing cadence or recomputing completion. The new `runOnCreate` field and
`schedule/run-once` RPC are additive on the WebSocket schema.
2026-05-04 16:37:33 +08:00
Mohamed Boudra a788064e32 mcp(create_agent): validate mode and refuse silent cross-provider inheritance (#688)
The MCP create_agent tool inherited the parent agent's mode regardless of
provider, so a Claude parent in bypassPermissions spawning an opencode
child would feed an invalid mode to opencode and the new agent would die
in error state. The fix:

- Add an optional mode field to the agent-to-agent input schema.
- Validate any explicit mode against the target provider's available
  modes; throw with the list when invalid.
- Inherit the caller's mode only when both sides use the same provider;
  otherwise refuse with a helpful error listing the target's modes.
2026-05-04 16:33:19 +08:00
Mohamed Boudra 74f0b9da56 refactor(relay): remove unnecessary awaits from synchronous crypto functions (#687) 2026-05-04 16:33:14 +08:00
Mohamed Boudra aa01f23ab3 feat(cli): paseo worktree create with MCP parity (#686)
Mirrors the MCP create_worktree tool's three modes (branch-off,
checkout-branch, checkout-pr) against the daemon's existing
createPaseoWorktree RPC, so CLI users (especially over --host) can
provision worktrees in the paseo-managed location.
2026-05-04 16:32:50 +08:00
Mohamed Boudra ae3f1bb5df fix(cli): update schedule provider/mode error message substring in test
The error message gained a `--mode` mention when the schedule mode flag
landed; the existing CLI integration test still asserted on the old
exact-prefix substring and broke main CI for all PR builds.
2026-05-04 15:31:23 +07:00
Mohamed Boudra 2064bac918 cli(schedule): require --cwd when --host is set (#685)
process.cwd() is the local path and won't exist on a remote daemon, so
fail fast client-side instead of running the schedule in a wrong dir.
2026-05-04 16:26:55 +08:00
Mohamed Boudra fa4294a0e1 voice: quieter thinking tone, log cleanup, and small ui polish
- Add scripts/lower-thinking-tone.mjs to scale the thinking-tone PCM
  amplitude in-place; apply it at 0.1 so the cue is a soft indicator
  instead of triggering the VAD via mic feedback.
- Drop the "Received first voice_audio_chunk" log that re-fires every
  summary window because the chunk counter is reset.
- Match Spoke message icon and label sizing to the standard tool-call
  badge.
- Append a spoken-input instruction so voice replies route through the
  speak tool.
2026-05-04 14:36:48 +07:00
Mohamed Boudra c993858898 Merge branch 'trace-voice-interrupt-message-gap' 2026-05-04 13:21:55 +07:00
Mohamed Boudra 6e04153b95 Switch voice turn controller to streaming transcription 2026-05-04 13:17:11 +07:00
Mohamed Boudra a6f0d79212 Merge branch 'investigate-opencode-error-swallowing' 2026-05-04 12:49:34 +07:00
Mohamed Boudra 4b2e0357aa fix(opencode): forward provider retries instead of swallowing them
OpenCode subproviders (e.g. opencode/kimi-k2.6 on OpenCode Zen) emit
session.status:retry events with messages like "Internal server error"
when the upstream provider returns 5xx. opencode itself retries
indefinitely with backoff and never emits a terminal event for these.

Previously the adapter only surfaced retry messages on a small allowlist
of "fatal" tokens (insufficient balance, invalid api key, etc.) and
silently dropped everything else. The agent appeared hung from the
user's perspective — no message, no spinner update, nothing — until the
upstream eventually recovered or the user manually interrupted.

Forward every session.status:retry as a non-terminal timeline error
item so the user can see what opencode is doing, mirroring opencode's
own TUI. Drop the fatal-token allowlist: classifying which retries are
"really" terminal is opencode's job, and synthesizing turn_failed for
ones we guess at is misleading anyway because opencode keeps spending
upstream while we tell the user the agent is done.

Also a small design pass on the timeline error rendering:
- drop the redundant "Agent error" prefix (the message is descriptive)
- drop the colored box background (visual weight too high for a retry)
- align the icon vertically to the first text line (height+center)
- make the message text selectable so users can copy errors
2026-05-04 12:48:29 +07:00
Mohamed Boudra acf88f4aca Merge branch 'main' of github.com:getpaseo/paseo 2026-05-04 12:38:16 +07:00
Mohamed Boudra 412c66ce4b Add --mode to schedule and loop CLI, default background runs to unattended mode
Plumb --mode/--verify-mode through paseo schedule create and paseo loop run.
Mark each provider's unattended mode (claude bypassPermissions, codex/opencode
full-access, copilot autopilot) so loop and schedule services pick it by
default when no explicit modeId is given.
2026-05-04 12:37:54 +07:00
Mohamed Boudra cb906d4223 Type CLI command host/json options at the source
The CommandOptions interface in packages/cli/src/output/with-output.ts had
an open `[key: string]: unknown` indexer, so every consumer of the global
--host and --json options had to cast `options.host as string | undefined`
at the call site. Add `host?: string` and `json?: boolean` to the interface
and remove 29 redundant casts across 12 command files.
2026-05-04 10:51:49 +07:00
github-actions[bot] bf0d3b9786 fix: update lockfile signatures and Nix hash 2026-05-04 03:37:38 +00:00
Mohamed Boudra 7f37255f88 Remove unnecessary type assertions across codebase
Add oxlint-tsgolint and configure typescript/no-unnecessary-type-assertion
to flag redundant `!` and `as Foo` casts. Type-aware mode is left off by
default to keep `npm run lint` fast; the rule sits configured for when we
turn type-aware on intentionally. Auto-fix removed ~283 redundant casts;
two manual touch-ups: a real tsgolint false positive in split-container.tsx
and a stale ChildProcess import after a double-cast collapsed.
2026-05-04 10:21:52 +07:00
Mohamed Boudra 5c03e25232 Drop subagent task notifications from parent timeline
Subagent task_notification system messages arrive without
parent_tool_use_id but with tool_use_id pointing at the parent's Task
call, so they slip past the sidechain router and render as top-level
"Task Notification" rows in the parent's timeline. Skip them when the
referenced tool_use is a Task call; parent-level background bash
notifications still flow through.
2026-05-04 10:13:44 +07:00
Mohamed Boudra b440f44111 Replace fictional fastlane action with Spaceship build-processing poll
The Fastfile called wait_for_build_processing_to_be_complete, which
isn't a built-in fastlane action and isn't published as a plugin
anywhere on rubygems. The submit_review lane has therefore failed
on every release that reached it (v0.1.65-beta.1, v0.1.66, v0.1.67).

Replace it with a direct Spaceship::ConnectAPI poll: fetch the build
matching the latest TestFlight build number, wait until its
processing_state is VALID, then hand off to deliver. 30-minute cap
with a clear failure message on INVALID/FAILED or timeout. Spaceship
ships with fastlane so no plugin or extra gem is required.

Lands in the next release; v0.1.67 needs a manual review submission
from App Store Connect.
2026-05-04 03:00:08 +07:00
github-actions[bot] d433e1a362 fix: update lockfile signatures and Nix hash 2026-05-03 18:45:43 +00:00
122 changed files with 2368 additions and 4369 deletions
-13
View File
@@ -1,18 +1,5 @@
# Changelog
## 0.1.69 - 2026-05-05
### Fixed
- Paseo now recovers automatically when an internal daemon process crashes — your agents stay connected instead of getting stuck and you don't have to restart anything.
- Answering an interactive question from a Claude agent now reaches Claude correctly instead of being dropped. ([#760](https://github.com/getpaseo/paseo/pull/760) by [@somus](https://github.com/somus))
## 0.1.68 - 2026-05-05
### Fixed
- The desktop app no longer fails on first launch after a fresh install.
## 0.1.67 - 2026-05-03
### Fixed
+2 -6
View File
@@ -1,10 +1,6 @@
# Ad-hoc daemon testing
Spin up an isolated in-process daemon test harness without touching the main daemon on port 6767.
This is for test code only. Executable daemon processes must start through
`scripts/supervisor-entrypoint.ts` or `dist/scripts/supervisor-entrypoint.js`;
do not use `createPaseoDaemon` as a product launch path.
Spin up an isolated daemon programmatically without touching the main daemon on port 6767.
## Quick start
@@ -89,7 +85,7 @@ await client.close();
await daemon.close(); // stops daemon + cleans up temp dirs
```
The test helper does **not** expose `providerOverrides`. In test harnesses, use `createPaseoDaemon` directly when you need it (see quick start above).
The test helper does **not** expose `providerOverrides`. Use `createPaseoDaemon` directly when you need it (see quick start above).
## Common client methods
+2 -2
View File
@@ -42,7 +42,7 @@ buildNpmPackage rec {
# To update: run `nix build` with lib.fakeHash, copy the `got:` hash.
# CI auto-updates this when package-lock.json changes (see .github/workflows/).
npmDepsHash = "sha256-rjzmIH918poK05edU/f6G3iiP16Ra7g7vM0k5HbfKRs=";
npmDepsHash = "sha256-14DjNXerk2vLOe7UV0oVo+ifmskwsWpu/jahxrLw4M8=";
# Prevent onnxruntime-node's install script from running during automatic
# npm rebuild (it tries to download from api.nuget.org, which fails in the sandbox).
@@ -123,7 +123,7 @@ buildNpmPackage rec {
# Create wrapper for the server entry point (for systemd / direct use)
mkdir -p $out/bin
makeWrapper ${nodejs}/bin/node $out/bin/paseo-server \
--add-flags "$out/lib/paseo/packages/server/dist/scripts/supervisor-entrypoint.js" \
--add-flags "$out/lib/paseo/packages/server/dist/server/server/index.js" \
--set NODE_ENV production
# Create wrapper for the CLI
+13 -13
View File
@@ -1,12 +1,12 @@
{
"name": "paseo",
"version": "0.1.69",
"version": "0.1.67",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "paseo",
"version": "0.1.69",
"version": "0.1.67",
"hasInstallScript": true,
"license": "AGPL-3.0-or-later",
"workspaces": [
@@ -38548,7 +38548,7 @@
},
"packages/app": {
"name": "@getpaseo/app",
"version": "0.1.69",
"version": "0.1.67",
"dependencies": {
"@dnd-kit/core": "^6.3.1",
"@dnd-kit/sortable": "^10.0.0",
@@ -38674,10 +38674,10 @@
},
"packages/cli": {
"name": "@getpaseo/cli",
"version": "0.1.69",
"version": "0.1.67",
"dependencies": {
"@clack/prompts": "^1.0.0",
"@getpaseo/server": "0.1.69",
"@getpaseo/server": "0.1.67",
"chalk": "^5.3.0",
"commander": "^12.0.0",
"mime-types": "^2.1.35",
@@ -38719,7 +38719,7 @@
},
"packages/desktop": {
"name": "@getpaseo/desktop",
"version": "0.1.69",
"version": "0.1.67",
"license": "AGPL-3.0-or-later",
"dependencies": {
"@getpaseo/cli": "*",
@@ -38768,7 +38768,7 @@
},
"packages/expo-two-way-audio": {
"name": "@getpaseo/expo-two-way-audio",
"version": "0.1.69",
"version": "0.1.67",
"license": "MIT",
"devDependencies": {
"@types/react": "^18.0.25",
@@ -38804,7 +38804,7 @@
},
"packages/highlight": {
"name": "@getpaseo/highlight",
"version": "0.1.69",
"version": "0.1.67",
"dependencies": {
"@lezer/common": "^1.5.0",
"@lezer/cpp": "^1.1.5",
@@ -38830,7 +38830,7 @@
},
"packages/relay": {
"name": "@getpaseo/relay",
"version": "0.1.69",
"version": "0.1.67",
"dependencies": {
"base64-js": "^1.5.1",
"tweetnacl": "^1.0.3",
@@ -38845,12 +38845,12 @@
},
"packages/server": {
"name": "@getpaseo/server",
"version": "0.1.69",
"version": "0.1.67",
"dependencies": {
"@agentclientprotocol/sdk": "^0.17.1",
"@anthropic-ai/claude-agent-sdk": "^0.2.11",
"@getpaseo/highlight": "0.1.69",
"@getpaseo/relay": "0.1.69",
"@getpaseo/highlight": "0.1.67",
"@getpaseo/relay": "0.1.67",
"@isaacs/ttlcache": "^2.1.4",
"@mariozechner/pi-agent-core": "^0.70.2",
"@mariozechner/pi-ai": "^0.70.2",
@@ -39303,7 +39303,7 @@
},
"packages/website": {
"name": "@getpaseo/website",
"version": "0.1.69",
"version": "0.1.67",
"dependencies": {
"@cloudflare/vite-plugin": "^1.20.3",
"@cloudflare/workers-types": "^4.20260114.0",
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "paseo",
"version": "0.1.69",
"version": "0.1.67",
"private": true,
"description": "Paseo: voice-controlled development environment with OpenAI Realtime API",
"keywords": [
@@ -1,37 +0,0 @@
// "00-" prefix is intentional: this file must sort before every other spec.
// Sessions history is daemon-global — any agent created by a prior spec hides the empty state.
// If the beforeAll probe below fails, a spec sorted before this file is creating agents.
import { test } from "./fixtures";
import {
connectArchiveTabDaemonClient,
expectSessionsEmptyState,
openSessions,
} from "./helpers/archive-tab";
test.describe("Sessions screen empty state", () => {
test.beforeAll(async () => {
const client = await connectArchiveTabDaemonClient();
try {
const history = await client.fetchAgentHistory({ page: { limit: 1 } });
if (history.entries.length > 0) {
throw new Error(
`Sessions empty-state precondition failed: daemon already has ${history.entries.length} agent(s). ` +
`Either a spec that sorts before 00-sessions-empty.spec.ts created agents, ` +
`or the daemon has stale history from a previous run.`,
);
}
} finally {
await client.close().catch(() => undefined);
}
});
test("shows empty placeholder when there is no session history", async ({
page,
withWorkspace,
}) => {
const workspace = await withWorkspace({ prefix: "sessions-empty-" });
await workspace.navigateTo();
await openSessions(page);
await expectSessionsEmptyState(page);
});
});
-45
View File
@@ -1,45 +0,0 @@
import { test } from "./fixtures";
import {
awaitAssistantMessage,
expectAgentIdle,
expectInlineWorkingIndicator,
expectTurnCopyButton,
expectScrollFollowsNewContent,
} from "./helpers/agent-stream";
import { startRunningMockAgent } from "./helpers/composer";
test.describe("Agent stream UI", () => {
test("auto-scroll sticks to bottom across token bursts", async ({ page }) => {
test.setTimeout(120_000);
const { client, repo } = await startRunningMockAgent(page, {
prefix: "stream-scroll-",
model: "one-minute-stream",
prompt: "Stream for auto-scroll test.",
});
try {
await awaitAssistantMessage(page);
await expectScrollFollowsNewContent(page);
} finally {
await client.close();
await repo.cleanup();
}
});
test("working-indicator transitions to copy-button when stream ends", async ({ page }) => {
test.setTimeout(60_000);
const { client, repo } = await startRunningMockAgent(page, {
prefix: "stream-indicator-",
model: "ten-second-stream",
prompt: "Stream briefly for indicator transition test.",
});
try {
await awaitAssistantMessage(page);
await expectInlineWorkingIndicator(page);
await expectAgentIdle(page, 30_000);
await expectTurnCopyButton(page);
} finally {
await client.close();
await repo.cleanup();
}
});
});
@@ -1,284 +0,0 @@
import { expect, test } from "./fixtures";
import { clickNewChat } from "./helpers/launcher";
import { expectComposerVisible } from "./helpers/composer";
import { expectAgentIdle } from "./helpers/agent-stream";
import {
openAttachmentMenu,
openGithubPickerFromMenu,
attachImageFromMenu,
expectAttachmentPill,
removeAttachmentPill,
openImageLightbox,
closeImageLightbox,
pressInterruptShortcut,
expectComposerDraft,
expectComposerDisabled,
expectComposerEditable,
expectAttachButtonDisabled,
fillComposerDraft,
sendDraftToQueue,
expectQueuedMessageButton,
startRunningMockAgent,
selectGithubOption,
expectGithubAttachmentPill,
openGithubWorkspace,
} from "./helpers/composer";
import {
connectNewWorkspaceDaemonClient,
delayBrowserAgentCreatedStatus,
openNewWorkspaceComposer,
openProjectViaDaemon,
} from "./helpers/new-workspace";
import { gotoAppShell } from "./helpers/app";
import { waitForSidebarHydration, switchWorkspaceViaSidebar } from "./helpers/workspace-ui";
import { createTempGitRepo } from "./helpers/workspace";
import { hasGithubAuth, createTempGithubRepo } from "./helpers/github-fixtures";
const MINIMAL_PNG = Buffer.from(
"iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mNk+M9QDwADhgGAWjR9awAAAABJRU5ErkJggg==",
"base64",
);
const TEST_IMAGE = { name: "test.png", mimeType: "image/png", buffer: MINIMAL_PNG };
test.describe("Composer attachments", () => {
test("Plus menu shows image and GitHub options", async ({ page, withWorkspace }) => {
test.setTimeout(60_000);
const workspace = await withWorkspace({ prefix: "attach-plus-" });
await workspace.navigateTo();
await clickNewChat(page);
await expectComposerVisible(page);
await openAttachmentMenu(page);
await expect(page.getByTestId("message-input-attachment-menu-item-image")).toBeVisible();
await expect(page.getByTestId("message-input-attachment-menu-item-github")).toBeVisible();
});
test("GitHub combobox does not render until the picker is opened", async ({
page,
withWorkspace,
}) => {
test.setTimeout(60_000);
const workspace = await withWorkspace({ prefix: "attach-gh-lazy-" });
await workspace.navigateTo();
await clickNewChat(page);
await expectComposerVisible(page);
await expect(page.getByTestId("combobox-desktop-container")).not.toBeVisible();
await openGithubPickerFromMenu(page);
await expect(page.getByPlaceholder("Search issues and PRs...")).toBeVisible({ timeout: 5_000 });
await expect(
page.getByTestId("combobox-empty-text").or(page.getByText("Searching...")),
).toBeVisible({ timeout: 10_000 });
});
test("GitHub issue attachment pill visible after search and selection", async ({ page }) => {
test.setTimeout(120_000);
if (!hasGithubAuth()) {
test.skip(true, "GitHub auth not available in this environment");
}
const ghRepo = await createTempGithubRepo({
prefix: "paseo-e2e-attach-issue-",
issues: [{ title: "fix: attach-issue-unique-alpha" }],
prs: [{ title: "feat: attach-issue-dummy-pr", state: "open" }],
});
const handle = await openGithubWorkspace(page, ghRepo.prs[0].localPath);
try {
await clickNewChat(page);
await expectComposerVisible(page);
await selectGithubOption(
page,
"attach-issue-unique-alpha",
`issue:${ghRepo.issues[0].number}`,
);
await expectGithubAttachmentPill(page, {
number: ghRepo.issues[0].number,
title: ghRepo.issues[0].title,
});
} finally {
await handle.cleanup();
await ghRepo.cleanup();
}
});
test("GitHub PR attachment pill visible after search and selection", async ({ page }) => {
test.setTimeout(120_000);
if (!hasGithubAuth()) {
test.skip(true, "GitHub auth not available in this environment");
}
const ghRepo = await createTempGithubRepo({
prefix: "paseo-e2e-attach-pr-",
prs: [{ title: "feat: attach-pr-unique-beta", state: "open" }],
});
const handle = await openGithubWorkspace(page, ghRepo.prs[0].localPath);
try {
await clickNewChat(page);
await expectComposerVisible(page);
await selectGithubOption(page, "attach-pr-unique-beta", `pr:${ghRepo.prs[0].number}`);
await expectGithubAttachmentPill(page, {
number: ghRepo.prs[0].number,
title: ghRepo.prs[0].title,
});
} finally {
await handle.cleanup();
await ghRepo.cleanup();
}
});
test.fixme("workspace-review pill suppresses on X-click and reappears after send", async () => {
// The review attachment is created via InlineReviewEditor in surface.tsx (addComment action).
// Automating this requires: a workspace with staged changes, navigating to the diff panel,
// hovering the gutter "+" button, typing a comment, and submitting. A dedicated
// helpers/review.ts with addInlineReviewComment(page, filePath, lineNumber, comment) is
// needed before this can be exercised end-to-end.
});
test.fixme("browser-element attachment pill is created from Electron webview selection", async () => {
// The browser-element attachment is only created in browser-pane.electron.tsx via DOM
// element selection in the Electron webview. It is not exercisable in headless Chromium E2E.
});
test("image lightbox opens on pill click and closes on Escape", async ({
page,
withWorkspace,
}) => {
test.setTimeout(60_000);
const workspace = await withWorkspace({ prefix: "attach-lightbox-" });
await workspace.navigateTo();
await clickNewChat(page);
await expectComposerVisible(page);
await attachImageFromMenu(page, TEST_IMAGE);
await expectAttachmentPill(page, "composer-image-attachment-pill");
await openImageLightbox(page);
await closeImageLightbox(page);
});
test("image attachment pill renders after file is selected", async ({ page, withWorkspace }) => {
test.setTimeout(60_000);
const workspace = await withWorkspace({ prefix: "attach-pill-" });
await workspace.navigateTo();
await clickNewChat(page);
await expectComposerVisible(page);
await attachImageFromMenu(page, TEST_IMAGE);
await expectAttachmentPill(page, "composer-image-attachment-pill");
});
test("clicking the X on an image pill removes it", async ({ page, withWorkspace }) => {
test.setTimeout(60_000);
const workspace = await withWorkspace({ prefix: "attach-remove-" });
await workspace.navigateTo();
await clickNewChat(page);
await expectComposerVisible(page);
await attachImageFromMenu(page, TEST_IMAGE);
await expectAttachmentPill(page, "composer-image-attachment-pill");
await removeAttachmentPill(page, "composer-image-attachment-pill", "Remove image attachment");
await expect(page.getByTestId("composer-image-attachment-pill")).toHaveCount(0, {
timeout: 5_000,
});
});
test("submitting while agent is running queues the message and clears the draft", async ({
page,
}) => {
test.setTimeout(120_000);
const { client, repo } = await startRunningMockAgent(page, {
prefix: "attach-queue-",
model: "one-minute-stream",
prompt: "Stay running for queue test.",
});
try {
await fillComposerDraft(page, "queued draft text");
await sendDraftToQueue(page);
await expectQueuedMessageButton(page);
await expectComposerDraft(page, "");
} finally {
await client.close();
await repo.cleanup();
}
});
test("Escape interrupt cancels the running agent and preserves composer draft", async ({
page,
}) => {
test.setTimeout(120_000);
const { client, repo } = await startRunningMockAgent(page, {
prefix: "attach-interrupt-",
model: "ten-second-stream",
prompt: "Stay running for interrupt test.",
});
try {
await fillComposerDraft(page, "preserve me");
await pressInterruptShortcut(page);
await expectAgentIdle(page, 15_000);
await expectComposerDraft(page, "preserve me");
} finally {
await client.close();
await repo.cleanup();
}
});
test("composer is locked while new workspace agent is being created", async ({ page }) => {
test.setTimeout(120_000);
const serverId = process.env.E2E_SERVER_ID;
if (!serverId) throw new Error("E2E_SERVER_ID is not set.");
const repo = await createTempGitRepo("attach-lock-");
const agentCreatedDelay = await delayBrowserAgentCreatedStatus(page);
const daemonClient = await connectNewWorkspaceDaemonClient();
try {
const openedProject = await openProjectViaDaemon(daemonClient, repo.path);
await gotoAppShell(page);
await waitForSidebarHydration(page);
await switchWorkspaceViaSidebar({
page,
serverId,
targetWorkspacePath: openedProject.workspaceId,
});
await openNewWorkspaceComposer(page, {
projectKey: openedProject.projectKey,
projectDisplayName: openedProject.projectDisplayName,
});
await fillComposerDraft(page, "lock test prompt");
const createButton = page
.getByTestId("message-input-root")
.getByRole("button", { name: "Create" });
await expect(createButton).toBeVisible({ timeout: 30_000 });
await createButton.click();
await agentCreatedDelay.waitForCreateRequest();
await agentCreatedDelay.waitForDelayedCreatedStatus();
await expectComposerDisabled(page);
await expectAttachButtonDisabled(page);
agentCreatedDelay.release();
await expectComposerEditable(page);
} finally {
agentCreatedDelay.release();
await daemonClient.close().catch(() => undefined);
await repo.cleanup();
}
});
});
+5 -37
View File
@@ -215,28 +215,8 @@ async function createFakeGhBin(): Promise<string> {
await writeFile(
ghPath,
`#!/usr/bin/env node
const { spawnSync } = require("child_process");
const fs = require("fs");
const path = require("path");
const args = process.argv.slice(2);
function findRealGh() {
const fakeBinDir = __dirname;
for (const dir of (process.env.PATH || "").split(path.delimiter)) {
if (dir === fakeBinDir) continue;
const candidate = path.join(dir, "gh");
try { fs.accessSync(candidate, fs.constants.X_OK); return candidate; } catch {}
}
return null;
}
function forwardToRealGh() {
const realGh = findRealGh();
if (!realGh) { console.error("[fake-gh] real gh not found in PATH"); process.exit(1); }
const result = spawnSync(realGh, process.argv.slice(2), { stdio: "inherit", env: process.env });
process.exit(result.status ?? 1);
}
if (args[0] === "auth" && args[1] === "status") {
process.exit(0);
}
@@ -258,21 +238,8 @@ if (args[0] === "pr" && args[1] === "list") {
}
if (args[0] === "pr" && args[1] === "view" && args[2] === "--json" && args[3]) {
const fixture = path.join(process.cwd(), ".paseo-e2e-pr.json");
if (fs.existsSync(fixture)) {
console.log(fs.readFileSync(fixture, "utf8"));
process.exit(0);
}
forwardToRealGh();
}
if (args[0] === "api" && args[1] === "graphql") {
const fixture = path.join(process.cwd(), ".paseo-e2e-timeline.json");
if (fs.existsSync(fixture)) {
console.log(fs.readFileSync(fixture, "utf8"));
process.exit(0);
}
forwardToRealGh();
console.error("no pull requests found for branch");
process.exit(1);
}
if (args[0] === "issue" && args[1] === "list") {
@@ -280,7 +247,8 @@ if (args[0] === "issue" && args[1] === "list") {
process.exit(0);
}
forwardToRealGh();
console.error("Unsupported fake gh invocation: " + args.join(" "));
process.exit(1);
`,
);
await chmod(ghPath, 0o755);
@@ -605,7 +573,7 @@ function startDaemon(args: DaemonSpawnArgs): ChildProcess {
const tsxBin = execSync("which tsx").toString().trim();
const { openAiUsable, localModelsDir } = args.dictation;
const child = spawn(tsxBin, ["scripts/supervisor-entrypoint.ts", "--dev"], {
const child = spawn(tsxBin, ["src/server/index.ts"], {
cwd: serverDir,
env: {
...process.env,
-18
View File
@@ -1,5 +1,4 @@
import { expect, type Page } from "@playwright/test";
import { readScrollMetrics, waitForContentGrowth, expectNearBottom } from "./agent-bottom-anchor";
export async function awaitAssistantMessage(page: Page, hasText?: string | RegExp): Promise<void> {
const messages = page.getByTestId("assistant-message");
@@ -16,20 +15,3 @@ export async function awaitToolCall(page: Page, toolName: string | RegExp): Prom
export async function expectAgentIdle(page: Page, timeout = 30_000): Promise<void> {
await expect(page.getByRole("button", { name: /stop|cancel/i })).toHaveCount(0, { timeout });
}
// The working indicator is an animated spinner View — no semantic ARIA role, testId is correct.
export async function expectInlineWorkingIndicator(page: Page): Promise<void> {
await expect(page.getByTestId("turn-working-indicator")).toBeVisible({ timeout: 30_000 });
}
export async function expectTurnCopyButton(page: Page): Promise<void> {
await expect(page.getByRole("button", { name: "Copy turn" }).first()).toBeVisible({
timeout: 30_000,
});
}
export async function expectScrollFollowsNewContent(page: Page): Promise<void> {
const { contentHeight } = await readScrollMetrics(page);
await waitForContentGrowth(page, contentHeight);
await expectNearBottom(page);
}
+1 -12
View File
@@ -36,9 +36,6 @@ interface ArchiveTabDaemonClient {
predicate: (snapshot: { status: string }) => boolean,
timeout?: number,
): Promise<{ status: string }>;
fetchAgentHistory(options?: {
page?: { limit: number };
}): Promise<{ entries: Array<{ id: string }> }>;
}
function getDaemonPort(): string {
@@ -268,10 +265,8 @@ export async function openSessions(page: Page): Promise<void> {
});
}
const AGENT_ROW_SELECTOR = '[data-testid^="agent-row-"]';
function getSessionRowByTitle(page: Page, title: string) {
return page.locator(AGENT_ROW_SELECTOR).filter({ hasText: title }).first();
return page.locator('[data-testid^="agent-row-"]').filter({ hasText: title }).first();
}
export async function expectSessionRowVisible(page: Page, title: string): Promise<void> {
@@ -288,12 +283,6 @@ export async function clickSessionRow(page: Page, title: string): Promise<void>
await row.click();
}
export async function expectSessionsEmptyState(page: Page): Promise<void> {
// Guard: if session rows appear, a prior spec polluted the shared daemon — see 00-sessions-empty.spec.ts.
await expect(page.locator(AGENT_ROW_SELECTOR)).toHaveCount(0, { timeout: 5_000 });
await expect(page.getByText("No sessions yet")).toBeVisible({ timeout: 30_000 });
}
export async function archiveAgentFromSessions(
page: Page,
input: { agentId: string; title: string },
-166
View File
@@ -1,10 +1,4 @@
import { expect, type Page } from "@playwright/test";
import { buildHostWorkspaceRoute } from "@/utils/host-routes";
import { createTempGitRepo } from "./workspace";
import { connectTerminalClient, type TerminalPerfDaemonClient } from "./terminal-perf";
import { connectWorkspaceSetupClient, openHomeWithProject } from "./workspace-setup";
import { selectWorkspaceInSidebar } from "./sidebar";
import { waitForTabBar } from "./launcher";
function composerInput(page: Page) {
return page.getByRole("textbox", { name: "Message agent..." }).first();
@@ -21,20 +15,6 @@ export async function expectComposerVisible(
await expect(composerInput(page)).toBeVisible({ timeout: options?.timeout ?? 15_000 });
}
export async function expectComposerDisabled(page: Page): Promise<void> {
// React Native TextInput with editable={false} renders as <textarea readonly> on web,
// not <textarea disabled>. Use not.toBeEditable() to match either form.
await expect(composerInput(page)).not.toBeEditable({ timeout: 10_000 });
}
export async function expectComposerDraft(page: Page, text: string): Promise<void> {
await expect(composerInput(page)).toHaveValue(text, { timeout: 5_000 });
}
export async function expectComposerEditable(page: Page): Promise<void> {
await expect(composerInput(page)).toBeEditable({ timeout: 15_000 });
}
export async function submitMessage(page: Page, text: string): Promise<void> {
const input = composerInput(page);
await expect(input).toBeEditable({ timeout: 30_000 });
@@ -42,154 +22,8 @@ export async function submitMessage(page: Page, text: string): Promise<void> {
await input.press("Enter");
}
export async function fillComposerDraft(page: Page, text: string): Promise<void> {
await composerInput(page).fill(text);
}
export async function sendDraftToQueue(page: Page): Promise<void> {
await composerInput(page).press("Control+Enter");
}
export async function expectQueuedMessageButton(page: Page): Promise<void> {
await expect(page.getByRole("button", { name: "Send queued message now" })).toBeVisible({
timeout: 10_000,
});
}
export async function cancelAgent(page: Page): Promise<void> {
const stopButton = page.getByRole("button", { name: /stop|cancel/i }).first();
await expect(stopButton).toBeVisible({ timeout: 10_000 });
await stopButton.click();
}
/** Escape is bound to the "agent.interrupt" keyboard shortcut. */
export async function pressInterruptShortcut(page: Page): Promise<void> {
await page.keyboard.press("Escape");
}
export async function openAttachmentMenu(page: Page): Promise<void> {
await page.getByTestId("message-input-attach-button").filter({ visible: true }).first().click();
await expect(page.getByTestId("message-input-attachment-menu")).toBeVisible({ timeout: 5_000 });
}
export async function expectAttachButtonDisabled(page: Page): Promise<void> {
await expect(
page.getByTestId("message-input-attach-button").filter({ visible: true }).first(),
).toBeDisabled({ timeout: 10_000 });
}
export async function attachImageFromMenu(
page: Page,
file: { name: string; mimeType: string; buffer: Buffer },
): Promise<void> {
const chooserPromise = page.waitForEvent("filechooser", { timeout: 10_000 });
await openAttachmentMenu(page);
await page.getByTestId("message-input-attachment-menu-item-image").click();
const chooser = await chooserPromise;
await chooser.setFiles([file]);
}
export async function expectAttachmentPill(page: Page, testID: string): Promise<void> {
await expect(page.getByTestId(testID).first()).toBeVisible({ timeout: 10_000 });
}
/** Hover to reveal the X button (hidden until hover on desktop web), then click by accessible label. */
export async function removeAttachmentPill(
page: Page,
pillTestId: string,
removeAccessibilityLabel: string,
): Promise<void> {
await page.getByTestId(pillTestId).first().hover();
await page.getByRole("button", { name: removeAccessibilityLabel }).first().click();
}
export async function expectGithubAttachmentPill(
page: Page,
input: { number: number; title: string },
): Promise<void> {
const pill = page.getByTestId("composer-github-attachment-pill").first();
await expect(pill).toBeVisible({ timeout: 10_000 });
await expect(pill).toContainText(`#${input.number}`);
await expect(pill).toContainText(input.title);
}
export async function openImageLightbox(page: Page): Promise<void> {
await page.getByRole("button", { name: "Open image attachment" }).first().click();
await expect(page.getByTestId("attachment-lightbox-close")).toBeVisible({ timeout: 5_000 });
}
export async function closeImageLightbox(page: Page): Promise<void> {
await page.keyboard.press("Escape");
await expect(page.getByTestId("attachment-lightbox-close")).not.toBeVisible({ timeout: 5_000 });
}
export async function openGithubPickerFromMenu(page: Page): Promise<void> {
await openAttachmentMenu(page);
await page.getByTestId("message-input-attachment-menu-item-github").click();
await expect(page.getByTestId("combobox-desktop-container")).toBeVisible({ timeout: 5_000 });
}
/** Open picker, type a query, wait for the matching option by id (e.g. "issue:3", "pr:1"), and click it. */
export async function selectGithubOption(
page: Page,
searchTerm: string,
optionId: string,
): Promise<void> {
await openGithubPickerFromMenu(page);
const searchInput = page.getByPlaceholder("Search issues and PRs...");
await expect(searchInput).toBeVisible({ timeout: 5_000 });
await searchInput.fill(searchTerm);
const option = page.getByTestId(`composer-github-option-${optionId}`);
await expect(option).toBeVisible({ timeout: 15_000 });
await option.click();
}
export interface MockAgentSetup {
client: TerminalPerfDaemonClient;
repo: Awaited<ReturnType<typeof createTempGitRepo>>;
}
/** Create a temp repo, start a mock agent, navigate to it, and wait for it to be running. */
export async function startRunningMockAgent(
page: Page,
opts: { prefix: string; model: string; prompt: string },
): Promise<MockAgentSetup> {
const serverId = process.env.E2E_SERVER_ID;
if (!serverId) throw new Error("E2E_SERVER_ID is not set.");
const repo = await createTempGitRepo(opts.prefix);
const client = await connectTerminalClient();
const opened = await client.openProject(repo.path);
if (!opened.workspace) throw new Error(opened.error ?? "Failed to open project");
const agent = await client.createAgent({
provider: "mock",
cwd: repo.path,
model: opts.model,
initialPrompt: opts.prompt,
});
const agentUrl = `${buildHostWorkspaceRoute(serverId, repo.path)}?open=${encodeURIComponent(`agent:${agent.id}`)}`;
await page.goto(agentUrl);
await expect(page.getByRole("button", { name: /stop|cancel/i }).first()).toBeVisible({
timeout: 30_000,
});
await expectComposerVisible(page);
return { client, repo };
}
export interface GithubWorkspaceHandle {
cleanup: () => Promise<void>;
}
/** Open a workspace backed by an existing repo path (e.g. a cloned GitHub repo). */
export async function openGithubWorkspace(
page: Page,
repoPath: string,
): Promise<GithubWorkspaceHandle> {
const client = await connectWorkspaceSetupClient();
const opened = await client.openProject(repoPath);
if (!opened.workspace) throw new Error(opened.error ?? `Failed to open project ${repoPath}`);
await openHomeWithProject(page, repoPath);
await selectWorkspaceInSidebar(page, opened.workspace.id);
await waitForTabBar(page);
return { cleanup: () => client.close().catch(() => undefined) };
}
+2 -7
View File
@@ -30,15 +30,10 @@ export function buildSeededHost(input: {
};
}
export const TEST_MOCK_PROVIDER_PREFERENCES = {
...TEST_PROVIDER_PREFERENCES,
mock: { model: "ten-second-stream" },
} as const;
export function buildCreateAgentPreferences(serverId: string) {
return {
serverId,
provider: "mock" as const,
providerPreferences: TEST_MOCK_PROVIDER_PREFERENCES,
provider: "codex" as const,
providerPreferences: TEST_PROVIDER_PREFERENCES,
};
}
+17 -21
View File
@@ -32,13 +32,12 @@ export async function loadRealDaemonState(): Promise<RealDaemonState> {
if (!paseoHome) throw new Error("E2E_PASEO_HOME not set — globalSetup must run first");
const resp = await fetch(`http://127.0.0.1:${port}/api/status`);
const data: DaemonApiStatus = await resp.json();
const data = (await resp.json()) as DaemonApiStatus;
let pid: number | null = null;
try {
const raw = readFileSync(`${paseoHome}/paseo.pid`, "utf8");
const pidContent: PidFileContent = JSON.parse(raw);
pid = pidContent.pid ?? null;
pid = (JSON.parse(raw) as PidFileContent).pid ?? null;
} catch (err) {
// PID file may not be present yet on a very fresh daemon start
console.warn("[desktop-updates] paseo.pid not found:", err);
@@ -71,12 +70,6 @@ export interface ConfirmDialogCall {
title: string | undefined;
}
declare global {
interface Window {
__capturedDialogCall: ConfirmDialogCall | undefined;
}
}
/**
* Injects window.paseoDesktop before app load so all Electron-gated code
* activates. The update-check IPC is mocked at the boundary so the real
@@ -158,14 +151,12 @@ export async function injectDesktopBridge(page: Page, config: DesktopBridgeConfi
}
if (command === "patch_desktop_settings") {
const daemon = args?.daemon;
if (
daemon !== null &&
typeof daemon === "object" &&
"manageBuiltInDaemon" in daemon &&
typeof daemon.manageBuiltInDaemon === "boolean"
) {
manageDaemon = daemon.manageBuiltInDaemon;
const patchDaemon =
args?.daemon && typeof args.daemon === "object"
? (args.daemon as Record<string, unknown>)
: {};
if (typeof patchDaemon.manageBuiltInDaemon === "boolean") {
manageDaemon = patchDaemon.manageBuiltInDaemon;
}
return {
releaseChannel: "stable",
@@ -192,9 +183,9 @@ export async function injectDesktopBridge(page: Page, config: DesktopBridgeConfi
},
dialog: {
ask: async (message: string, options?: Record<string, unknown>) => {
window.__capturedDialogCall = {
(window as unknown as Record<string, unknown>).__capturedDialogCall = {
message,
title: typeof options?.title === "string" ? options.title : undefined,
title: options?.title,
};
return cfg.confirmShouldAccept ?? false;
},
@@ -237,8 +228,13 @@ export async function interceptDaemonManagementConfirmDialog(
page: Page,
): Promise<ConfirmDialogCall> {
await page.getByRole("switch", { name: "Manage built-in daemon" }).click();
await page.waitForFunction(() => !!window.__capturedDialogCall, { timeout: 5_000 });
return page.evaluate(() => window.__capturedDialogCall!);
await page.waitForFunction(
() => !!(window as unknown as Record<string, unknown>).__capturedDialogCall,
{ timeout: 5_000 },
);
return page.evaluate(
() => (window as unknown as Record<string, unknown>).__capturedDialogCall as ConfirmDialogCall,
);
}
export async function toggleDaemonManagement(
-244
View File
@@ -1,244 +0,0 @@
import { execFileSync, execSync } from "node:child_process";
import { mkdtemp, rm, writeFile } from "node:fs/promises";
import path from "node:path";
export function hasGithubAuth(): boolean {
try {
execSync("gh auth status", { stdio: "ignore" });
return true;
} catch {
return false;
}
}
export interface CheckSpec {
context: string;
state: "success" | "failure" | "pending";
}
export interface PrSpec {
title: string;
state: "open" | "merged" | "closed" | "draft";
checks?: CheckSpec[];
commentCount?: number;
}
export interface IssueSpec {
title: string;
body?: string;
labels?: string[];
state?: "open" | "closed";
}
export interface GhPrFixture {
number: number;
title: string;
url: string;
branch: string;
localPath: string;
}
export interface GhIssueFixture {
number: number;
title: string;
url: string;
}
export interface GhRepoFixture {
owner: string;
name: string;
fullName: string;
prs: GhPrFixture[];
issues: GhIssueFixture[];
cleanup(): Promise<void>;
}
function gh(args: string[], opts?: { cwd?: string }): string {
return execFileSync("gh", args, {
cwd: opts?.cwd,
encoding: "utf8",
stdio: ["ignore", "pipe", "pipe"],
}).trim();
}
function git(args: string[], cwd: string): string {
return execFileSync("git", args, {
cwd,
encoding: "utf8",
stdio: ["ignore", "pipe", "pipe"],
}).trim();
}
async function seedPr(args: {
spec: PrSpec;
branch: string;
index: number;
basePath: string;
authedUrl: string;
fullName: string;
repoName: string;
}): Promise<{ fixture: GhPrFixture; localPath: string }> {
const { spec, branch, index, basePath, authedUrl, fullName, repoName } = args;
const createArgs = [
"pr",
"create",
"--title",
spec.title,
"--base",
"main",
"--head",
branch,
"--body",
"",
];
if (spec.state === "draft") createArgs.push("--draft");
const prUrl = gh(createArgs, { cwd: basePath });
const prNumber = parseInt(prUrl.split("/").pop() ?? "0", 10);
if (spec.checks && spec.checks.length > 0) {
const sha = git(["rev-parse", branch], basePath);
for (const check of spec.checks) {
gh([
"api",
`repos/${fullName}/statuses/${sha}`,
"--method",
"POST",
"-f",
`state=${check.state}`,
"-f",
`context=${check.context}`,
"-f",
`target_url=https://example.com/${encodeURIComponent(check.context)}`,
]);
}
}
for (let j = 0; j < (spec.commentCount ?? 0); j++) {
gh(["pr", "comment", String(prNumber), "--body", `Test comment ${j + 1}`], { cwd: basePath });
}
if (spec.state === "merged") {
gh(["pr", "merge", String(prNumber), "--merge"], { cwd: basePath });
} else if (spec.state === "closed") {
gh(["pr", "close", String(prNumber)], { cwd: basePath });
}
const localPath = await mkdtemp(path.join("/tmp", `${repoName}-ws-${index}-`));
git(["clone", authedUrl, localPath, "--quiet", "-b", branch], basePath);
// Clean remote URL (no embedded token) so gh can parse owner/repo
git(["remote", "set-url", "origin", `https://github.com/${fullName}.git`], localPath);
git(["config", "user.email", "e2e@paseo.test"], localPath);
git(["config", "user.name", "Paseo E2E"], localPath);
git(["config", "commit.gpgsign", "false"], localPath);
return {
fixture: { number: prNumber, title: spec.title, url: prUrl, branch, localPath },
localPath,
};
}
function seedIssue(args: { spec: IssueSpec; basePath: string }): GhIssueFixture {
const { spec, basePath } = args;
const createArgs = ["issue", "create", "--title", spec.title, "--body", spec.body ?? ""];
for (const label of spec.labels ?? []) {
createArgs.push("--label", label);
}
const issueUrl = gh(createArgs, { cwd: basePath });
const issueNumber = parseInt(issueUrl.split("/").pop() ?? "0", 10);
if (spec.state === "closed") {
gh(["issue", "close", String(issueNumber)], { cwd: basePath });
}
return { number: issueNumber, title: spec.title, url: issueUrl };
}
export async function createTempGithubRepo(options: {
prefix?: string;
prs?: PrSpec[];
issues?: IssueSpec[];
}): Promise<GhRepoFixture> {
const { prefix = "paseo-e2e-", prs = [], issues = [] } = options;
const uniqueSuffix = `${Date.now()}-${Math.random().toString(36).slice(2, 7)}`;
const repoName = `${prefix}${uniqueSuffix}`;
// Bootstrap local git repo
const basePath = await mkdtemp(path.join("/tmp", `${repoName}-base-`));
git(["init", "-b", "main"], basePath);
git(["config", "user.email", "e2e@paseo.test"], basePath);
git(["config", "user.name", "Paseo E2E"], basePath);
git(["config", "commit.gpgsign", "false"], basePath);
await writeFile(path.join(basePath, "README.md"), "# E2E Test Repo\n");
git(["add", "README.md"], basePath);
git(["commit", "-m", "Initial commit"], basePath);
// Create GitHub repo and push initial commit
gh(["repo", "create", repoName, "--private", `--source=${basePath}`, "--push"]);
const owner = gh(["api", "user", "--jq", ".login"]);
const fullName = `${owner}/${repoName}`;
const token = gh(["auth", "token"]);
const authedUrl = `https://x-access-token:${token}@github.com/${fullName}.git`;
// Switch remote to authed URL for subsequent pushes
git(["remote", "set-url", "origin", authedUrl], basePath);
// Create a branch + commit for each PR spec
const branches: string[] = [];
for (let i = 0; i < prs.length; i++) {
const branch = `pr-branch-${i + 1}`;
branches.push(branch);
git(["checkout", "-b", branch], basePath);
await writeFile(path.join(basePath, `pr-${i + 1}.txt`), `PR ${i + 1}\n`);
git(["add", `pr-${i + 1}.txt`], basePath);
git(["commit", "-m", `Add PR ${i + 1}`], basePath);
git(["checkout", "main"], basePath);
}
if (branches.length > 0) {
git(["push", "origin", ...branches], basePath);
}
// Create PRs, seed checks/comments, apply state changes, clone workspaces
const prFixtures: GhPrFixture[] = [];
const localPaths: string[] = [];
for (let i = 0; i < prs.length; i++) {
const { fixture, localPath } = await seedPr({
spec: prs[i],
branch: branches[i],
index: i,
basePath,
authedUrl,
fullName,
repoName,
});
localPaths.push(localPath);
prFixtures.push(fixture);
}
// Create issues
const issueFixtures: GhIssueFixture[] = [];
for (const spec of issues) {
issueFixtures.push(seedIssue({ spec, basePath }));
}
return {
owner,
name: repoName,
fullName,
prs: prFixtures,
issues: issueFixtures,
cleanup: async () => {
try {
gh(["repo", "delete", fullName, "--yes"]);
} catch {
// Best-effort cleanup
}
await Promise.all([
rm(basePath, { recursive: true, force: true }),
...localPaths.map((p) => rm(p, { recursive: true, force: true })),
]);
},
};
}
+1 -141
View File
@@ -202,45 +202,12 @@ export async function expectStartingRefPickerTriggerPr(
page: Page,
input: { number: number; title: string; headRef: string },
): Promise<void> {
const trigger = page.getByRole("button", { name: "Starting ref" });
const trigger = page.getByTestId("new-workspace-ref-picker-trigger");
await expect(trigger).toContainText(`#${input.number}`);
await expect(trigger).toContainText(input.title);
await expect(trigger).not.toContainText(input.headRef);
}
export async function openBranchPicker(page: Page): Promise<void> {
const trigger = page.getByRole("button", { name: "Starting ref" });
await expect(trigger).toBeVisible({ timeout: 30_000 });
await trigger.click();
}
export async function selectPickerOptionByKeyboard(page: Page, label: string): Promise<void> {
const searchInput = page.getByPlaceholder("Search branches and PRs");
await expect(searchInput).toBeVisible({ timeout: 30_000 });
await page.keyboard.type(label);
await page.keyboard.press("ArrowDown");
await page.keyboard.press("Enter");
}
export async function closeBranchPicker(page: Page): Promise<void> {
await page.keyboard.press("Escape");
}
export async function expectPickerOpen(page: Page): Promise<void> {
await expect(page.getByTestId("combobox-desktop-container")).toBeVisible({ timeout: 30_000 });
}
export async function expectPickerClosed(page: Page): Promise<void> {
await expect(page.getByTestId("combobox-desktop-container")).not.toBeVisible({
timeout: 30_000,
});
}
export async function expectPickerSelected(page: Page, label: string): Promise<void> {
const trigger = page.getByRole("button", { name: "Starting ref" });
await expect(trigger).toContainText(label);
}
export async function expectComposerGithubAttachmentPill(
page: Page,
input: { number: number; title: string },
@@ -283,110 +250,3 @@ export async function assertNewWorkspaceSidebarAndHeader(
return { workspaceId };
}
type WebSocketMessage = string | Buffer;
function parseWebSocketJson(message: WebSocketMessage): unknown {
const rawMessage = typeof message === "string" ? message : message.toString("utf8");
try {
return JSON.parse(rawMessage);
} catch {
return null;
}
}
function getSessionMessage(message: WebSocketMessage): Record<string, unknown> | null {
const envelope = parseWebSocketJson(message);
if (!envelope || typeof envelope !== "object") {
return null;
}
const maybeEnvelope = envelope as { type?: unknown; message?: unknown };
if (maybeEnvelope.type !== "session" || !maybeEnvelope.message) {
return null;
}
if (typeof maybeEnvelope.message !== "object") {
return null;
}
return maybeEnvelope.message as Record<string, unknown>;
}
function getStringField(input: Record<string, unknown>, key: string): string | null {
const value = input[key];
return typeof value === "string" ? value : null;
}
export interface AgentCreatedDelayControl {
release(): void;
waitForCreateRequest(): Promise<void>;
waitForDelayedCreatedStatus(): Promise<void>;
}
export async function delayBrowserAgentCreatedStatus(
page: Page,
): Promise<AgentCreatedDelayControl> {
const daemonPort = process.env.E2E_DAEMON_PORT;
if (!daemonPort) {
throw new Error("E2E_DAEMON_PORT is not set.");
}
const daemonPortPattern = new RegExp(`:${daemonPort.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")}\\b`);
const createRequestIds = new Set<string>();
const delayedForwards: Array<() => void> = [];
let releaseRequested = false;
let resolveCreateRequest: (() => void) | null = null;
let resolveDelayedCreatedStatus: (() => void) | null = null;
const createRequestSeen = new Promise<void>((resolve) => {
resolveCreateRequest = resolve;
});
const delayedCreatedStatusSeen = new Promise<void>((resolve) => {
resolveDelayedCreatedStatus = resolve;
});
await page.routeWebSocket(daemonPortPattern, (ws) => {
const server = ws.connectToServer();
ws.onMessage((message) => {
const sessionMessage = getSessionMessage(message);
if (sessionMessage?.type === "create_agent_request") {
const requestId = getStringField(sessionMessage, "requestId");
if (requestId) {
createRequestIds.add(requestId);
resolveCreateRequest?.();
}
}
server.send(message);
});
server.onMessage((message) => {
const sessionMessage = getSessionMessage(message);
const payload =
sessionMessage?.type === "status" && typeof sessionMessage.payload === "object"
? (sessionMessage.payload as Record<string, unknown>)
: null;
const requestId = payload ? getStringField(payload, "requestId") : null;
if (payload?.status === "agent_created" && requestId && createRequestIds.has(requestId)) {
resolveDelayedCreatedStatus?.();
if (releaseRequested) {
ws.send(message);
return;
}
delayedForwards.push(() => ws.send(message));
return;
}
ws.send(message);
});
});
return {
release() {
releaseRequested = true;
for (const forward of delayedForwards.splice(0)) {
forward();
}
},
waitForCreateRequest: () => createRequestSeen,
waitForDelayedCreatedStatus: () => delayedCreatedStatusSeen,
};
}
-42
View File
@@ -1,42 +0,0 @@
import { expect, type Page } from "@playwright/test";
import { getStateLabel } from "@/utils/pr-pane-data";
export async function openPrPane(page: Page): Promise<void> {
await page.getByRole("button", { name: "Open explorer" }).click();
await page.getByTestId("explorer-tab-pr").click();
await expect(page.getByTestId("pr-pane")).toBeVisible({ timeout: 15_000 });
}
export async function expectPrPaneTitle(page: Page, title: string): Promise<void> {
await expect(page.getByTestId("pr-pane-title")).toContainText(title, { timeout: 15_000 });
}
export async function expectPrPaneState(
page: Page,
state: "open" | "merged" | "closed" | "draft",
): Promise<void> {
await expect(page.getByTestId("pr-pane-state")).toHaveText(getStateLabel(state), {
timeout: 15_000,
});
}
async function assertCheckPill(page: Page, testId: string, count: number): Promise<void> {
const locator = page.getByTestId(testId);
await expect(locator).toHaveCount(count > 0 ? 1 : 0, { timeout: 15_000 });
if (count > 0) {
await expect(locator).toContainText(String(count));
}
}
export async function expectPrPaneCheckSummary(
page: Page,
counts: { passed: number; failed: number; pending: number },
): Promise<void> {
await assertCheckPill(page, "pr-pane-check-passed", counts.passed);
await assertCheckPill(page, "pr-pane-check-failed", counts.failed);
await assertCheckPill(page, "pr-pane-check-pending", counts.pending);
}
export async function expectPrPaneActivityCount(page: Page, count: number): Promise<void> {
await expect(page.getByTestId("pr-pane-activity-row")).toHaveCount(count, { timeout: 15_000 });
}
@@ -1,269 +0,0 @@
import { chmod, readFile, writeFile } from "node:fs/promises";
import path from "node:path";
import { expect, type Page } from "@playwright/test";
import type { WebSocketRoute } from "@playwright/test";
import { gotoAppShell, openSettings } from "./app";
// --- Navigation ---
export async function openProjects(page: Page): Promise<void> {
await gotoAppShell(page);
await openSettings(page);
await page.getByTestId("settings-projects").click();
await expect(page).toHaveURL(/\/settings\/projects$/);
}
export async function openProjectSettings(page: Page, projectName: string): Promise<void> {
await page.getByRole("button", { name: `Edit ${projectName}`, exact: true }).click();
await expect(page.getByRole("textbox", { name: "Worktree setup commands" })).toBeVisible({
timeout: 30_000,
});
}
export async function navigateToProjectSettings(page: Page, projectName: string): Promise<void> {
await page.getByRole("button", { name: `Edit ${projectName}`, exact: true }).click();
}
// --- Form interactions ---
export async function editWorktreeSetup(page: Page, setupCommands: string[]): Promise<void> {
await page
.getByRole("textbox", { name: "Worktree setup commands" })
.fill(setupCommands.join("\n"));
}
export async function clickSaveProjectSettings(page: Page): Promise<void> {
await page.getByRole("button", { name: "Save project config" }).click();
}
export async function clickRetryProjectSettingsSave(page: Page): Promise<void> {
// action-0 is always "Try again"; action-1 is always "Reload".
// The write-failed callout renders these two buttons in a fixed order.
await page.getByTestId("write-failed-callout-action-0").click();
}
export async function clickReloadProjectSettings(page: Page): Promise<void> {
// Scope to the active error callout so the locator is unambiguous.
// At most one error callout renders at a time.
await page.locator('[data-testid$="-callout"]').getByRole("button", { name: "Reload" }).click();
}
// --- Error-state assertions ---
type ErrorKind = "stale" | "invalid" | "write_failed" | "transport" | "read_failed";
const errorCalloutTestId: Record<ErrorKind, string> = {
stale: "stale-callout",
invalid: "invalid-callout",
write_failed: "write-failed-callout",
transport: "read-transport-callout",
read_failed: "read-failed-callout",
};
export async function expectProjectSettingsError(page: Page, kind: ErrorKind): Promise<void> {
await expect(page.getByTestId(errorCalloutTestId[kind])).toBeVisible({ timeout: 15_000 });
}
export async function expectNoProjectSettingsError(
page: Page,
kind: ErrorKind,
timeout = 15_000,
): Promise<void> {
await expect(page.getByTestId(errorCalloutTestId[kind])).not.toBeVisible({ timeout });
}
export async function expectWriteFailedCalloutActions(page: Page): Promise<void> {
await expect(page.getByTestId("write-failed-callout-action-0")).toHaveText("Try again");
await expect(page.getByTestId("write-failed-callout-action-1")).toHaveText("Reload");
}
export async function expectSaveButtonDisabled(page: Page): Promise<void> {
await expect(page.getByRole("button", { name: "Save project config" })).toBeDisabled();
}
// --- Form-state assertions ---
export async function expectProjectSettingsFormVisible(page: Page): Promise<void> {
await expect(page.getByRole("textbox", { name: "Worktree setup commands" })).toBeVisible({
timeout: 15_000,
});
}
export async function expectProjectSettingsFormHidden(page: Page): Promise<void> {
await expect(page.getByRole("textbox", { name: "Worktree setup commands" })).not.toBeVisible();
}
export async function expectNoEditableTarget(page: Page): Promise<void> {
await expect(page.getByTestId("project-settings-back-button")).toBeVisible({ timeout: 30_000 });
}
// --- Host-section assertions ---
export async function expectHostIndicatorVisible(page: Page): Promise<void> {
await expect(page.getByTestId("host-indicator")).toBeVisible();
}
export async function expectHostPickerHidden(page: Page): Promise<void> {
await expect(page.getByTestId("host-picker")).not.toBeVisible();
}
// --- Script-list assertions and interactions ---
// Counts only row Views, not kebab-trigger elements (which share the "script-row-"
// prefix but contain "-menu-").
export async function expectScriptRowCount(page: Page, count: number): Promise<void> {
await expect(
page
.getByTestId("scripts-list")
.locator('[data-testid^="script-row-"]:not([data-testid*="-menu-"])'),
).toHaveCount(count);
}
export async function expectEmptyScriptList(page: Page): Promise<void> {
await expect(page.getByText("No scripts yet.")).toBeVisible();
}
export async function removeProjectScript(page: Page, scriptName: string): Promise<void> {
const row = page
.getByTestId("scripts-list")
.locator('[data-testid^="script-row-"]:not([data-testid*="-menu-"])')
.filter({ hasText: scriptName })
.first();
// DropdownMenuTrigger renders as a Pressable (no role="button"); derive its testID
// from the row's testID to avoid scoped locator unreliability.
const id = (await row.getAttribute("data-testid"))!.replace("script-row-", "");
await page.getByTestId(`script-row-menu-${id}`).click();
page.once("dialog", (dialog) => void dialog.accept());
await page.getByRole("button", { name: "Remove" }).click();
}
// --- File manipulation ---
export async function corruptPaseoConfig(repoPath: string): Promise<void> {
await writeFile(path.join(repoPath, "paseo.json"), "{not valid json}");
}
export async function bumpPaseoConfigOnDisk(repoPath: string): Promise<void> {
const configPath = path.join(repoPath, "paseo.json");
const raw = await readFile(configPath, "utf8");
const config = JSON.parse(raw) as Record<string, unknown>;
config._bump = Date.now();
await writeFile(configPath, JSON.stringify(config, null, 2) + "\n");
}
export async function restorePaseoConfig(
repoPath: string,
config: Record<string, unknown>,
): Promise<void> {
await writeFile(path.join(repoPath, "paseo.json"), JSON.stringify(config, null, 2) + "\n");
}
// The daemon writes atomically via a temp file + rename, so blocking writes requires
// removing write permission from the *directory*, not just the file.
export async function blockPaseoConfigWrites(repoPath: string): Promise<void> {
await chmod(repoPath, 0o555);
}
export async function unblockPaseoConfigWrites(repoPath: string): Promise<void> {
await chmod(repoPath, 0o755);
}
// --- WebSocket helpers ---
function buildDaemonPortPattern(): RegExp {
const port = process.env.E2E_DAEMON_PORT;
if (!port) throw new Error("E2E_DAEMON_PORT not set — globalSetup must run first");
return new RegExp(`:${port.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")}\\b`);
}
// Proxies all daemon WS traffic transparently until a read_project_config_request
// is seen, then closes that connection (triggering readQuery.isError). Subsequent
// connections pass through so the Reload action can succeed.
export async function installReadTransportFailure(page: Page): Promise<void> {
let armed = true;
await page.routeWebSocket(buildDaemonPortPattern(), (ws) => {
const server = ws.connectToServer();
ws.onMessage((message) => {
if (armed && typeof message === "string") {
try {
const envelope = JSON.parse(message) as {
type?: string;
message?: { type?: string };
};
if (
envelope.type === "session" &&
envelope.message?.type === "read_project_config_request"
) {
armed = false;
void ws.close({ code: 1001 });
return;
}
} catch {
// binary or malformed frame — pass through
}
}
try {
server.send(message);
} catch {
// server socket already closed
}
});
server.onMessage((message) => {
try {
ws.send(message);
} catch {
// client socket already closed
}
});
});
}
// Installs a transparent WS proxy that can later drop all active daemon connections
// and block new ones. Code 1001 (Going Away) without reason triggers "error" state
// in DaemonClient due to describeTransportClose returning a non-empty string.
export async function installDaemonConnectionGate(
page: Page,
): Promise<{ drop: () => Promise<void> }> {
let acceptingConnections = true;
const activeSockets = new Set<WebSocketRoute>();
await page.routeWebSocket(buildDaemonPortPattern(), (ws) => {
if (!acceptingConnections) {
void ws.close({ code: 1001 });
return;
}
activeSockets.add(ws);
const server = ws.connectToServer();
ws.onMessage((message) => {
if (!acceptingConnections) return;
try {
server.send(message);
} catch {
activeSockets.delete(ws);
}
});
server.onMessage((message) => {
if (!acceptingConnections) return;
try {
ws.send(message);
} catch {
activeSockets.delete(ws);
}
});
});
return {
async drop(): Promise<void> {
acceptingConnections = false;
const sockets = Array.from(activeSockets);
activeSockets.clear();
await Promise.all(sockets.map((ws) => ws.close({ code: 1001 }).catch(() => undefined)));
},
};
}
-18
View File
@@ -19,21 +19,3 @@ export async function expectWorkspaceListed(page: Page, name: string): Promise<v
page.locator('[data-testid^="sidebar-workspace-row-"]').filter({ hasText: name }).first(),
).toBeVisible({ timeout: 30_000 });
}
export async function openMobileAgentSidebar(page: Page): Promise<void> {
await page.getByTestId("menu-button").click();
}
// force=true: the overlay covers the button when the mobile sidebar is open.
export async function closeMobileAgentSidebar(page: Page): Promise<void> {
await page.getByTestId("menu-button").click({ force: true });
}
// The mobile sidebar panel animates via translateX; toBeInViewport reflects the rendered position.
export async function expectMobileAgentSidebarVisible(page: Page): Promise<void> {
await expect(page.getByTestId("sidebar-sessions")).toBeInViewport({ timeout: 5_000 });
}
export async function expectMobileAgentSidebarHidden(page: Page): Promise<void> {
await expect(page.getByTestId("sidebar-sessions")).not.toBeInViewport({ timeout: 5_000 });
}
+104 -58
View File
@@ -1,29 +1,22 @@
import { existsSync } from "node:fs";
import path from "node:path";
import { buildHostWorkspaceRoute } from "@/utils/host-routes";
import { expect, test } from "./fixtures";
import { expect, test, type Page } from "./fixtures";
import { gotoAppShell } from "./helpers/app";
import {
archiveWorkspaceFromDaemon,
archiveLocalWorkspaceFromDaemon,
assertNewWorkspaceSidebarAndHeader,
clickNewWorkspaceButton,
closeBranchPicker,
connectNewWorkspaceDaemonClient,
createWorktreeViaDaemon,
delayBrowserAgentCreatedStatus,
expectComposerGithubAttachmentPill,
expectPickerClosed,
expectPickerOpen,
expectPickerSelected,
expectStartingRefPickerTriggerPr,
openBranchPicker,
openNewWorkspaceComposer,
openProjectViaDaemon,
openStartingRefPicker,
openProjectViaDaemon,
selectBranchInPicker,
selectGitHubPrInPicker,
selectPickerOptionByKeyboard,
} from "./helpers/new-workspace";
import { createTempGitRepo, readWorktreeBranchInfo } from "./helpers/workspace";
import {
@@ -35,6 +28,108 @@ import {
workspaceLabelFromPath,
} from "./helpers/workspace-ui";
type WebSocketMessage = string | Buffer;
function parseWebSocketJson(message: WebSocketMessage): unknown {
const rawMessage = typeof message === "string" ? message : message.toString("utf8");
try {
return JSON.parse(rawMessage);
} catch {
return null;
}
}
function getSessionMessage(message: WebSocketMessage): Record<string, unknown> | null {
const envelope = parseWebSocketJson(message);
if (!envelope || typeof envelope !== "object") {
return null;
}
const maybeEnvelope = envelope as { type?: unknown; message?: unknown };
if (maybeEnvelope.type !== "session" || !maybeEnvelope.message) {
return null;
}
if (typeof maybeEnvelope.message !== "object") {
return null;
}
return maybeEnvelope.message as Record<string, unknown>;
}
function getStringField(input: Record<string, unknown>, key: string): string | null {
const value = input[key];
return typeof value === "string" ? value : null;
}
async function delayBrowserAgentCreatedStatus(page: Page) {
const daemonPort = process.env.E2E_DAEMON_PORT;
if (!daemonPort) {
throw new Error("E2E_DAEMON_PORT is not set.");
}
const daemonPortPattern = new RegExp(`:${daemonPort.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")}\\b`);
const createRequestIds = new Set<string>();
const delayedForwards: Array<() => void> = [];
let releaseRequested = false;
let resolveCreateRequest: (() => void) | null = null;
let resolveDelayedCreatedStatus: (() => void) | null = null;
const createRequestSeen = new Promise<void>((resolve) => {
resolveCreateRequest = resolve;
});
const delayedCreatedStatusSeen = new Promise<void>((resolve) => {
resolveDelayedCreatedStatus = resolve;
});
await page.routeWebSocket(daemonPortPattern, (ws) => {
const server = ws.connectToServer();
ws.onMessage((message) => {
const sessionMessage = getSessionMessage(message);
if (sessionMessage?.type === "create_agent_request") {
const requestId = getStringField(sessionMessage, "requestId");
if (requestId) {
createRequestIds.add(requestId);
resolveCreateRequest?.();
}
}
server.send(message);
});
server.onMessage((message) => {
const sessionMessage = getSessionMessage(message);
const payload =
sessionMessage?.type === "status" && typeof sessionMessage.payload === "object"
? (sessionMessage.payload as Record<string, unknown>)
: null;
const requestId = payload ? getStringField(payload, "requestId") : null;
if (payload?.status === "agent_created" && requestId && createRequestIds.has(requestId)) {
resolveDelayedCreatedStatus?.();
if (releaseRequested) {
ws.send(message);
return;
}
delayedForwards.push(() => ws.send(message));
return;
}
ws.send(message);
});
});
return {
release() {
releaseRequested = true;
for (const forward of delayedForwards.splice(0)) {
forward();
}
},
waitForCreateRequest: () => createRequestSeen,
waitForDelayedCreatedStatus: () => delayedCreatedStatusSeen,
};
}
test.describe("New workspace flow", () => {
let client: Awaited<ReturnType<typeof connectNewWorkspaceDaemonClient>>;
const localWorkspaceIds = new Set<string>();
@@ -410,55 +505,6 @@ test.describe("New workspace flow", () => {
}
});
test("branch picker opens via keyboard, navigates options, and selects on Enter", async ({
page,
}) => {
const tempRepo = await createTempGitRepo("picker-keyboard-", { branches: ["main", "dev"] });
try {
const openedProject = await openProjectViaDaemon(client, tempRepo.path);
localWorkspaceIds.add(openedProject.workspaceId);
await gotoAppShell(page);
await waitForSidebarHydration(page);
await openNewWorkspaceComposer(page, {
projectKey: openedProject.projectKey,
projectDisplayName: openedProject.projectDisplayName,
});
await openBranchPicker(page);
await expectPickerOpen(page);
await selectPickerOptionByKeyboard(page, "dev");
await expectPickerSelected(page, "dev");
await expectPickerClosed(page);
} finally {
await tempRepo.cleanup();
}
});
test("branch picker closes on Escape without selecting an option", async ({ page }) => {
const tempRepo = await createTempGitRepo("picker-escape-");
try {
const openedProject = await openProjectViaDaemon(client, tempRepo.path);
localWorkspaceIds.add(openedProject.workspaceId);
await gotoAppShell(page);
await waitForSidebarHydration(page);
await openNewWorkspaceComposer(page, {
projectKey: openedProject.projectKey,
projectDisplayName: openedProject.projectDisplayName,
});
await openBranchPicker(page);
await expectPickerOpen(page);
await closeBranchPicker(page);
await expectPickerClosed(page);
} finally {
await tempRepo.cleanup();
}
});
test("selected GitHub PR shows PR context in the trigger and composer", async ({ page }) => {
const tempRepo = await createTempGitRepo("new-workspace-pr-ref-");
-124
View File
@@ -1,124 +0,0 @@
import { test } from "./fixtures";
import {
openPrPane,
expectPrPaneTitle,
expectPrPaneState,
expectPrPaneCheckSummary,
expectPrPaneActivityCount,
} from "./helpers/pr-pane";
import { gotoWorkspace } from "./helpers/launcher";
import { hasGithubAuth, createTempGithubRepo, type GhRepoFixture } from "./helpers/github-fixtures";
import {
connectWorkspaceSetupClient,
type WorkspaceSetupDaemonClient,
} from "./helpers/workspace-setup";
const GITHUB_AUTH = hasGithubAuth();
test.describe("PR pane", () => {
test.describe.configure({ retries: 1 });
let seedClient: WorkspaceSetupDaemonClient;
let repoFixture: GhRepoFixture;
const workspaceByTitle = new Map<string, string>();
test.beforeAll(async () => {
if (!GITHUB_AUTH) return;
seedClient = await connectWorkspaceSetupClient();
repoFixture = await createTempGithubRepo({
prefix: "paseo-e2e-pr-",
prs: [
{ title: "Review selected start ref", state: "open" },
{ title: "Merged feature branch", state: "merged" },
{ title: "Closed without merge", state: "closed" },
{ title: "Work in progress", state: "draft" },
{
title: "PR with mixed checks",
state: "open",
checks: [
{ context: "build-1", state: "success" },
{ context: "build-2", state: "success" },
{ context: "deploy", state: "failure" },
{ context: "security", state: "pending" },
],
},
{ title: "PR with reviews", state: "open", commentCount: 3 },
{ title: "PR with no checks", state: "open" },
],
});
for (const pr of repoFixture.prs) {
const result = await seedClient.openProject(pr.localPath);
if (!result.workspace) {
throw new Error(result.error ?? `Failed to open project ${pr.localPath}`);
}
workspaceByTitle.set(pr.title, result.workspace.id);
}
});
test.afterAll(async () => {
await repoFixture?.cleanup().catch(() => undefined);
await seedClient?.close().catch(() => undefined);
});
test.beforeEach(async () => {
test.skip(!GITHUB_AUTH, "Requires GitHub authentication (gh auth login)");
test.setTimeout(60_000);
});
test("renders an open PR with title, state, and repo line", async ({ page }) => {
await gotoWorkspace(page, workspaceByTitle.get("Review selected start ref")!);
await openPrPane(page);
await expectPrPaneTitle(page, "Review selected start ref");
await expectPrPaneState(page, "open");
});
test("renders merged state label and icon", async ({ page }) => {
await gotoWorkspace(page, workspaceByTitle.get("Merged feature branch")!);
await openPrPane(page);
await expectPrPaneState(page, "merged");
await expectPrPaneTitle(page, "Merged feature branch");
});
test("renders closed state label and icon", async ({ page }) => {
await gotoWorkspace(page, workspaceByTitle.get("Closed without merge")!);
await openPrPane(page);
await expectPrPaneState(page, "closed");
await expectPrPaneTitle(page, "Closed without merge");
});
test("renders draft state label and icon", async ({ page }) => {
await gotoWorkspace(page, workspaceByTitle.get("Work in progress")!);
await openPrPane(page);
await expectPrPaneState(page, "draft");
await expectPrPaneTitle(page, "Work in progress");
});
test("renders check pills with correct passed/failed/pending counts", async ({ page }) => {
await gotoWorkspace(page, workspaceByTitle.get("PR with mixed checks")!);
await openPrPane(page);
await expectPrPaneCheckSummary(page, { passed: 2, failed: 1, pending: 1 });
});
test("renders activity rows with correct count", async ({ page }) => {
await gotoWorkspace(page, workspaceByTitle.get("PR with reviews")!);
await openPrPane(page);
await expectPrPaneActivityCount(page, 3);
});
test("renders gracefully with zero checks", async ({ page }) => {
await gotoWorkspace(page, workspaceByTitle.get("PR with no checks")!);
await openPrPane(page);
await expectPrPaneCheckSummary(page, { passed: 0, failed: 0, pending: 0 });
await expectPrPaneTitle(page, "PR with no checks");
});
});
+29 -166
View File
@@ -1,36 +1,9 @@
import { chmod, readFile } from "node:fs/promises";
import { readFile } from "node:fs/promises";
import path from "node:path";
import { expect, test as base } from "./fixtures";
import { expect, test as base, type Page } from "./fixtures";
import { gotoAppShell, openSettings } from "./helpers/app";
import { connectNewWorkspaceDaemonClient, openProjectViaDaemon } from "./helpers/new-workspace";
import { createTempGitRepo } from "./helpers/workspace";
import {
blockPaseoConfigWrites,
bumpPaseoConfigOnDisk,
clickReloadProjectSettings,
clickRetryProjectSettingsSave,
clickSaveProjectSettings,
corruptPaseoConfig,
editWorktreeSetup,
expectEmptyScriptList,
expectHostIndicatorVisible,
expectHostPickerHidden,
expectNoEditableTarget,
expectNoProjectSettingsError,
expectProjectSettingsError,
expectProjectSettingsFormHidden,
expectProjectSettingsFormVisible,
expectSaveButtonDisabled,
expectScriptRowCount,
expectWriteFailedCalloutActions,
installDaemonConnectionGate,
installReadTransportFailure,
navigateToProjectSettings,
openProjectSettings,
openProjects,
removeProjectScript,
restorePaseoConfig,
unblockPaseoConfigWrites,
} from "./helpers/project-settings";
const updatedSetup = ["npm install", "npm run build"];
@@ -75,9 +48,6 @@ const test = base.extend<ProjectsSettingsFixtures>({
});
await client.close();
// Defensive: restore directory write permission in case the test left it blocked
// (write_failed test), so that repo.cleanup() can remove files inside.
await chmod(repo.path, 0o755).catch(() => undefined);
await repo.cleanup();
},
gitlabRemoteProject: async ({ page: _page }, provide) => {
@@ -98,6 +68,30 @@ const test = base.extend<ProjectsSettingsFixtures>({
},
});
async function openProjects(page: Page): Promise<void> {
await gotoAppShell(page);
await openSettings(page);
await page.getByTestId("settings-projects").click();
await expect(page).toHaveURL(/\/settings\/projects$/);
}
async function openProjectSettings(page: Page, projectName: string): Promise<void> {
await page.getByRole("button", { name: `Edit ${projectName}`, exact: true }).click();
await expect(page.getByRole("textbox", { name: "Worktree setup commands" })).toBeVisible({
timeout: 30_000,
});
}
async function editWorktreeSetup(page: Page, setupCommands: string[]): Promise<void> {
await page
.getByRole("textbox", { name: "Worktree setup commands" })
.fill(setupCommands.join("\n"));
}
async function saveProjectConfig(page: Page): Promise<void> {
await page.getByRole("button", { name: "Save project config" }).click();
}
async function expectProjectConfigSaved(project: ProjectsSettingsProject): Promise<void> {
await expect
.poll(
@@ -139,7 +133,7 @@ test.describe("Projects settings", () => {
await openProjects(page);
await openProjectSettings(page, editableProject.name);
await editWorktreeSetup(page, updatedSetup);
await clickSaveProjectSettings(page);
await saveProjectConfig(page);
await expectProjectConfigSaved(editableProject);
});
@@ -151,138 +145,7 @@ test.describe("Projects settings", () => {
await openProjects(page);
await openProjectSettings(page, gitlabRemoteProject.name);
await editWorktreeSetup(page, updatedSetup);
await clickSaveProjectSettings(page);
await saveProjectConfig(page);
await expectProjectConfigSaved(gitlabRemoteProject);
});
});
test.describe("Projects settings — error UX", () => {
test("stale-write callout appears on save, disables save, and reload clears it", async ({
page,
editableProject,
}) => {
await openProjects(page);
await openProjectSettings(page, editableProject.name);
// Bump the file on disk so the daemon detects a revision mismatch on save.
await bumpPaseoConfigOnDisk(editableProject.path);
await clickSaveProjectSettings(page);
await expectProjectSettingsError(page, "stale");
await expectSaveButtonDisabled(page);
await clickReloadProjectSettings(page);
await expectNoProjectSettingsError(page, "stale");
await expectProjectSettingsFormVisible(page);
});
test("invalid paseo.json shows read-error callout, reload after fix shows form", async ({
page,
editableProject,
}) => {
await corruptPaseoConfig(editableProject.path);
await openProjects(page);
await navigateToProjectSettings(page, editableProject.name);
await expectProjectSettingsError(page, "invalid");
await expectProjectSettingsFormHidden(page);
// Restore a valid config so the reload succeeds.
await restorePaseoConfig(editableProject.path, initialPaseoConfig);
await clickReloadProjectSettings(page);
await expectNoProjectSettingsError(page, "invalid");
await expectProjectSettingsFormVisible(page);
});
test("write_failed callout appears on save with blocked directory, retry re-attempts, reload clears it", async ({
page,
editableProject,
}) => {
await openProjects(page);
await openProjectSettings(page, editableProject.name);
await blockPaseoConfigWrites(editableProject.path);
await clickSaveProjectSettings(page);
await expectProjectSettingsError(page, "write_failed");
await expectWriteFailedCalloutActions(page);
await clickRetryProjectSettingsSave(page);
await expectProjectSettingsError(page, "write_failed");
await unblockPaseoConfigWrites(editableProject.path);
await clickReloadProjectSettings(page);
await expectNoProjectSettingsError(page, "write_failed");
await expectProjectSettingsFormVisible(page);
});
test("read-transport failure shows callout, reload recovers", async ({
page,
editableProject,
}) => {
// Drop the WS connection the moment a read_project_config_request is sent.
// Subsequent connections are proxied transparently so Reload can succeed.
await installReadTransportFailure(page);
await openProjects(page);
await navigateToProjectSettings(page, editableProject.name);
await expectProjectSettingsError(page, "transport");
await expectProjectSettingsFormHidden(page);
// The client reconnects after a ~1.5 s backoff; retry Reload until refetch succeeds.
await expect(async () => {
await clickReloadProjectSettings(page);
await expectNoProjectSettingsError(page, "transport", 3_000);
}).toPass({ timeout: 15_000 });
await expectProjectSettingsFormVisible(page);
});
test("project settings shows no-target state when daemon connection drops", async ({
page,
editableProject,
}) => {
const gate = await installDaemonConnectionGate(page);
await openProjects(page);
await openProjectSettings(page, editableProject.name);
// Closing with code 1001 (Going Away) transitions DaemonClient to "error" state.
// The NoEditableTarget UI renders via isHostGone check regardless of state.
await gate.drop();
await expectNoEditableTarget(page);
});
test("single-host project renders static host indicator, not a picker chip", async ({
page,
editableProject,
}) => {
await openProjects(page);
await openProjectSettings(page, editableProject.name);
await expectHostIndicatorVisible(page);
await expectHostPickerHidden(page);
});
test("script removal via kebab menu removes the row from the form", async ({
page,
editableProject,
}) => {
await openProjects(page);
await openProjectSettings(page, editableProject.name);
await expectScriptRowCount(page, 1);
await removeProjectScript(page, "dev");
await expectScriptRowCount(page, 0);
await expectEmptyScriptList(page);
});
});
@@ -4,12 +4,6 @@ import { tmpdir } from "node:os";
import path from "node:path";
import { test, expect } from "./fixtures";
import { gotoAppShell } from "./helpers/app";
import {
closeMobileAgentSidebar,
expectMobileAgentSidebarHidden,
expectMobileAgentSidebarVisible,
openMobileAgentSidebar,
} from "./helpers/sidebar";
import { createTempGitRepo } from "./helpers/workspace";
import { expectWorkspaceHeader } from "./helpers/workspace-ui";
import { connectWorkspaceSetupClient } from "./helpers/workspace-setup";
@@ -186,16 +180,3 @@ test.describe("Sidebar workspace list", () => {
}
});
});
test.describe("Mobile sidebar panelState transition", () => {
test.use({ viewport: { width: 390, height: 844 } });
test("showMobileAgent open and close transition", async ({ page }) => {
await gotoAppShell(page);
await expectMobileAgentSidebarHidden(page);
await openMobileAgentSidebar(page);
await expectMobileAgentSidebarVisible(page);
await closeMobileAgentSidebar(page);
await expectMobileAgentSidebarHidden(page);
});
});
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@getpaseo/app",
"version": "0.1.69",
"version": "0.1.67",
"private": true,
"main": "index.ts",
"scripts": {
@@ -11,8 +11,6 @@ export function getStatusSelectorHint(selector: ExplainedStatusSelector): string
return "Change model";
case "mode":
return "Change permission mode";
default:
throw new Error("unreachable");
}
}
@@ -20,7 +20,7 @@ const IS_WEB = platformIsWeb;
import { Combobox, ComboboxItem, type ComboboxOption } from "@/components/ui/combobox";
const EMPTY_COMBOBOX_OPTIONS: ComboboxOption[] = [];
const EMPTY_COMBOBOX_OPTIONS: ReadonlyArray<ComboboxOption> = [];
function noop() {}
@@ -446,11 +446,12 @@ function ProviderSearchInput({
const InputComponent = isMobile ? BottomSheetTextInput : TextInput;
useEffect(() => {
if (!autoFocus || !platformIsWeb || !inputRef.current) return () => {};
const timer = setTimeout(() => {
inputRef.current?.focus();
}, 50);
return () => clearTimeout(timer);
if (autoFocus && platformIsWeb && inputRef.current) {
const timer = setTimeout(() => {
inputRef.current?.focus();
}, 50);
return () => clearTimeout(timer);
}
}, [autoFocus]);
const inputStyle = useMemo(
@@ -698,12 +699,12 @@ export function CombinedModelSelector({
useEffect(() => {
if (platformIsWeb) {
return () => {};
return;
}
if (!isOpen) {
setIsContentReady(false);
return () => {};
return;
}
const frame = requestAnimationFrame(() => {
@@ -790,7 +791,7 @@ export function CombinedModelSelector({
)}
</Pressable>
<Combobox
options={EMPTY_COMBOBOX_OPTIONS}
options={EMPTY_COMBOBOX_OPTIONS as ComboboxOption[]}
value=""
onSelect={noop}
open={isOpen}
-2
View File
@@ -461,7 +461,6 @@ function QueuedMessageRow({ item, onEdit, onSendNow }: QueuedMessageRowProps) {
onPress={handleEdit}
style={styles.queueActionButton}
accessibilityLabel="Edit queued message"
accessibilityRole="button"
>
<ThemedPencil size={ICON_SIZE.sm} uniProps={iconForegroundMapping} />
</Pressable>
@@ -469,7 +468,6 @@ function QueuedMessageRow({ item, onEdit, onSendNow }: QueuedMessageRowProps) {
onPress={handleSendNow}
style={QUEUE_SEND_BUTTON_STYLE}
accessibilityLabel="Send queued message now"
accessibilityRole="button"
>
<ArrowUp size={ICON_SIZE.sm} color="white" />
</Pressable>
+1 -1
View File
@@ -308,7 +308,7 @@ function HostPickerTrigger({
const pressableStyle = useCallback(
({ hovered = false }: PressableStateCallbackType & { hovered?: boolean }) => [
styles.hostTrigger,
hovered && styles.hostTriggerHovered,
Boolean(hovered) && styles.hostTriggerHovered,
],
[],
);
+6 -17
View File
@@ -91,17 +91,15 @@ export function PrPane({ data }: { data: PrPaneData }) {
);
return (
<View style={styles.root} testID="pr-pane">
<View style={styles.root}>
<Pressable onPress={handleOpenPrUrl} style={styles.header}>
{({ hovered }) => (
<>
<View style={styles.stateLine}>
<StateIcon size={14} color={stateColor} />
<Text style={stateLabelStyle} testID="pr-pane-state">
{stateLabel}
</Text>
<Text style={stateLabelStyle}>{stateLabel}</Text>
</View>
<Text style={styles.title} numberOfLines={3} testID="pr-pane-title">
<Text style={styles.title} numberOfLines={3}>
{data.title}
{hovered ? (
<Text>
@@ -126,19 +124,12 @@ export function PrPane({ data }: { data: PrPaneData }) {
count={passed}
color={theme.colors.statusSuccess}
icon={checkSuccessIcon}
testID="pr-pane-check-passed"
/>
<SummaryPill
count={failed}
color={theme.colors.statusDanger}
icon={checkDangerIcon}
testID="pr-pane-check-failed"
/>
<SummaryPill count={failed} color={theme.colors.statusDanger} icon={checkDangerIcon} />
<SummaryPill
count={pending}
color={theme.colors.statusWarning}
icon={checkWarningIcon}
testID="pr-pane-check-pending"
/>
</>
}
@@ -220,17 +211,15 @@ function SummaryPill({
count,
color,
icon,
testID,
}: {
count: number;
color: string;
icon: React.ReactNode;
testID?: string;
}) {
const textStyle = useMemo(() => [styles.summaryPillText, { color }], [color]);
if (count === 0) return null;
return (
<View style={styles.summaryPill} testID={testID}>
<View style={styles.summaryPill}>
{icon}
<Text style={textStyle}>{count}</Text>
</View>
@@ -275,7 +264,7 @@ function ActivityRow({ item }: { item: PrPaneActivity }) {
[item.avatarColor],
);
return (
<Pressable onPress={handlePress} style={activityPressableStyle} testID="pr-pane-activity-row">
<Pressable onPress={handlePress} style={activityPressableStyle}>
<View style={avatarStyle}>
<Text style={styles.avatarText}>{item.author.slice(0, 1).toUpperCase()}</Text>
</View>
@@ -72,7 +72,7 @@ function AutocompleteRow({
const pressableStyle = useCallback(
({ hovered = false, pressed }: PressableStateCallbackType & { hovered?: boolean }) => [
styles.item,
(hovered || pressed || isSelected) && styles.itemActive,
(Boolean(hovered) || pressed || isSelected) && styles.itemActive,
],
[isSelected],
);
@@ -315,7 +315,7 @@ export function DropdownMenuTrigger({
const pressableStyle = useCallback(
({ pressed, hovered = false }: PressableStateCallbackType & { hovered?: boolean }) => {
if (typeof style === "function") {
return style({ pressed, hovered, open: ctx.open });
return style({ pressed, hovered: Boolean(hovered), open: ctx.open });
}
return style;
},
@@ -324,7 +324,7 @@ export function DropdownMenuTrigger({
const renderChildren = useCallback(
({ pressed, hovered = false }: PressableStateCallbackType & { hovered?: boolean }) => {
const state: TriggerState = { pressed, hovered, open: ctx.open };
const state: TriggerState = { pressed, hovered: Boolean(hovered), open: ctx.open };
return typeof children === "function" ? children(state) : children;
},
[children, ctx.open],
@@ -445,7 +445,7 @@ export function DropdownMenuContent({
setTriggerRect(null);
setContentSize(null);
setPosition(null);
return undefined;
return;
}
// Capture status bar height synchronously before async measurement.
@@ -454,8 +454,8 @@ export function DropdownMenuContent({
const statusBarHeight = Platform.OS === "android" ? (StatusBar.currentHeight ?? 0) : 0;
let cancelled = false;
void measureElement(triggerRef.current).then((rect) => {
if (cancelled) return undefined;
measureElement(triggerRef.current).then((rect) => {
if (cancelled) return;
// On Android with statusBarTranslucent, measureInWindow returns coordinates
// relative to below the status bar, but Modal content starts from screen top.
// Add status bar height to align coordinate systems (same as react-native-popover-view).
@@ -463,7 +463,7 @@ export function DropdownMenuContent({
...rect,
y: rect.y + statusBarHeight,
});
return undefined;
return;
});
return () => {
@@ -32,7 +32,6 @@ vi.mock("react-native-reanimated", () => ({
vi.mock("react-native", () => ({
Pressable: ({
"aria-checked": ariaChecked,
accessibilityLabel,
accessibilityRole,
accessibilityState,
@@ -41,7 +40,6 @@ vi.mock("react-native", () => ({
onPress,
testID,
}: {
"aria-checked"?: boolean;
accessibilityLabel?: string;
accessibilityRole?: string;
accessibilityState?: { checked?: boolean; disabled?: boolean };
@@ -53,7 +51,7 @@ vi.mock("react-native", () => ({
React.createElement(
"button",
{
"aria-checked": ariaChecked ?? accessibilityState?.checked,
"aria-checked": accessibilityState?.checked,
"aria-disabled": accessibilityState?.disabled,
"aria-label": accessibilityLabel,
"data-disabled": disabled,
+1 -1
View File
@@ -99,8 +99,8 @@ export function Switch({
hitSlop={8}
accessibilityRole="switch"
accessibilityState={accessibilityState}
accessibilityLabel={accessibilityLabel}
aria-checked={value}
accessibilityLabel={accessibilityLabel}
testID={testID}
style={pressableStyle}
>
+27 -17
View File
@@ -865,7 +865,11 @@ function SessionProviderInternal({ children, serverId, client }: SessionProvider
}, [client, isConnected, serverId]);
useEffect(() => {
const unregister = voiceRuntime?.registerSession({
if (!voiceRuntime) {
return;
}
return voiceRuntime.registerSession({
serverId,
setVoiceMode: async (enabled, agentId) => {
if (!client) {
@@ -895,7 +899,6 @@ function SessionProviderInternal({ children, serverId, client }: SessionProvider
setIsPlayingAudio(serverId, isPlaying);
},
});
return () => unregister?.();
}, [client, serverId, setIsPlayingAudio, voiceRuntime]);
useEffect(() => {
@@ -913,7 +916,7 @@ function SessionProviderInternal({ children, serverId, client }: SessionProvider
useEffect(() => {
if (!client || !isConnected) {
return () => {};
return;
}
let cancelled = false;
@@ -1239,10 +1242,10 @@ function SessionProviderInternal({ children, serverId, client }: SessionProvider
if (message.payload.kind === "remove") {
clearWorkspaceArchivePending({
serverId,
workspaceId: message.payload.id,
workspaceId: String(message.payload.id),
});
removeWorkspaceSetup({ serverId, workspaceId: message.payload.id });
removeWorkspace(serverId, message.payload.id);
removeWorkspaceSetup({ serverId, workspaceId: String(message.payload.id) });
removeWorkspace(serverId, String(message.payload.id));
return;
}
const workspace = normalizeWorkspaceDescriptor(message.payload.workspace);
@@ -1400,10 +1403,15 @@ function SessionProviderInternal({ children, serverId, client }: SessionProvider
}
if (data.type === "tool_call" && data.metadata) {
const toolCallId =
typeof data.metadata.toolCallId === "string" ? data.metadata.toolCallId : "";
const toolName = typeof data.metadata.toolName === "string" ? data.metadata.toolName : "";
const args = data.metadata.arguments;
const {
toolCallId,
toolName,
arguments: args,
} = data.metadata as {
toolCallId: string;
toolName: string;
arguments: unknown;
};
setMessages(serverId, (prev) => [
...prev,
@@ -1420,9 +1428,10 @@ function SessionProviderInternal({ children, serverId, client }: SessionProvider
}
if (data.type === "tool_result" && data.metadata) {
const toolCallId =
typeof data.metadata.toolCallId === "string" ? data.metadata.toolCallId : "";
const result = data.metadata.result;
const { toolCallId, result } = data.metadata as {
toolCallId: string;
result: unknown;
};
const applyToolResult = applyToolResultToMessages(toolCallId, result);
setMessages(serverId, applyToolResult);
@@ -1430,9 +1439,10 @@ function SessionProviderInternal({ children, serverId, client }: SessionProvider
}
if (data.type === "error" && data.metadata && "toolCallId" in data.metadata) {
const toolCallId =
typeof data.metadata.toolCallId === "string" ? data.metadata.toolCallId : "";
const error = data.metadata.error;
const { toolCallId, error } = data.metadata as {
toolCallId: string;
error: unknown;
};
const applyToolError = applyToolErrorToMessages(toolCallId, error);
setMessages(serverId, applyToolError);
@@ -1790,7 +1800,7 @@ function SessionProviderInternal({ children, serverId, client }: SessionProvider
} catch (error) {
console.error("[Session] Failed to prepare images for agent creation:", error);
}
await client.createAgent({
return client.createAgent({
config,
...(trimmedPrompt ? { initialPrompt: trimmedPrompt } : {}),
...(imagesData && imagesData.length > 0 ? { images: imagesData } : {}),
@@ -553,7 +553,5 @@ export function resolveAgentForm(
case "RESET":
return { ...state, userModified: INITIAL_USER_MODIFIED };
default:
throw new Error("unreachable");
}
}
@@ -459,7 +459,7 @@ export function useAgentFormState(options: UseAgentFormStateOptions = {}): UseAg
}, []);
const refreshProviderModels = useCallback(() => {
void refreshSnapshot();
refreshSnapshot();
}, [refreshSnapshot]);
const refetchProviderModelsIfStale = useCallback(() => {
+1 -1
View File
@@ -141,7 +141,7 @@ export function useAgentHistory(options: {
isLoading,
isInitialLoad,
isRevalidating,
hasMore: hasNextPage,
hasMore: Boolean(hasNextPage),
isLoadingMore: isFetchingNextPage,
refreshAll,
loadMore,
+2 -2
View File
@@ -100,7 +100,7 @@ function isAgentArchiving(input: IsAgentArchivingInput): boolean {
if (!key) {
return false;
}
return readPendingState(input.queryClient)[key] ?? false;
return Boolean(readPendingState(input.queryClient)[key]);
}
function removeAgentFromListPayload<T extends AgentsListQueryData | undefined>(
@@ -420,7 +420,7 @@ export function useArchiveAgent() {
if (!key) {
return false;
}
return (pendingQuery.data ?? {})[key] ?? false;
return Boolean((pendingQuery.data ?? {})[key]);
},
[pendingQuery.data],
);
@@ -1055,8 +1055,6 @@ function resolvePayload(
return { delta: def.delta };
case "message-input":
return { kind: def.kind };
default:
throw new Error("unreachable");
}
}
+5 -5
View File
@@ -7,17 +7,17 @@ export interface PaneContextValue {
workspaceId: string;
tabId: string;
target: WorkspaceTabTarget;
openTab: (target: WorkspaceTabTarget) => void;
closeCurrentTab: () => void;
retargetCurrentTab: (target: WorkspaceTabTarget) => void;
openFileInWorkspace: (filePath: string) => void;
openTab(target: WorkspaceTabTarget): void;
closeCurrentTab(): void;
retargetCurrentTab(target: WorkspaceTabTarget): void;
openFileInWorkspace(filePath: string): void;
}
export interface PaneFocusContextValue {
isWorkspaceFocused: boolean;
isPaneFocused: boolean;
isInteractive: boolean;
focusPane: () => void;
focusPane(): void;
}
const PaneContext = createContext<PaneContextValue | null>(null);
@@ -71,12 +71,6 @@ export default function ProjectSettingsScreen({ projectKey }: ProjectSettingsScr
}
}, [editableHosts, selectedServerId]);
const selectedSnapshot = useHostRuntimeSnapshot(selectedServerId);
const isHostGone =
Boolean(selectedServerId) &&
(selectedSnapshot?.connectionStatus === "offline" ||
selectedSnapshot?.connectionStatus === "error");
const selectedHost = editableHosts.find((host) => host.serverId === selectedServerId);
const client = useHostRuntimeClient(selectedHost?.serverId ?? "");
@@ -91,7 +85,6 @@ export default function ProjectSettingsScreen({ projectKey }: ProjectSettingsScr
selectedHost={selectedHost}
onSelectHost={setSelectedServerId}
client={client}
isHostGone={isHostGone}
/>
);
}
@@ -146,7 +139,6 @@ interface ProjectSettingsBodyProps {
selectedHost: ProjectHostEntry;
onSelectHost: (serverId: string) => void;
client: DaemonClient;
isHostGone: boolean;
}
function ProjectSettingsBody({
@@ -155,7 +147,6 @@ function ProjectSettingsBody({
selectedHost,
onSelectHost,
client,
isHostGone,
}: ProjectSettingsBodyProps) {
const queryKey = useMemo(
() => ["project-config", selectedHost.serverId, selectedHost.repoRoot] as const,
@@ -201,7 +192,6 @@ function ProjectSettingsBody({
client,
onReload: handleReload,
hasMultipleHosts,
isHostGone,
})}
</View>
);
@@ -217,7 +207,6 @@ interface RenderContentInput {
client: DaemonClient;
onReload: () => void;
hasMultipleHosts: boolean;
isHostGone: boolean;
}
function renderContent({
@@ -230,7 +219,6 @@ function renderContent({
client,
onReload,
hasMultipleHosts,
isHostGone,
}: RenderContentInput) {
if (readQuery.isLoading) {
return (
@@ -262,10 +250,6 @@ function renderContent({
);
}
if (isHostGone) {
return <NoEditableTarget />;
}
if (!loadedConfig) {
return (
<View style={styles.centered}>
+1 -1
View File
@@ -86,7 +86,7 @@ async function showDesktopConfirmDialog(input: ConfirmDialogInput): Promise<bool
const desktopAsk = desktopApi.dialog?.ask;
if (typeof desktopAsk === "function") {
return await desktopAsk(input.message, options);
return Boolean(await desktopAsk(input.message, options));
}
return null;
@@ -1,5 +1,4 @@
import { describe, expect, it } from "vitest";
import { PaseoConfigRawSchema } from "@server/utils/paseo-config-schema";
import type { PaseoConfigRaw } from "@server/shared/messages";
import { applyDraftToConfig, configToDraft, type ProjectConfigDraft } from "./project-config-form";
@@ -103,7 +102,7 @@ describe("applyDraftToConfig", () => {
});
it("preserves unknown top-level, worktree, and script entry fields on round-trip", () => {
const base = PaseoConfigRawSchema.parse({
const base = {
worktree: {
setup: "npm install",
terminals: [{ name: "dev", command: "npm run dev" }],
@@ -118,28 +117,28 @@ describe("applyDraftToConfig", () => {
},
},
customTopLevel: "preserved",
});
} as unknown as PaseoConfigRaw;
const draft = configToDraft(base);
const next = applyDraftToConfig({ draft, base });
expect((next as Record<string, unknown>).customTopLevel).toBe("preserved");
expect((next.worktree as Record<string, unknown>).customWorktreeField).toBe("keep");
expect((next.worktree as Record<string, unknown>).terminals).toEqual([
expect((next as unknown as Record<string, unknown>).customTopLevel).toBe("preserved");
expect((next.worktree as unknown as Record<string, unknown>).customWorktreeField).toBe("keep");
expect((next.worktree as unknown as Record<string, unknown>).terminals).toEqual([
{ name: "dev", command: "npm run dev" },
]);
const devEntry = (next.scripts ?? {}).dev as Record<string, unknown>;
const devEntry = (next.scripts ?? {}).dev as unknown as Record<string, unknown>;
expect(devEntry.customScriptField).toEqual({ nested: true });
});
it("preserves all scripts on round-trip, including ones never edited in this session", () => {
const base = PaseoConfigRawSchema.parse({
const base = {
scripts: {
dev: { type: "long-running", command: "npm run dev", port: 3000, customDevField: "keep" },
build: { command: ["npm", "run", "build"], customBuildField: { nested: 1 } },
lint: { command: "npm run lint", type: "task" },
},
});
} as unknown as PaseoConfigRaw;
const draft = configToDraft(base);
// Edit only "dev". Leave "build" and "lint" untouched.
@@ -151,37 +150,37 @@ describe("applyDraftToConfig", () => {
const scripts = next.scripts ?? {};
expect(Object.keys(scripts).sort()).toEqual(["build", "dev", "lint"]);
const devEntry = scripts.dev as Record<string, unknown>;
const devEntry = scripts.dev as unknown as Record<string, unknown>;
expect(devEntry.command).toBe("npm run dev -- --watch");
expect(devEntry.type).toBe("long-running");
expect(devEntry.port).toBe(3000);
expect(devEntry.customDevField).toBe("keep");
const buildEntry = scripts.build as Record<string, unknown>;
const buildEntry = scripts.build as unknown as Record<string, unknown>;
expect(buildEntry.command).toEqual(["npm", "run", "build"]);
expect(buildEntry.customBuildField).toEqual({ nested: 1 });
const lintEntry = scripts.lint as Record<string, unknown>;
const lintEntry = scripts.lint as unknown as Record<string, unknown>;
expect(lintEntry.command).toBe("npm run lint");
expect(lintEntry.type).toBe("task");
});
it("normalizes script command text into the original command kind", () => {
const base = PaseoConfigRawSchema.parse({
const base = {
scripts: {
build: { command: ["npm", "run", "build"] },
},
});
} as unknown as PaseoConfigRaw;
const draft = configToDraft(base);
const buildRow = draft.scripts[0];
buildRow.commandText = "npm run build";
const next = applyDraftToConfig({ draft, base });
const buildEntry = (next.scripts ?? {}).build as Record<string, unknown>;
const buildEntry = (next.scripts ?? {}).build as unknown as Record<string, unknown>;
expect(buildEntry.command).toEqual(["npm run build"]);
});
it("parses script port as a number when numeric and writes string for non-numeric input", () => {
const base = PaseoConfigRawSchema.parse({});
const base: PaseoConfigRaw = {};
const draft = configToDraft(base);
draft.scripts = [
{
@@ -204,19 +203,19 @@ describe("applyDraftToConfig", () => {
},
];
const next = applyDraftToConfig({ draft, base });
const dev = (next.scripts ?? {}).dev as Record<string, unknown>;
const tunnel = (next.scripts ?? {}).tunnel as Record<string, unknown>;
const dev = (next.scripts ?? {}).dev as unknown as Record<string, unknown>;
const tunnel = (next.scripts ?? {}).tunnel as unknown as Record<string, unknown>;
expect(dev.port).toBe(3000);
expect(tunnel.port).toBe("auto");
});
it("drops scripts with an empty name and removes scripts no longer present in the draft", () => {
const base = PaseoConfigRawSchema.parse({
const base = {
scripts: {
dev: { command: "npm run dev" },
build: { command: "npm run build" },
},
});
} as unknown as PaseoConfigRaw;
const draft = configToDraft(base);
// remove build, add a row with empty name.
draft.scripts = draft.scripts
@@ -112,7 +112,7 @@ export function getWorkspaceExecutionAuthority(
reason: "workspace_missing",
message:
"workspaces" in input
? `Workspace not found: ${input.workspaceId ?? ""}`
? `Workspace not found: ${String(input.workspaceId ?? "")}`
: "Workspace not found.",
};
}
+2 -2
View File
@@ -1,6 +1,6 @@
{
"name": "@getpaseo/cli",
"version": "0.1.69",
"version": "0.1.67",
"description": "Paseo CLI - control your AI coding agents from the command line",
"bin": {
"paseo": "bin/paseo"
@@ -24,7 +24,7 @@
},
"dependencies": {
"@clack/prompts": "^1.0.0",
"@getpaseo/server": "0.1.69",
"@getpaseo/server": "0.1.67",
"chalk": "^5.3.0",
"commander": "^12.0.0",
"mime-types": "^2.1.35",
@@ -1,80 +0,0 @@
import { EventEmitter } from "node:events";
import { beforeEach, describe, expect, test, vi } from "vitest";
const mocks = vi.hoisted(() => ({
spawnSync: vi.fn(),
spawnProcess: vi.fn(),
}));
vi.mock("node:child_process", async () => {
const actual = await vi.importActual<typeof import("node:child_process")>("node:child_process");
return {
...actual,
spawnSync: mocks.spawnSync,
};
});
vi.mock("@getpaseo/server", async () => {
const actual = await vi.importActual<typeof import("@getpaseo/server")>("@getpaseo/server");
return {
...actual,
loadConfig: () => ({ listen: "127.0.0.1:6767" }),
resolvePaseoHome: (env: NodeJS.ProcessEnv) => env.PASEO_HOME ?? "/tmp/paseo",
spawnProcess: mocks.spawnProcess,
};
});
class FakeChildProcess extends EventEmitter {
pid = 4242;
unref = vi.fn();
}
function expectSupervisorLaunch(argv: string[]): void {
const joined = argv.join(" ");
expect(joined).toContain("supervisor-entrypoint");
expect(joined).not.toContain("src/server/index.ts");
expect(joined).not.toContain("dist/server/server/index.js");
expect(joined).not.toContain("src/server/daemon-worker.ts");
expect(joined).not.toContain("dist/server/server/daemon-worker.js");
}
describe("local daemon launch supervision", () => {
beforeEach(() => {
vi.useRealTimers();
mocks.spawnSync.mockReset();
mocks.spawnProcess.mockReset();
});
test("foreground start spawns supervisor-entrypoint instead of server/index", async () => {
mocks.spawnSync.mockReturnValue({ status: 0, error: undefined });
const { startLocalDaemonForeground } = await import("./local-daemon.js");
const status = startLocalDaemonForeground({ home: "/tmp/paseo-test", relay: false });
expect(status).toBe(0);
expect(mocks.spawnSync).toHaveBeenCalledOnce();
const [command, argv] = mocks.spawnSync.mock.calls[0] as [string, string[]];
expect(command).toBe(process.execPath);
expectSupervisorLaunch(argv);
expect(argv).toContain("--no-relay");
});
test("detached start spawns supervisor-entrypoint instead of server/index", async () => {
vi.useFakeTimers();
const child = new FakeChildProcess();
mocks.spawnProcess.mockReturnValue(child);
const { startLocalDaemonDetached } = await import("./local-daemon.js");
const resultPromise = startLocalDaemonDetached({ home: "/tmp/paseo-test", mcp: false });
await vi.advanceTimersByTimeAsync(1200);
const result = await resultPromise;
expect(result).toEqual({ pid: 4242, logPath: "/tmp/paseo-test/daemon.log" });
expect(child.unref).toHaveBeenCalledOnce();
expect(mocks.spawnProcess).toHaveBeenCalledOnce();
const [command, argv] = mocks.spawnProcess.mock.calls[0] as [string, string[]];
expect(command).toBe(process.execPath);
expectSupervisorLaunch(argv);
expect(argv).toContain("--no-mcp");
});
});
+1 -1
View File
@@ -432,7 +432,7 @@ async function waitForDaemonReadyWithUi(args: {
} else {
log.error(message);
}
return process.exit(1);
process.exit(1);
}
}
@@ -123,8 +123,6 @@ export function toDaemonCreateInput(parsed: ParsedWorktreeCreateInput) {
action: "checkout" as const,
githubPrNumber: parsed.target.prNumber,
};
default:
throw new Error("unreachable");
}
}
@@ -1,105 +0,0 @@
#!/usr/bin/env npx tsx
/**
* Regression: executable daemon launch commands must enter the supervisor.
* The worker entry remains an implementation detail of supervisor-entrypoint.
*/
import assert from "node:assert";
import { readFile } from "node:fs/promises";
import { join } from "node:path";
const repoRoot = join(import.meta.dirname, "../../..");
const serverPackagePath = join(repoRoot, "packages/server/package.json");
const appGlobalSetupPath = join(repoRoot, "packages/app/e2e/global-setup.ts");
const serverConnectionOfferE2ePath = join(
repoRoot,
"packages/server/src/server/daemon-e2e/connection-offer.e2e.test.ts",
);
const desktopRuntimePathsPath = join(repoRoot, "packages/desktop/src/daemon/runtime-paths.ts");
const nixPackagePath = join(repoRoot, "nix/package.nix");
function assertNoDirectWorkerLaunch(label: string, command: string): void {
assert(
!command.includes("src/server/index.ts"),
`${label} must not launch src/server/index.ts directly: ${command}`,
);
assert(
!command.includes("dist/server/server/index.js"),
`${label} must not launch dist/server/server/index.js directly: ${command}`,
);
assert(
!command.includes("src/server/daemon-worker.ts"),
`${label} must not launch src/server/daemon-worker.ts directly: ${command}`,
);
assert(
!command.includes("dist/server/server/daemon-worker.js"),
`${label} must not launch dist/server/server/daemon-worker.js directly: ${command}`,
);
}
function assertNoSpawnedWorkerEntrypoint(label: string, source: string): void {
assertNoDirectWorkerLaunch(label, source);
assert(
!/spawn\([^)]*["'`][^"'`]*\.\.\/index\.ts["'`]/s.test(source),
`${label} must not spawn ../index.ts directly`,
);
}
console.log("=== Daemon Launch Supervision Regression ===\n");
console.log("Test 1: server package scripts launch supervisor-entrypoint");
const serverPackage = JSON.parse(await readFile(serverPackagePath, "utf-8")) as {
scripts?: Record<string, string>;
};
const startScript = serverPackage.scripts?.start ?? "";
const devScript = serverPackage.scripts?.dev ?? "";
const devTsxScript = serverPackage.scripts?.["dev:tsx"] ?? "";
assert(startScript.includes("dist/scripts/supervisor-entrypoint.js"), startScript);
assertNoDirectWorkerLaunch("server start script", startScript);
assert(devScript.includes("scripts/dev-runner.ts"), devScript);
assertNoDirectWorkerLaunch("server dev script", devScript);
assert(devTsxScript.includes("scripts/dev-runner.ts"), devTsxScript);
assertNoDirectWorkerLaunch("server dev:tsx script", devTsxScript);
console.log("✓ server package scripts enter supervisor\n");
console.log("Test 2: app e2e global setup launches supervisor-entrypoint in dev mode");
const appGlobalSetup = await readFile(appGlobalSetupPath, "utf-8");
assert(
appGlobalSetup.includes('spawn(tsxBin, ["scripts/supervisor-entrypoint.ts", "--dev"]'),
"app e2e setup should spawn supervisor-entrypoint.ts with --dev",
);
assertNoSpawnedWorkerEntrypoint("app e2e global setup", appGlobalSetup);
console.log("✓ app e2e setup enters supervisor\n");
console.log("Test 3: server daemon e2e process launch enters supervisor");
const serverConnectionOfferE2e = await readFile(serverConnectionOfferE2ePath, "utf-8");
assert(
serverConnectionOfferE2e.includes("scripts/supervisor-entrypoint.ts"),
"server daemon e2e process launch should use supervisor-entrypoint.ts",
);
assertNoSpawnedWorkerEntrypoint("server daemon e2e process launch", serverConnectionOfferE2e);
console.log("✓ server daemon e2e process launch enters supervisor\n");
console.log("Test 4: desktop runtime and Nix wrapper point at supervisor-entrypoint");
const desktopRuntimePaths = await readFile(desktopRuntimePathsPath, "utf-8");
assert(
desktopRuntimePaths.includes('"dist", "scripts", "supervisor-entrypoint.js"'),
"desktop packaged daemon runner should resolve dist/scripts/supervisor-entrypoint.js",
);
assert(
desktopRuntimePaths.includes('"scripts", "supervisor-entrypoint.ts"'),
"desktop dev daemon runner should resolve scripts/supervisor-entrypoint.ts",
);
assertNoDirectWorkerLaunch("desktop runtime paths", desktopRuntimePaths);
const nixPackage = await readFile(nixPackagePath, "utf-8");
assert(
nixPackage.includes("dist/scripts/supervisor-entrypoint.js"),
"Nix paseo-server wrapper should use dist/scripts/supervisor-entrypoint.js",
);
assertNoDirectWorkerLaunch("Nix package wrapper", nixPackage);
console.log("✓ desktop runtime and Nix wrapper enter supervisor\n");
console.log("=== Daemon launch supervision regression test passed ===");
@@ -0,0 +1,198 @@
#!/usr/bin/env npx tsx
/**
* Regression: unsupervised restart request should gracefully stop and exit 0,
* so an external owner can decide whether to respawn.
*/
import assert from "node:assert";
import { spawn, type ChildProcess } from "node:child_process";
import { mkdtemp, readFile, rm } from "node:fs/promises";
import { tmpdir } from "node:os";
import { join } from "node:path";
import { tryConnectToDaemon } from "../src/utils/client.ts";
import { getAvailablePort } from "./helpers/network.ts";
const pollIntervalMs = 100;
const testEnv = {
PASEO_LOCAL_SPEECH_AUTO_DOWNLOAD: process.env.PASEO_LOCAL_SPEECH_AUTO_DOWNLOAD ?? "0",
PASEO_DICTATION_ENABLED: process.env.PASEO_DICTATION_ENABLED ?? "0",
PASEO_VOICE_MODE_ENABLED: process.env.PASEO_VOICE_MODE_ENABLED ?? "0",
};
function sleep(ms: number): Promise<void> {
return new Promise((resolve) => setTimeout(resolve, ms));
}
function isProcessRunning(pid: number): boolean {
if (!Number.isInteger(pid) || pid <= 0) {
return false;
}
try {
process.kill(pid, 0);
return true;
} catch {
return false;
}
}
async function waitFor(
check: () => Promise<boolean> | boolean,
timeoutMs: number,
message: string,
): Promise<void> {
const deadline = Date.now() + timeoutMs;
async function poll(): Promise<void> {
if (await check()) return;
if (Date.now() >= deadline) throw new Error(message);
await sleep(pollIntervalMs);
return poll();
}
return poll();
}
interface ExitResult {
code: number | null;
signal: NodeJS.Signals | null;
}
function waitForProcessExit(processRef: ChildProcess, timeoutMs: number): Promise<ExitResult> {
return new Promise((resolve, reject) => {
const timeout = setTimeout(() => {
reject(new Error("timed out waiting for process exit"));
}, timeoutMs);
processRef.once("exit", (code, signal) => {
clearTimeout(timeout);
resolve({ code, signal });
});
});
}
async function canConnectToDaemon(host: string, timeoutMs: number): Promise<boolean> {
const deadline = Date.now() + timeoutMs;
async function poll(): Promise<boolean> {
const client = await tryConnectToDaemon({ host, timeout: 500 }).catch(() => null);
if (client) {
await client.close().catch(() => undefined);
return true;
}
if (Date.now() >= deadline) return false;
await sleep(pollIntervalMs);
return poll();
}
return poll();
}
async function readPidLockPid(paseoHome: string): Promise<number | null> {
const pidPath = join(paseoHome, "paseo.pid");
try {
const content = await readFile(pidPath, "utf-8");
const parsed = JSON.parse(content) as { pid?: unknown };
if (typeof parsed.pid !== "number" || !Number.isInteger(parsed.pid) || parsed.pid <= 0) {
return null;
}
return parsed.pid;
} catch {
return null;
}
}
console.log("=== Daemon Restart (unsupervised regression) ===\n");
const port = await getAvailablePort();
const paseoHome = await mkdtemp(join(tmpdir(), "paseo-restart-unsupervised-"));
const cliRoot = join(import.meta.dirname, "..");
const host = `127.0.0.1:${port}`;
let daemonProcess: ChildProcess | null = null;
let recentDaemonLogs = "";
try {
console.log("Test 1: start unsupervised daemon worker directly");
daemonProcess = spawn(process.execPath, ["--import", "tsx", "../server/src/server/index.ts"], {
cwd: cliRoot,
env: {
...process.env,
...testEnv,
PASEO_HOME: paseoHome,
PASEO_LISTEN: host,
PASEO_RELAY_ENABLED: "false",
CI: "true",
},
stdio: ["ignore", "pipe", "pipe"],
});
daemonProcess.stdout?.on("data", (chunk) => {
recentDaemonLogs = (recentDaemonLogs + chunk.toString()).slice(-8000);
});
daemonProcess.stderr?.on("data", (chunk) => {
recentDaemonLogs = (recentDaemonLogs + chunk.toString()).slice(-8000);
});
await waitFor(
async () =>
Boolean(daemonProcess?.pid && isProcessRunning(daemonProcess.pid)) &&
(await canConnectToDaemon(host, 1000)),
120000,
"daemon did not become running in time",
);
assert(daemonProcess.pid, "unsupervised daemon process pid should exist");
const lockPid = await readPidLockPid(paseoHome);
assert.strictEqual(lockPid, daemonProcess.pid, "unsupervised worker should own pid lock");
console.log(`✓ unsupervised daemon started with pid ${daemonProcess.pid}\n`);
console.log("Test 2: restart request should gracefully stop and exit code 0");
const client = await tryConnectToDaemon({ host, timeout: 5000 });
assert(client, "daemon client should connect");
const exitPromise = waitForProcessExit(daemonProcess, 30000);
try {
const restartAck = await client.restartServer("settings_update");
assert.strictEqual(
restartAck.status,
"restart_requested",
"restart request should be acknowledged",
);
} finally {
await client?.close().catch(() => undefined);
}
const exit = await exitPromise;
assert.strictEqual(exit.signal, null, `daemon should exit cleanly, got signal=${exit.signal}`);
assert.strictEqual(
exit.code,
0,
`daemon should exit with status 0, got code=${exit.code}\nRecent daemon logs:\n${recentDaemonLogs}`,
);
await waitFor(
async () => (await readPidLockPid(paseoHome)) === null,
15000,
"pid lock was not released after unsupervised restart request",
);
console.log("✓ unsupervised restart exited cleanly with code 0\n");
} finally {
if (daemonProcess?.pid && isProcessRunning(daemonProcess.pid)) {
daemonProcess.kill("SIGTERM");
await waitFor(
() => !isProcessRunning(daemonProcess!.pid ?? -1),
5000,
"daemon cleanup timed out",
).catch(() => {
daemonProcess?.kill("SIGKILL");
});
}
await rm(paseoHome, { recursive: true, force: true });
}
console.log("=== Unsupervised restart regression test passed ===");
+1 -6
View File
@@ -11,14 +11,10 @@
import { spawn } from "child_process";
import { $ } from "zx";
import { readdir, writeFile } from "fs/promises";
import { join, dirname, delimiter } from "path";
import { join, dirname } from "path";
import { fileURLToPath } from "url";
const __dirname = dirname(fileURLToPath(import.meta.url));
// npm workspace scripts only add the local node_modules/.bin to PATH; hoisted
// packages live in the root. Prepend it so `npx paseo` resolves locally.
const rootNodeModulesBin = join(__dirname, "..", "..", "..", "node_modules", ".bin");
const args = process.argv.slice(2);
const testEnvDefaults = {
PASEO_LOCAL_SPEECH_AUTO_DOWNLOAD: process.env.PASEO_LOCAL_SPEECH_AUTO_DOWNLOAD ?? "0",
@@ -197,7 +193,6 @@ async function runSingleTest(testFile: string): Promise<TestOutcome> {
const proc = spawn("npx", ["tsx", testPath], {
env: {
...process.env,
PATH: [rootNodeModulesBin, process.env.PATH].filter(Boolean).join(delimiter),
PASEO_LOCAL_SPEECH_AUTO_DOWNLOAD: testEnvDefaults.PASEO_LOCAL_SPEECH_AUTO_DOWNLOAD,
PASEO_DICTATION_ENABLED: testEnvDefaults.PASEO_DICTATION_ENABLED,
PASEO_VOICE_MODE_ENABLED: testEnvDefaults.PASEO_VOICE_MODE_ENABLED,
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@getpaseo/desktop",
"version": "0.1.69",
"version": "0.1.67",
"private": true,
"description": "Paseo desktop app (Electron wrapper)",
"homepage": "https://paseo.sh",
@@ -43,22 +43,6 @@ describe("desktop-settings", () => {
expect(persisted.settings).toEqual(DEFAULT_DESKTOP_SETTINGS);
});
it("handles concurrent first-launch reads without racing the settings write", async () => {
const userDataPath = await createTempUserDataDir();
directories.add(userDataPath);
const store = createDesktopSettingsStore({ userDataPath });
const settings = await Promise.all(Array.from({ length: 20 }, () => store.get()));
const persisted = JSON.parse(await readFile(settingsFilePath(userDataPath), "utf8")) as {
settings: DesktopSettings;
};
const files = await readdir(userDataPath);
expect(settings).toEqual(Array.from({ length: 20 }, () => DEFAULT_DESKTOP_SETTINGS));
expect(persisted.settings).toEqual(DEFAULT_DESKTOP_SETTINGS);
expect(files).toEqual(["desktop-settings.json"]);
});
it("coerces invalid persisted values back to safe defaults", async () => {
const userDataPath = await createTempUserDataDir();
directories.add(userDataPath);
@@ -1,4 +1,3 @@
import { randomUUID } from "node:crypto";
import { mkdir, readFile, rename, writeFile } from "node:fs/promises";
import path from "node:path";
@@ -199,19 +198,13 @@ export function createDesktopSettingsStore({
}): DesktopSettingsStore {
const filePath = path.join(userDataPath, DESKTOP_SETTINGS_FILENAME);
let cachedDocument: PersistedDesktopSettingsDocument | null = null;
let persistQueue: Promise<void> = Promise.resolve();
async function persistDocument(document: PersistedDesktopSettingsDocument): Promise<void> {
const write = async () => {
await mkdir(userDataPath, { recursive: true });
const tempFilePath = `${filePath}.tmp.${process.pid}.${randomUUID()}`;
await writeFile(tempFilePath, `${JSON.stringify(document, null, 2)}\n`, "utf8");
await rename(tempFilePath, filePath);
cachedDocument = document;
};
const queued = persistQueue.then(write, write);
persistQueue = queued.catch(() => undefined);
await queued;
await mkdir(userDataPath, { recursive: true });
const tempFilePath = `${filePath}.tmp`;
await writeFile(tempFilePath, `${JSON.stringify(document, null, 2)}\n`, "utf8");
await rename(tempFilePath, filePath);
cachedDocument = document;
}
async function loadDocument(): Promise<PersistedDesktopSettingsDocument> {
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@getpaseo/expo-two-way-audio",
"version": "0.1.69",
"version": "0.1.67",
"description": "Native module for two way audio streaming",
"keywords": [
"ExpoTwoWayAudio",
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@getpaseo/highlight",
"version": "0.1.69",
"version": "0.1.67",
"files": [
"dist"
],
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@getpaseo/relay",
"version": "0.1.69",
"version": "0.1.67",
"description": "Paseo relay for bridging daemon and client connections",
"files": [
"dist"
+5 -5
View File
@@ -1,6 +1,6 @@
{
"name": "@getpaseo/server",
"version": "0.1.69",
"version": "0.1.67",
"description": "Paseo backend server",
"files": [
"dist/server",
@@ -29,12 +29,12 @@
},
"scripts": {
"dev": "cross-env PASEO_NODE_ENV=development node --import tsx scripts/dev-runner.ts",
"dev:tsx": "cross-env PASEO_NODE_ENV=development tsx watch --ignore '**/*.timestamp-*' scripts/dev-runner.ts",
"dev:tsx": "cross-env PASEO_NODE_ENV=development tsx watch --ignore '**/*.timestamp-*' src/server/index.ts",
"build": "node -e \"require('node:fs').rmSync('dist',{ recursive: true, force: true })\" && npm run build:lib && npm run build:scripts",
"build:lib": "tsc -p tsconfig.server.json --incremental false && node -e \"const fs=require('node:fs'); fs.mkdirSync('dist/server/server/speech/providers/local/sherpa/assets',{recursive:true}); fs.copyFileSync('src/server/speech/providers/local/sherpa/assets/silero_vad.onnx','dist/server/server/speech/providers/local/sherpa/assets/silero_vad.onnx'); fs.cpSync('src/terminal/shell-integration','dist/server/terminal/shell-integration',{recursive:true}); fs.cpSync('src/terminal/shell-integration','dist/src/terminal/shell-integration',{recursive:true}); fs.copyFileSync('src/terminal/terminal-ts-loader.mjs','dist/server/terminal/terminal-ts-loader.mjs');\"",
"build:scripts": "tsc -p tsconfig.scripts.json --incremental false && node -e \"const fs=require('node:fs'); fs.mkdirSync('dist/scripts',{recursive:true}); fs.copyFileSync('scripts/mcp-stdio-socket-bridge-cli.mjs','dist/scripts/mcp-stdio-socket-bridge-cli.mjs');\"",
"prepack": "npm run build",
"start": "node dist/scripts/supervisor-entrypoint.js",
"start": "node dist/server/server/index.js",
"typecheck": "tsgo -p tsconfig.server.typecheck.json --noEmit",
"generate:config-schema": "tsx scripts/generate-config-schema.ts",
"speech:models": "tsx scripts/list-speech-models.ts",
@@ -58,8 +58,8 @@
"dependencies": {
"@agentclientprotocol/sdk": "^0.17.1",
"@anthropic-ai/claude-agent-sdk": "^0.2.11",
"@getpaseo/highlight": "0.1.69",
"@getpaseo/relay": "0.1.69",
"@getpaseo/highlight": "0.1.67",
"@getpaseo/relay": "0.1.67",
"@isaacs/ttlcache": "^2.1.4",
"@mariozechner/pi-agent-core": "^0.70.2",
"@mariozechner/pi-ai": "^0.70.2",
+1 -4
View File
@@ -26,10 +26,7 @@ const supervisorArgs = [
const supervisor = spawn(process.execPath, supervisorArgs, {
stdio: "inherit",
env: {
...process.env,
PASEO_LOG_FORMAT: process.env.PASEO_LOG_FORMAT ?? "pretty",
},
env: process.env,
});
function exitCodeForSignal(signal: NodeJS.Signals): number {
@@ -15,17 +15,6 @@ describe("supervision parity", () => {
expect(daemonRunnerCalls + devRunnerCalls).toBe(1);
});
test("supervisor worker implementation is not server/index", () => {
const daemonRunner = readFileSync(
new URL("./supervisor-entrypoint.ts", import.meta.url),
"utf8",
);
expect(daemonRunner).toContain("daemon-worker");
expect(daemonRunner).not.toContain("server/server/index.js");
expect(daemonRunner).not.toContain("src/server/index.ts");
});
test("dev runner waits asynchronously for supervisor shutdown", () => {
const devRunner = readFileSync(new URL("./dev-runner.ts", import.meta.url), "utf8");
@@ -8,14 +8,9 @@ import {
updatePidLock,
} from "../src/server/pid-lock.js";
import { resolvePaseoHome } from "../src/server/paseo-home.js";
import { loadPersistedConfig } from "../src/server/persisted-config.js";
import { runSupervisor } from "./supervisor.js";
import { applySherpaLoaderEnv } from "../src/server/speech/providers/local/sherpa/sherpa-runtime-env.js";
const DEFAULT_DAEMON_LOG_FILENAME = "daemon.log";
const DEFAULT_LOG_ROTATE_SIZE = "10m";
const DEFAULT_LOG_ROTATE_MAX_FILES = 2;
interface DaemonRunnerConfig {
devMode: boolean;
workerArgs: string[];
@@ -38,10 +33,10 @@ function parseConfig(argv: string[]): DaemonRunnerConfig {
function resolveWorkerEntry(): string {
const candidates = [
fileURLToPath(new URL("../server/server/daemon-worker.js", import.meta.url)),
fileURLToPath(new URL("../dist/server/server/daemon-worker.js", import.meta.url)),
fileURLToPath(new URL("../src/server/daemon-worker.ts", import.meta.url)),
fileURLToPath(new URL("../../src/server/daemon-worker.ts", import.meta.url)),
fileURLToPath(new URL("../server/server/index.js", import.meta.url)),
fileURLToPath(new URL("../dist/server/server/index.js", import.meta.url)),
fileURLToPath(new URL("../src/server/index.ts", import.meta.url)),
fileURLToPath(new URL("../../src/server/index.ts", import.meta.url)),
];
for (const candidate of candidates) {
@@ -54,7 +49,7 @@ function resolveWorkerEntry(): string {
}
function resolveDevWorkerEntry(): string {
const candidate = fileURLToPath(new URL("../src/server/daemon-worker.ts", import.meta.url));
const candidate = fileURLToPath(new URL("../src/server/index.ts", import.meta.url));
if (!existsSync(candidate)) {
throw new Error(`Dev worker entry not found: ${candidate}`);
}
@@ -77,33 +72,11 @@ function resolvePackagedNodeEntrypointRunnerPath(currentScriptPath: string): str
return existsSync(runnerPath) ? runnerPath : null;
}
function resolveSupervisorLogFile(
paseoHome: string,
persistedConfig: ReturnType<typeof loadPersistedConfig>,
) {
const configuredFile = persistedConfig.log?.file;
const configuredPath = configuredFile?.path;
let logPath = path.join(paseoHome, DEFAULT_DAEMON_LOG_FILENAME);
if (configuredPath) {
logPath = path.isAbsolute(configuredPath)
? configuredPath
: path.resolve(paseoHome, configuredPath);
}
return {
path: logPath,
rotate: {
maxSize: configuredFile?.rotate?.maxSize ?? DEFAULT_LOG_ROTATE_SIZE,
maxFiles: configuredFile?.rotate?.maxFiles ?? DEFAULT_LOG_ROTATE_MAX_FILES,
},
};
}
async function main(): Promise<void> {
const config = parseConfig(process.argv.slice(2));
const workerEntry = config.devMode ? resolveDevWorkerEntry() : resolveWorkerEntry();
const workerExecArgv = resolveWorkerExecArgv(workerEntry);
const workerEnv: NodeJS.ProcessEnv = { ...process.env };
const workerEnv: NodeJS.ProcessEnv = { ...process.env, PASEO_SUPERVISED: "1" };
const packagedNodeEntrypointRunner =
process.env.ELECTRON_RUN_AS_NODE === "1"
? resolvePackagedNodeEntrypointRunnerPath(fileURLToPath(import.meta.url))
@@ -112,8 +85,6 @@ async function main(): Promise<void> {
applySherpaLoaderEnv(workerEnv);
const paseoHome = resolvePaseoHome(workerEnv);
const persistedConfig = loadPersistedConfig(paseoHome);
const supervisorLogFile = resolveSupervisorLogFile(paseoHome, persistedConfig);
try {
await acquirePidLock(paseoHome, null, {
@@ -141,7 +112,9 @@ async function main(): Promise<void> {
runSupervisor({
name: "DaemonRunner",
startupMessage: "Starting daemon worker (IPC restart and crash restart enabled)",
startupMessage: config.devMode
? "Starting daemon worker (dev mode, crash restarts enabled)"
: "Starting daemon worker (IPC restart enabled)",
resolveWorkerEntry: () => workerEntry,
workerArgs: config.workerArgs,
workerEnv,
@@ -161,8 +134,7 @@ async function main(): Promise<void> {
},
})
: undefined,
restartOnCrash: true,
logFile: supervisorLogFile,
restartOnCrash: config.devMode,
onWorkerReady: async ({ listen }) => {
await updatePidLock(paseoHome, { listen }, { ownerPid: process.pid });
},
@@ -1,132 +0,0 @@
import { mkdtemp, readFile, writeFile } from "node:fs/promises";
import { tmpdir } from "node:os";
import path from "node:path";
import { fileURLToPath, pathToFileURL } from "node:url";
import { spawn } from "node:child_process";
import { describe, expect, test } from "vitest";
const repoRoot = path.resolve(fileURLToPath(new URL("../../..", import.meta.url)));
const supervisorPath = fileURLToPath(new URL("./supervisor.ts", import.meta.url));
async function runSupervisorFixture(options: {
workerSource: string;
restartOnCrash?: boolean;
}): Promise<{
code: number | null;
signal: NodeJS.Signals | null;
log: string;
stdout: string;
stderr: string;
}> {
const tempDir = await mkdtemp(path.join(tmpdir(), "paseo-supervisor-log-"));
const logPath = path.join(tempDir, "daemon.log");
const workerPath = path.join(tempDir, "worker.mjs");
const runnerPath = path.join(tempDir, "runner.mjs");
await writeFile(workerPath, options.workerSource);
await writeFile(
runnerPath,
`
import { runSupervisor } from ${JSON.stringify(pathToFileURL(supervisorPath).href)};
runSupervisor({
name: "TestSupervisor",
startupMessage: "starting fixture",
resolveWorkerEntry: () => ${JSON.stringify(workerPath)},
workerArgs: [],
workerEnv: process.env,
workerExecArgv: [],
restartOnCrash: ${JSON.stringify(options.restartOnCrash ?? false)},
logFile: {
path: ${JSON.stringify(logPath)},
rotate: { maxSize: "1m", maxFiles: 2 },
},
});
`,
);
const child = spawn(process.execPath, ["--import", "tsx", runnerPath], {
cwd: repoRoot,
env: { ...process.env },
stdio: ["ignore", "pipe", "pipe"],
});
let stdout = "";
let stderr = "";
child.stdout.setEncoding("utf8");
child.stderr.setEncoding("utf8");
child.stdout.on("data", (chunk) => {
stdout += chunk;
});
child.stderr.on("data", (chunk) => {
stderr += chunk;
});
const { code, signal } = await new Promise<{
code: number | null;
signal: NodeJS.Signals | null;
}>((resolve, reject) => {
const timeout = setTimeout(() => {
child.kill("SIGKILL");
reject(new Error("supervisor fixture timed out"));
}, 10000);
child.on("error", (error) => {
clearTimeout(timeout);
reject(error);
});
child.on("close", (exitCode, exitSignal) => {
clearTimeout(timeout);
resolve({ code: exitCode, signal: exitSignal });
});
});
const log = await readFile(logPath, "utf8");
return { code, signal, log, stdout, stderr };
}
describe("supervisor durable logging", () => {
test("writes supervised worker stdout and stderr to daemon.log", async () => {
const result = await runSupervisorFixture({
workerSource: `
process.stdout.write('{"level":30,"msg":"worker-json-stdout"}\\n');
process.stderr.write('{"level":50,"msg":"worker-json-stderr"}\\n');
process.exit(0);
`,
});
expect(result.code).toBe(0);
expect(result.signal).toBeNull();
expect(result.log).toContain('"worker-json-stdout"');
expect(result.log).toContain('"worker-json-stderr"');
expect(result.stdout).toContain('"worker-json-stdout"');
expect(result.stderr).toContain('"worker-json-stderr"');
});
test("preserves raw non-JSON stdout and stderr lines", async () => {
const result = await runSupervisorFixture({
workerSource: `
process.stdout.write('raw stdout line\\n');
process.stderr.write('raw stderr line\\n');
process.exit(0);
`,
});
expect(result.log).toContain("raw stdout line\n");
expect(result.log).toContain("raw stderr line\n");
});
test("logs worker signal exits even when the worker cannot log", async () => {
const result = await runSupervisorFixture({
workerSource: `
process.kill(process.pid, "SIGKILL");
`,
});
expect(result.code).toBe(1);
expect(result.signal).toBeNull();
expect(result.log).toContain('"msg":"Worker exited"');
expect(result.log).toContain('"signal":"SIGKILL"');
expect(result.log).toContain("Supervisor exiting");
});
});
+6 -97
View File
@@ -1,15 +1,4 @@
import { fork, spawn, type ChildProcess } from "child_process";
import { mkdirSync } from "node:fs";
import path from "node:path";
import { createStream as createRotatingFileStream } from "rotating-file-stream";
interface SupervisorLogFileOptions {
path: string;
rotate: {
maxSize: string;
maxFiles: number;
};
}
type WorkerLifecycleMessage =
| {
@@ -39,7 +28,6 @@ interface SupervisorOptions {
onWorkerReady?: (message: { listen: string }) => Promise<void> | void;
restartOnCrash?: boolean;
onSupervisorExit?: () => Promise<void> | void;
logFile?: SupervisorLogFileOptions;
}
function describeExit(code: number | null, signal: NodeJS.Signals | null): string {
@@ -71,31 +59,6 @@ function parseLifecycleMessage(msg: unknown): WorkerLifecycleMessage | null {
return null;
}
function toRotatingFileStreamSize(size: string): string {
const trimmed = size.trim();
const match = trimmed.match(/^(\d+)\s*([bBkKmMgG])?$/);
if (!match) {
return trimmed;
}
const value = match[1];
const unit = (match[2] ?? "M").toUpperCase();
return `${value}${unit}`;
}
function createSupervisorLogStream(options: SupervisorLogFileOptions | undefined) {
if (!options) {
return null;
}
mkdirSync(path.dirname(options.path), { recursive: true });
return createRotatingFileStream(path.basename(options.path), {
path: path.dirname(options.path),
size: toRotatingFileStreamSize(options.rotate.maxSize),
maxFiles: options.rotate.maxFiles,
});
}
export function runSupervisor(options: SupervisorOptions): void {
const restartOnCrash = options.restartOnCrash ?? false;
const workerArgs = options.workerArgs ?? process.argv.slice(2);
@@ -107,39 +70,11 @@ export function runSupervisor(options: SupervisorOptions): void {
let restarting = false;
let shuttingDown = false;
let exiting = false;
const logStream = createSupervisorLogStream(options.logFile);
const writeDurableChunk = (chunk: string | Buffer): void => {
logStream?.write(chunk);
};
const writeLifecycleLog = (message: string, fields: Record<string, unknown> = {}): void => {
writeDurableChunk(
`${JSON.stringify({
level: "info",
time: new Date().toISOString(),
pid: process.pid,
name: options.name,
msg: message,
...fields,
})}\n`,
);
};
const log = (message: string): void => {
process.stderr.write(`[${options.name}] ${message}\n`);
writeLifecycleLog(message);
};
const closeLogStream = (): Promise<void> =>
new Promise((resolve) => {
if (!logStream) {
resolve();
return;
}
logStream.end(resolve);
});
const exitSupervisor = (code: number): void => {
if (exiting) {
return;
@@ -150,7 +85,6 @@ export function runSupervisor(options: SupervisorOptions): void {
const message = error instanceof Error ? error.message : String(error);
log(`Supervisor exit cleanup failed: ${message}`);
})
.then(closeLogStream)
.finally(() => {
process.exit(code);
});
@@ -169,30 +103,19 @@ export function runSupervisor(options: SupervisorOptions): void {
}
const spawnSpec = resolveWorkerSpawnSpec?.(workerEntry) ?? null;
writeLifecycleLog("Spawning worker", { workerEntry });
if (spawnSpec) {
child = spawn(spawnSpec.command, spawnSpec.args, {
stdio: ["inherit", "pipe", "pipe", "ipc"],
stdio: ["inherit", "inherit", "inherit", "ipc"],
env: spawnSpec.env ?? workerEnv,
});
} else {
child = fork(workerEntry, workerArgs, {
stdio: ["inherit", "pipe", "pipe", "ipc"],
stdio: "inherit",
env: workerEnv,
execArgv: workerExecArgv,
});
}
child.stdout?.on("data", (chunk: Buffer) => {
process.stdout.write(chunk);
writeDurableChunk(chunk);
});
child.stderr?.on("data", (chunk: Buffer) => {
process.stderr.write(chunk);
writeDurableChunk(chunk);
});
child.on("message", (msg: unknown) => {
const lifecycleMessage = parseLifecycleMessage(msg);
if (!lifecycleMessage) {
@@ -200,7 +123,6 @@ export function runSupervisor(options: SupervisorOptions): void {
}
if (lifecycleMessage.type === "paseo:ready") {
writeLifecycleLog("Worker ready", { listen: lifecycleMessage.listen });
Promise.resolve(options.onWorkerReady?.({ listen: lifecycleMessage.listen })).catch(
(error) => {
const message = error instanceof Error ? error.message : String(error);
@@ -211,21 +133,15 @@ export function runSupervisor(options: SupervisorOptions): void {
}
if (lifecycleMessage.type === "paseo:shutdown") {
writeLifecycleLog("Worker requested shutdown");
requestShutdown("Shutdown requested by worker");
return;
}
writeLifecycleLog(
"Worker requested restart",
lifecycleMessage.reason ? { reason: lifecycleMessage.reason } : {},
);
requestRestart("Restart requested by worker");
});
child.on("close", (code, signal) => {
child.on("exit", (code, signal) => {
const exitDescriptor = describeExit(code, signal);
writeLifecycleLog("Worker exited", { code, signal, exit: exitDescriptor });
if (shuttingDown) {
log(`Worker exited (${exitDescriptor}). Supervisor shutting down.`);
@@ -235,21 +151,17 @@ export function runSupervisor(options: SupervisorOptions): void {
const crashed =
restartOnCrash &&
((code !== 0 && code !== null) || (signal !== null && signal !== "SIGTERM"));
((code !== 0 && code !== null) || (signal !== null && signal === "SIGKILL"));
if (restarting || crashed) {
restarting = false;
log(
crashed
? `Worker crashed (${exitDescriptor}). Restarting worker...`
: `Worker exited (${exitDescriptor}). Restarting worker...`,
);
log(`Worker exited (${exitDescriptor}). Restarting worker...`);
spawnWorker();
return;
}
log(`Worker exited (${exitDescriptor}). Supervisor exiting.`);
exitSupervisor(typeof code === "number" ? code : 1);
exitSupervisor(typeof code === "number" ? code : 0);
});
};
@@ -258,7 +170,6 @@ export function runSupervisor(options: SupervisorOptions): void {
return;
}
restarting = true;
writeLifecycleLog("Restart requested", { reason });
log(`${reason}. Stopping worker for restart...`);
child.kill("SIGTERM");
};
@@ -269,7 +180,6 @@ export function runSupervisor(options: SupervisorOptions): void {
}
shuttingDown = true;
restarting = false;
writeLifecycleLog("Supervisor shutdown requested", { reason });
log(`${reason}. Stopping worker...`);
if (!child) {
exitSupervisor(0);
@@ -286,6 +196,5 @@ export function runSupervisor(options: SupervisorOptions): void {
process.on("SIGTERM", () => forwardSignal("SIGTERM"));
process.stdout.write(`[${options.name}] ${options.startupMessage}\n`);
writeLifecycleLog(options.startupMessage);
spawnWorker();
}
+376 -179
View File
@@ -1,5 +1,4 @@
import { afterEach, expect, expectTypeOf, test, vi } from "vitest";
import { z } from "zod";
import { DaemonClient, type DaemonTransport } from "./daemon-client";
import { encodeFileTransferFrame, FileTransferOpcode } from "../shared/binary-frames/index.js";
import {
@@ -95,22 +94,6 @@ function wrapSessionMessage(message: unknown): string {
});
}
function assertStr(data: string | Uint8Array | ArrayBuffer | undefined): string {
if (typeof data !== "string") throw new Error("Expected string frame");
return data;
}
function parseSentFrame(
data: string | Uint8Array | ArrayBuffer | undefined,
): Record<string, unknown> {
return z
.object({
type: z.literal("session"),
message: z.record(z.unknown()),
})
.parse(JSON.parse(assertStr(data))).message;
}
const clients: DaemonClient[] = [];
afterEach(async () => {
@@ -140,7 +123,10 @@ test("dedupes in-flight checkout status requests per agentId", async () => {
expect(mock.sent).toHaveLength(1);
const request = parseSentFrame(mock.sent[0]);
const request = JSON.parse(mock.sent[0]) as {
type: "session";
message: { type: "checkout_status_request"; cwd: string; requestId: string };
};
const response = {
type: "session",
@@ -149,7 +135,7 @@ test("dedupes in-flight checkout status requests per agentId", async () => {
payload: {
cwd: "/tmp/project",
error: null,
requestId: request.requestId,
requestId: request.message.requestId,
isGit: false,
isPaseoOwnedWorktree: false,
repoRoot: null,
@@ -169,12 +155,12 @@ test("dedupes in-flight checkout status requests per agentId", async () => {
const [r1, r2] = await Promise.all([p1, p2]);
expect(r1).toMatchObject({
cwd: "/tmp/project",
requestId: request.requestId,
requestId: request.message.requestId,
isGit: false,
});
expect(r2).toMatchObject({
cwd: "/tmp/project",
requestId: request.requestId,
requestId: request.message.requestId,
isGit: false,
});
@@ -182,21 +168,24 @@ test("dedupes in-flight checkout status requests per agentId", async () => {
const p3 = client.getCheckoutStatus("/tmp/project");
expect(mock.sent).toHaveLength(2);
const request2 = parseSentFrame(mock.sent[1]);
const request2 = JSON.parse(mock.sent[1]) as {
type: "session";
message: { type: "checkout_status_request"; cwd: string; requestId: string };
};
mock.triggerMessage(
JSON.stringify({
...response,
message: {
...response.message,
payload: { ...response.message.payload, requestId: request2.requestId },
payload: { ...response.message.payload, requestId: request2.message.requestId },
},
}),
);
await expect(p3).resolves.toMatchObject({
cwd: "/tmp/project",
requestId: request2.requestId,
requestId: request2.message.requestId,
isGit: false,
});
});
@@ -245,7 +234,7 @@ test("advertises reasoning_merge_enum in hello", async () => {
await connectPromise;
expect(mock.sent).toHaveLength(1);
expect(JSON.parse(assertStr(mock.sent[0]))).toEqual({
expect(JSON.parse(mock.sent[0] as string)).toEqual({
type: "hello",
clientId: "clsk_unit_test",
clientType: "cli",
@@ -300,7 +289,7 @@ test("listDirectory sends a list file explorer request and returns directory ent
const responsePromise = client.listDirectory("/tmp/project", "src", "req-list");
expect(JSON.parse(assertStr(mock.sent[0]))).toEqual({
expect(JSON.parse(String(mock.sent[0]))).toEqual({
type: "session",
message: {
type: "file_explorer_request",
@@ -370,7 +359,7 @@ test("readFile hides legacy base64 behind bytes", async () => {
const responsePromise = client.readFile("/tmp/project", "logo.png", "req-file");
expect(JSON.parse(assertStr(mock.sent[0]))).toEqual({
expect(JSON.parse(String(mock.sent[0]))).toEqual({
type: "session",
message: {
type: "file_explorer_request",
@@ -435,7 +424,7 @@ test("readFile resolves from binary file frames when the daemon supports them",
const responsePromise = client.readFile("/tmp/project", "logo.png", "req-binary");
expect(JSON.parse(assertStr(mock.sent[0]))).toEqual({
expect(JSON.parse(String(mock.sent[0]))).toEqual({
type: "session",
message: {
type: "file_explorer_request",
@@ -586,8 +575,15 @@ test("sends create_agent_request with string workspace ids", async () => {
});
expect(mock.sent).toHaveLength(1);
const request = parseSentFrame(mock.sent[0]);
expect(request).toEqual(
const request = JSON.parse(String(mock.sent[0])) as {
type: "session";
message: {
type: "create_agent_request";
requestId: string;
workspaceId: string;
};
};
expect(request.message).toEqual(
expect.objectContaining({
type: "create_agent_request",
workspaceId: "ws-feature-a",
@@ -599,7 +595,7 @@ test("sends create_agent_request with string workspace ids", async () => {
type: "status",
payload: {
status: "agent_create_failed",
requestId: request.requestId,
requestId: request.message.requestId,
error: "compat test sentinel",
},
}),
@@ -643,8 +639,15 @@ test("sends structured attachments with create_agent_request", async () => {
});
expect(mock.sent).toHaveLength(1);
const request = parseSentFrame(mock.sent[0]);
expect(request.attachments).toEqual([
const request = JSON.parse(String(mock.sent[0])) as {
type: "session";
message: {
type: "create_agent_request";
requestId: string;
attachments: Array<{ type: string; mimeType: string; number: number }>;
};
};
expect(request.message.attachments).toEqual([
{
type: "github_pr",
mimeType: "application/github-pr",
@@ -661,7 +664,7 @@ test("sends structured attachments with create_agent_request", async () => {
type: "status",
payload: {
status: "agent_create_failed",
requestId: request.requestId,
requestId: request.message.requestId,
error: "attachment test sentinel",
},
}),
@@ -701,8 +704,21 @@ test("sends worktree base-ref fields in create_agent_request git options", async
});
expect(mock.sent).toHaveLength(1);
const request = parseSentFrame(mock.sent[0]);
expect(request.git).toEqual({
const request = JSON.parse(String(mock.sent[0])) as {
type: "session";
message: {
type: "create_agent_request";
requestId: string;
git: {
createWorktree: boolean;
worktreeSlug: string;
refName: string;
action: string;
githubPrNumber: number;
};
};
};
expect(request.message.git).toEqual({
createWorktree: true,
worktreeSlug: "review-pr-123",
refName: "feature/worktree-base-ref",
@@ -715,7 +731,7 @@ test("sends worktree base-ref fields in create_agent_request git options", async
type: "status",
payload: {
status: "agent_create_failed",
requestId: request.requestId,
requestId: request.message.requestId,
error: "git ref fields sentinel",
},
}),
@@ -751,7 +767,7 @@ test("omitting create_agent_request worktree base-ref fields preserves legacy wi
},
});
expect(assertStr(mock.sent[0])).toBe(
expect(String(mock.sent[0])).toBe(
JSON.stringify({
type: "session",
message: {
@@ -818,11 +834,17 @@ test("sends structured first-agent context attachments with create_paseo_worktre
});
expect(mock.sent).toHaveLength(1);
const request = parseSentFrame(mock.sent[0]);
const firstAgentContext = z
.object({ attachments: z.array(z.unknown()) })
.parse(request.firstAgentContext);
expect(firstAgentContext.attachments).toEqual([
const request = JSON.parse(String(mock.sent[0])) as {
type: "session";
message: {
type: "create_paseo_worktree_request";
requestId: string;
firstAgentContext: {
attachments: Array<{ type: string; mimeType: string; number: number }>;
};
};
};
expect(request.message.firstAgentContext.attachments).toEqual([
{
type: "github_pr",
mimeType: "application/github-pr",
@@ -836,7 +858,7 @@ test("sends structured first-agent context attachments with create_paseo_worktre
wrapSessionMessage({
type: "create_paseo_worktree_response",
payload: {
requestId: request.requestId,
requestId: request.message.requestId,
workspace: null,
error: "worktree attachment sentinel",
setupTerminalId: null,
@@ -845,7 +867,7 @@ test("sends structured first-agent context attachments with create_paseo_worktre
);
await expect(createPromise).resolves.toEqual({
requestId: request.requestId,
requestId: request.message.requestId,
workspace: null,
error: "worktree attachment sentinel",
setupTerminalId: null,
@@ -881,8 +903,19 @@ test("sends worktree base-ref fields in create_paseo_worktree_request", async ()
);
expect(mock.sent).toHaveLength(1);
const request = parseSentFrame(mock.sent[0]);
expect(request).toEqual({
const request = JSON.parse(String(mock.sent[0])) as {
type: "session";
message: {
type: "create_paseo_worktree_request";
requestId: string;
cwd: string;
worktreeSlug: string;
refName: string;
action: string;
githubPrNumber: number;
};
};
expect(request.message).toEqual({
type: "create_paseo_worktree_request",
cwd: "/tmp/project",
worktreeSlug: "review-pr-123",
@@ -896,7 +929,7 @@ test("sends worktree base-ref fields in create_paseo_worktree_request", async ()
wrapSessionMessage({
type: "create_paseo_worktree_response",
payload: {
requestId: request.requestId,
requestId: request.message.requestId,
workspace: null,
error: "worktree ref fields sentinel",
setupTerminalId: null,
@@ -905,7 +938,7 @@ test("sends worktree base-ref fields in create_paseo_worktree_request", async ()
);
await expect(createPromise).resolves.toEqual({
requestId: request.requestId,
requestId: request.message.requestId,
workspace: null,
error: "worktree ref fields sentinel",
setupTerminalId: null,
@@ -937,7 +970,7 @@ test("omitting create_paseo_worktree_request worktree base-ref fields preserves
"req-worktree-legacy",
);
expect(assertStr(mock.sent[0])).toBe(
expect(String(mock.sent[0])).toBe(
JSON.stringify({
type: "session",
message: {
@@ -998,8 +1031,14 @@ test("sends explicit shutdown_server_request via shutdownServer", async () => {
const promise = lifecycleClient.shutdownServer("req-shutdown-1");
expect(mock.sent).toHaveLength(1);
const request = parseSentFrame(mock.sent[0]);
expect(request).toEqual({
const request = JSON.parse(mock.sent[0]) as {
type: "session";
message: {
type: string;
requestId: string;
};
};
expect(request.message).toEqual({
type: "shutdown_server_request",
requestId: "req-shutdown-1",
});
@@ -1042,8 +1081,15 @@ test("restartServer remains restart-only and sends restart_server_request", asyn
const promise = client.restartServer("settings_update", "req-restart-1");
expect(mock.sent).toHaveLength(1);
const request = parseSentFrame(mock.sent[0]);
expect(request).toEqual({
const request = JSON.parse(mock.sent[0]) as {
type: "session";
message: {
type: string;
reason?: string;
requestId: string;
};
};
expect(request.message).toEqual({
type: "restart_server_request",
reason: "settings_update",
requestId: "req-restart-1",
@@ -1096,9 +1142,7 @@ test("transitions out of connecting when connect timeout elapses", async () => {
expect(result.ok).toBe(false);
if (!result.ok) {
expect(result.error).toBeInstanceOf(Error);
if (result.error instanceof Error) {
expect(result.error.message).toContain("Connection timed out");
}
expect((result.error as Error).message).toContain("Connection timed out");
}
expect(client.getConnectionState().status).toBe("disconnected");
} finally {
@@ -1190,14 +1234,12 @@ test("logs configured runtime generation in connection transition events", async
mock.triggerOpen();
await connectPromise;
const transitionPayloads = logger.debug.mock.calls.filter(
([, message]) => message === "DaemonClientTransition",
);
const transitionPayloads = logger.debug.mock.calls
.filter(([, message]) => message === "DaemonClientTransition")
.map(([payload]) => payload as { generation?: number | null });
expect(transitionPayloads.length).toBeGreaterThan(0);
for (const [payload] of transitionPayloads) {
expect(
z.object({ generation: z.number().nullable().optional() }).parse(payload).generation,
).toBe(7);
for (const payload of transitionPayloads) {
expect(payload.generation).toBe(7);
}
});
@@ -1225,11 +1267,20 @@ test("subscribes to checkout diff updates via RPC handshake", async () => {
);
expect(mock.sent).toHaveLength(1);
const request = parseSentFrame(mock.sent[0]);
expect(request.type).toBe("subscribe_checkout_diff_request");
expect(request.subscriptionId).toBe("checkout-sub-1");
expect(request.cwd).toBe("/tmp/project");
expect(request.compare).toEqual({ mode: "uncommitted" });
const request = JSON.parse(mock.sent[0]) as {
type: "session";
message: {
type: "subscribe_checkout_diff_request";
subscriptionId: string;
cwd: string;
compare: { mode: "uncommitted" | "base"; baseRef?: string };
requestId: string;
};
};
expect(request.message.type).toBe("subscribe_checkout_diff_request");
expect(request.message.subscriptionId).toBe("checkout-sub-1");
expect(request.message.cwd).toBe("/tmp/project");
expect(request.message.compare).toEqual({ mode: "uncommitted" });
mock.triggerMessage(
JSON.stringify({
@@ -1241,7 +1292,7 @@ test("subscribes to checkout diff updates via RPC handshake", async () => {
cwd: "/tmp/project",
files: [],
error: null,
requestId: request.requestId,
requestId: request.message.requestId,
},
},
}),
@@ -1252,7 +1303,7 @@ test("subscribes to checkout diff updates via RPC handshake", async () => {
cwd: "/tmp/project",
files: [],
error: null,
requestId: request.requestId,
requestId: request.message.requestId,
});
});
@@ -1276,10 +1327,19 @@ test("getCheckoutDiff uses one-shot subscription protocol", async () => {
const promise = client.getCheckoutDiff("/tmp/project", { mode: "base", baseRef: "main" });
expect(mock.sent).toHaveLength(1);
const subscribeRequest = parseSentFrame(mock.sent[0]);
expect(subscribeRequest.type).toBe("subscribe_checkout_diff_request");
expect(subscribeRequest.cwd).toBe("/tmp/project");
expect(subscribeRequest.compare).toEqual({
const subscribeRequest = JSON.parse(mock.sent[0]) as {
type: "session";
message: {
type: "subscribe_checkout_diff_request";
subscriptionId: string;
cwd: string;
compare: { mode: "uncommitted" | "base"; baseRef?: string };
requestId: string;
};
};
expect(subscribeRequest.message.type).toBe("subscribe_checkout_diff_request");
expect(subscribeRequest.message.cwd).toBe("/tmp/project");
expect(subscribeRequest.message.compare).toEqual({
mode: "base",
baseRef: "main",
});
@@ -1290,11 +1350,11 @@ test("getCheckoutDiff uses one-shot subscription protocol", async () => {
message: {
type: "subscribe_checkout_diff_response",
payload: {
subscriptionId: subscribeRequest.subscriptionId,
subscriptionId: subscribeRequest.message.subscriptionId,
cwd: "/tmp/project",
files: [],
error: null,
requestId: subscribeRequest.requestId,
requestId: subscribeRequest.message.requestId,
},
},
}),
@@ -1304,13 +1364,19 @@ test("getCheckoutDiff uses one-shot subscription protocol", async () => {
cwd: "/tmp/project",
files: [],
error: null,
requestId: subscribeRequest.requestId,
requestId: subscribeRequest.message.requestId,
});
expect(mock.sent).toHaveLength(2);
const unsubscribeRequest = parseSentFrame(mock.sent[1]);
expect(unsubscribeRequest.type).toBe("unsubscribe_checkout_diff_request");
expect(unsubscribeRequest.subscriptionId).toBe(subscribeRequest.subscriptionId);
const unsubscribeRequest = JSON.parse(mock.sent[1]) as {
type: "session";
message: {
type: "unsubscribe_checkout_diff_request";
subscriptionId: string;
};
};
expect(unsubscribeRequest.message.type).toBe("unsubscribe_checkout_diff_request");
expect(unsubscribeRequest.message.subscriptionId).toBe(subscribeRequest.message.subscriptionId);
});
test("requests branch suggestions via RPC", async () => {
@@ -1336,12 +1402,21 @@ test("requests branch suggestions via RPC", async () => {
);
expect(mock.sent).toHaveLength(1);
const request = parseSentFrame(mock.sent[0]);
expect(request.type).toBe("branch_suggestions_request");
expect(request.cwd).toBe("/tmp/project");
expect(request.query).toBe("mai");
expect(request.limit).toBe(5);
expect(request.requestId).toBe("req-branches");
const request = JSON.parse(mock.sent[0]) as {
type: "session";
message: {
type: "branch_suggestions_request";
cwd: string;
query?: string;
limit?: number;
requestId: string;
};
};
expect(request.message.type).toBe("branch_suggestions_request");
expect(request.message.cwd).toBe("/tmp/project");
expect(request.message.query).toBe("mai");
expect(request.message.limit).toBe(5);
expect(request.message.requestId).toBe("req-branches");
mock.triggerMessage(
JSON.stringify({
@@ -1383,8 +1458,11 @@ test("reads project config via correlated RPC", async () => {
const promise = client.readProjectConfig("/repo/app", "read-project-config-1");
expect(mock.sent).toHaveLength(1);
const request = parseSentFrame(mock.sent[0]);
expect(request).toEqual({
const request = JSON.parse(mock.sent[0]) as {
type: "session";
message: { type: "read_project_config_request"; requestId: string; repoRoot: string };
};
expect(request.message).toEqual({
type: "read_project_config_request",
requestId: "read-project-config-1",
repoRoot: "/repo/app",
@@ -1436,8 +1514,17 @@ test("writes project config via correlated RPC and returns inline failures", asy
});
expect(mock.sent).toHaveLength(1);
const request = parseSentFrame(mock.sent[0]);
expect(request).toEqual({
const request = JSON.parse(mock.sent[0]) as {
type: "session";
message: {
type: "write_project_config_request";
requestId: string;
repoRoot: string;
config: unknown;
expectedRevision: unknown;
};
};
expect(request.message).toEqual({
type: "write_project_config_request",
requestId: "write-project-config-1",
repoRoot: "/repo/app",
@@ -1500,14 +1587,25 @@ test("requests directory suggestions via RPC", async () => {
);
expect(mock.sent).toHaveLength(1);
const request = parseSentFrame(mock.sent[0]);
expect(request.type).toBe("directory_suggestions_request");
expect(request.query).toBe("proj");
expect(request.cwd).toBe("/tmp/project");
expect(request.includeFiles).toBe(true);
expect(request.includeDirectories).toBe(true);
expect(request.limit).toBe(10);
expect(request.requestId).toBe("req-directories");
const request = JSON.parse(mock.sent[0]) as {
type: "session";
message: {
type: "directory_suggestions_request";
query: string;
cwd?: string;
includeFiles?: boolean;
includeDirectories?: boolean;
limit?: number;
requestId: string;
};
};
expect(request.message.type).toBe("directory_suggestions_request");
expect(request.message.query).toBe("proj");
expect(request.message.cwd).toBe("/tmp/project");
expect(request.message.includeFiles).toBe(true);
expect(request.message.includeDirectories).toBe(true);
expect(request.message.limit).toBe(10);
expect(request.message.requestId).toBe("req-directories");
mock.triggerMessage(
JSON.stringify({
@@ -1556,12 +1654,21 @@ test("requests checkout merge from base via RPC", async () => {
);
expect(mock.sent).toHaveLength(1);
const request = parseSentFrame(mock.sent[0]);
expect(request.type).toBe("checkout_merge_from_base_request");
expect(request.cwd).toBe("/tmp/project");
expect(request.baseRef).toBe("main");
expect(request.requireCleanTarget).toBe(true);
expect(request.requestId).toBe("req-merge-from-base");
const request = JSON.parse(mock.sent[0]) as {
type: "session";
message: {
type: "checkout_merge_from_base_request";
cwd: string;
baseRef?: string;
requireCleanTarget?: boolean;
requestId: string;
};
};
expect(request.message.type).toBe("checkout_merge_from_base_request");
expect(request.message.cwd).toBe("/tmp/project");
expect(request.message.baseRef).toBe("main");
expect(request.message.requireCleanTarget).toBe(true);
expect(request.message.requestId).toBe("req-merge-from-base");
mock.triggerMessage(
JSON.stringify({
@@ -1606,10 +1713,17 @@ test("requests checkout pull via RPC", async () => {
const promise = client.checkoutPull("/tmp/project", "req-pull");
expect(mock.sent).toHaveLength(1);
const request = parseSentFrame(mock.sent[0]);
expect(request.type).toBe("checkout_pull_request");
expect(request.cwd).toBe("/tmp/project");
expect(request.requestId).toBe("req-pull");
const request = JSON.parse(mock.sent[0]) as {
type: "session";
message: {
type: "checkout_pull_request";
cwd: string;
requestId: string;
};
};
expect(request.message.type).toBe("checkout_pull_request");
expect(request.message.cwd).toBe("/tmp/project");
expect(request.message.requestId).toBe("req-pull");
mock.triggerMessage(
JSON.stringify({
@@ -1663,13 +1777,22 @@ test("resubscribes checkout diff streams after reconnect", async () => {
await connectPromise;
expect(mock.sent).toHaveLength(1);
const request = parseSentFrame(mock.sent[0]);
expect(request.type).toBe("subscribe_checkout_diff_request");
expect(request.subscriptionId).toBe("checkout-sub-1");
expect(request.cwd).toBe("/tmp/project");
expect(request.compare).toEqual({ mode: "base", baseRef: "main" });
expect(typeof request.requestId).toBe("string");
expect(z.string().parse(request.requestId).length).toBeGreaterThan(0);
const request = JSON.parse(mock.sent[0]) as {
type: "session";
message: {
type: "subscribe_checkout_diff_request";
subscriptionId: string;
cwd: string;
compare: { mode: "uncommitted" | "base"; baseRef?: string };
requestId: string;
};
};
expect(request.message.type).toBe("subscribe_checkout_diff_request");
expect(request.message.subscriptionId).toBe("checkout-sub-1");
expect(request.message.cwd).toBe("/tmp/project");
expect(request.message.compare).toEqual({ mode: "base", baseRef: "main" });
expect(typeof request.message.requestId).toBe("string");
expect(request.message.requestId.length).toBeGreaterThan(0);
});
test("fetches agents via RPC with filters, sort, and pagination", async () => {
@@ -1700,14 +1823,27 @@ test("fetches agents via RPC with filters, sort, and pagination", async () => {
});
expect(mock.sent).toHaveLength(1);
const request = parseSentFrame(mock.sent[0]);
expect(request.type).toBe("fetch_agents_request");
expect(request.sort).toEqual([
const request = JSON.parse(mock.sent[0]) as {
type: "session";
message: {
type: "fetch_agents_request";
requestId: string;
filter?: { labels?: Record<string, string> };
sort?: Array<{
key: "status_priority" | "created_at" | "updated_at" | "title";
direction: "asc" | "desc";
}>;
page?: { limit: number; cursor?: string };
subscribe?: { subscriptionId?: string };
};
};
expect(request.message.type).toBe("fetch_agents_request");
expect(request.message.sort).toEqual([
{ key: "status_priority", direction: "asc" },
{ key: "created_at", direction: "desc" },
]);
expect(request.page).toEqual({ limit: 25, cursor: "cursor-1" });
expect(request.subscribe).toEqual({ subscriptionId: "sub-1" });
expect(request.message.page).toEqual({ limit: 25, cursor: "cursor-1" });
expect(request.message.subscribe).toEqual({ subscriptionId: "sub-1" });
mock.triggerMessage(
JSON.stringify({
@@ -1715,7 +1851,7 @@ test("fetches agents via RPC with filters, sort, and pagination", async () => {
message: {
type: "fetch_agents_response",
payload: {
requestId: request.requestId,
requestId: request.message.requestId,
subscriptionId: "sub-1",
entries: [],
pageInfo: {
@@ -1729,7 +1865,7 @@ test("fetches agents via RPC with filters, sort, and pagination", async () => {
);
await expect(promise).resolves.toEqual({
requestId: request.requestId,
requestId: request.message.requestId,
subscriptionId: "sub-1",
entries: [],
pageInfo: {
@@ -1763,8 +1899,15 @@ test("sends active-scoped fetch_agents_request", async () => {
});
expect(mock.sent).toHaveLength(1);
const request = parseSentFrame(mock.sent[0]);
expect(request).toMatchObject({
const request = JSON.parse(String(mock.sent[0])) as {
type: "session";
message: {
type: "fetch_agents_request";
requestId: string;
scope?: "active";
};
};
expect(request.message).toMatchObject({
type: "fetch_agents_request",
scope: "active",
});
@@ -1775,7 +1918,7 @@ test("sends active-scoped fetch_agents_request", async () => {
message: {
type: "fetch_agents_response",
payload: {
requestId: request.requestId,
requestId: request.message.requestId,
entries: [],
pageInfo: {
nextCursor: null,
@@ -1788,7 +1931,7 @@ test("sends active-scoped fetch_agents_request", async () => {
);
await expect(promise).resolves.toMatchObject({
requestId: request.requestId,
requestId: request.message.requestId,
entries: [],
});
});
@@ -1816,9 +1959,16 @@ test("fetches paginated agent history separately from active agents", async () =
});
expect(mock.sent).toHaveLength(1);
const request = parseSentFrame(mock.sent[0]);
expect(request.type).toBe("fetch_agent_history_request");
expect(request.page).toEqual({ limit: 25, cursor: "cursor-1" });
const request = JSON.parse(String(mock.sent[0])) as {
type: "session";
message: {
type: "fetch_agent_history_request";
requestId: string;
page?: { limit: number; cursor?: string };
};
};
expect(request.message.type).toBe("fetch_agent_history_request");
expect(request.message.page).toEqual({ limit: 25, cursor: "cursor-1" });
mock.triggerMessage(
JSON.stringify({
@@ -1826,7 +1976,7 @@ test("fetches paginated agent history separately from active agents", async () =
message: {
type: "fetch_agent_history_response",
payload: {
requestId: request.requestId,
requestId: request.message.requestId,
entries: [],
pageInfo: {
nextCursor: null,
@@ -1839,7 +1989,7 @@ test("fetches paginated agent history separately from active agents", async () =
);
await expect(promise).resolves.toEqual({
requestId: request.requestId,
requestId: request.message.requestId,
entries: [],
pageInfo: {
nextCursor: null,
@@ -1887,9 +2037,7 @@ test("uses server-provided dictation finish timeout budget", async () => {
await vi.advanceTimersByTimeAsync(5_101);
const error = await finishError;
expect(error).toBeInstanceOf(Error);
if (error instanceof Error) {
expect(error.message).toContain("Timeout waiting for dictation finalization (5100ms)");
}
expect((error as Error).message).toContain("Timeout waiting for dictation finalization (5100ms)");
vi.useRealTimers();
});
@@ -1999,8 +2147,11 @@ test("lists available providers via RPC", async () => {
const promise = client.listAvailableProviders();
expect(mock.sent).toHaveLength(1);
const request = parseSentFrame(mock.sent[0]);
expect(request.type).toBe("list_available_providers_request");
const request = JSON.parse(mock.sent[0]) as {
type: "session";
message: { type: "list_available_providers_request"; requestId: string };
};
expect(request.message.type).toBe("list_available_providers_request");
mock.triggerMessage(
JSON.stringify({
@@ -2014,7 +2165,7 @@ test("lists available providers via RPC", async () => {
],
error: null,
fetchedAt: "2026-02-12T00:00:00.000Z",
requestId: request.requestId,
requestId: request.message.requestId,
},
},
}),
@@ -2027,7 +2178,7 @@ test("lists available providers via RPC", async () => {
],
error: null,
fetchedAt: "2026-02-12T00:00:00.000Z",
requestId: request.requestId,
requestId: request.message.requestId,
});
});
@@ -2059,10 +2210,24 @@ test("lists commands with draft config via RPC", async () => {
});
expect(mock.sent).toHaveLength(1);
const request = parseSentFrame(mock.sent[0]);
expect(request.type).toBe("list_commands_request");
expect(request.agentId).toBe("__new_agent__");
expect(request.draftConfig).toEqual({
const request = JSON.parse(mock.sent[0]) as {
type: "session";
message: {
type: "list_commands_request";
agentId: string;
draftConfig?: {
provider: string;
cwd: string;
modeId?: string;
model?: string;
thinkingOptionId?: string;
};
requestId: string;
};
};
expect(request.message.type).toBe("list_commands_request");
expect(request.message.agentId).toBe("__new_agent__");
expect(request.message.draftConfig).toEqual({
provider: "codex",
cwd: "/tmp/project",
modeId: "bypassPermissions",
@@ -2079,7 +2244,7 @@ test("lists commands with draft config via RPC", async () => {
agentId: "__new_agent__",
commands: [{ name: "help", description: "Show help", argumentHint: "" }],
error: null,
requestId: request.requestId,
requestId: request.message.requestId,
},
},
}),
@@ -2089,7 +2254,7 @@ test("lists commands with draft config via RPC", async () => {
agentId: "__new_agent__",
commands: [{ name: "help", description: "Show help", argumentHint: "" }],
error: null,
requestId: request.requestId,
requestId: request.message.requestId,
});
});
@@ -2113,11 +2278,19 @@ test("lists commands with legacy requestId signature via RPC", async () => {
const promise = client.listCommands("agent-1", "req-legacy");
expect(mock.sent).toHaveLength(1);
const request = parseSentFrame(mock.sent[0]);
expect(request.type).toBe("list_commands_request");
expect(request.agentId).toBe("agent-1");
expect(request.requestId).toBe("req-legacy");
expect(request.draftConfig).toBeUndefined();
const request = JSON.parse(mock.sent[0]) as {
type: "session";
message: {
type: "list_commands_request";
agentId: string;
draftConfig?: unknown;
requestId: string;
};
};
expect(request.message.type).toBe("list_commands_request");
expect(request.message.agentId).toBe("agent-1");
expect(request.message.requestId).toBe("req-legacy");
expect(request.message.draftConfig).toBeUndefined();
mock.triggerMessage(
JSON.stringify({
@@ -2515,17 +2688,25 @@ test("parses canonical agent_stream tool_call payloads without crashing", async
unsubscribe();
expect(received).toHaveLength(1);
expect(received[0]).toMatchObject({
const streamMsg = received[0] as {
payload: {
event: {
type: "timeline";
item: {
status: "running",
error: null,
detail: { type: "shell" },
},
},
},
});
type: "tool_call";
status: string;
error: unknown;
detail: {
type: string;
};
};
};
};
};
expect(streamMsg.payload.event.item.status).toBe("running");
expect(streamMsg.payload.event.item.error).toBeNull();
expect(streamMsg.payload.event.item.detail.type).toBe("shell");
expect(logger.warn).not.toHaveBeenCalled();
});
@@ -2651,20 +2832,28 @@ test("parses canonical fetch_agent_timeline_response payloads without crashing",
unsubscribe();
expect(received).toHaveLength(1);
expect(received[0]).toMatchObject({
const timelineMsg = received[0] as {
payload: {
entries: [
{
item: {
type: "tool_call",
status: "running",
error: null,
detail: { type: "shell" },
},
},
],
},
});
entries: Array<{
item: {
type: "tool_call";
status: string;
error: unknown;
detail: {
type: string;
};
};
}>;
};
};
const firstEntry = timelineMsg.payload.entries[0];
expect(firstEntry?.item.type).toBe("tool_call");
if (firstEntry?.item.type === "tool_call") {
expect(firstEntry.item.status).toBe("running");
expect(firstEntry.item.error).toBeNull();
expect(firstEntry.item.detail.type).toBe("shell");
}
expect(logger.warn).not.toHaveBeenCalled();
});
@@ -2761,14 +2950,14 @@ test("sends subscribe/unsubscribe terminals messages", async () => {
client.unsubscribeTerminals({ cwd: "/tmp/project" });
expect(mock.sent).toHaveLength(2);
expect(JSON.parse(assertStr(mock.sent[0]))).toEqual({
expect(JSON.parse(String(mock.sent[0]))).toEqual({
type: "session",
message: {
type: "subscribe_terminals_request",
cwd: "/tmp/project",
},
});
expect(JSON.parse(assertStr(mock.sent[1]))).toEqual({
expect(JSON.parse(String(mock.sent[1]))).toEqual({
type: "session",
message: {
type: "unsubscribe_terminals_request",
@@ -2852,7 +3041,7 @@ test("sends close_items_request and resolves close_items_response", async () =>
"req-close-items",
);
expect(JSON.parse(assertStr(mock.sent[0]))).toEqual({
expect(JSON.parse(String(mock.sent[0]))).toEqual({
type: "session",
message: {
type: "close_items_request",
@@ -2902,10 +3091,18 @@ test("waitForFinish with timeout=0 omits timeoutMs and has no client deadline",
const waitPromise = client.waitForFinish("agent-wait-zero-timeout", 0);
expect(mock.sent).toHaveLength(1);
const request = parseSentFrame(mock.sent[0]);
expect(request.type).toBe("wait_for_finish_request");
expect(request.agentId).toBe("agent-wait-zero-timeout");
expect(request).not.toHaveProperty("timeoutMs");
const request = JSON.parse(String(mock.sent[0])) as {
type: "session";
message: {
type: "wait_for_finish_request";
requestId: string;
agentId: string;
timeoutMs?: number;
};
};
expect(request.message.type).toBe("wait_for_finish_request");
expect(request.message.agentId).toBe("agent-wait-zero-timeout");
expect(request.message).not.toHaveProperty("timeoutMs");
const settled = vi.fn();
void waitPromise.then(
@@ -2920,7 +3117,7 @@ test("waitForFinish with timeout=0 omits timeoutMs and has no client deadline",
wrapSessionMessage({
type: "wait_for_finish_response",
payload: {
requestId: request.requestId,
requestId: request.message.requestId,
status: "idle",
final: null,
error: null,
+7 -13
View File
@@ -601,8 +601,8 @@ export interface WaitForFinishResult {
interface Waiter<T> {
predicate: (msg: SessionOutboundMessage) => T | null;
resolve(value: T): void;
reject(error: Error): void;
resolve: (value: T) => void;
reject: (error: Error) => void;
timeoutHandle: ReturnType<typeof setTimeout> | null;
}
@@ -1160,7 +1160,7 @@ export class DaemonClient {
}
const type = arg1;
const handler = arg2!;
const handler = arg2 as (message: SessionOutboundMessage) => void;
if (!this.messageHandlers.has(type)) {
this.messageHandlers.set(type, new Set());
@@ -4043,13 +4043,7 @@ export class DaemonClient {
const parsed = WSOutboundMessageSchema.safeParse(parsedJson);
if (!parsed.success) {
const msgType =
parsedJson != null &&
typeof parsedJson === "object" &&
"type" in parsedJson &&
typeof parsedJson.type === "string"
? parsedJson.type
: "unknown";
const msgType = (parsedJson as { type?: string })?.type ?? "unknown";
this.logger.warn({ msgType, error: parsed.error.message }, "Message validation failed");
return;
}
@@ -4411,7 +4405,7 @@ export class DaemonClient {
timeout > 0
? setTimeout(() => {
if (waiter) {
this.waiters.delete(waiter);
this.waiters.delete(waiter as Waiter<unknown>);
}
wrappedReject(timeoutError);
}, timeout)
@@ -4423,7 +4417,7 @@ export class DaemonClient {
reject: wrappedReject,
timeoutHandle,
};
this.waiters.add(waiter);
this.waiters.add(waiter as Waiter<unknown>);
});
const cancel = (error: Error) => {
@@ -4432,7 +4426,7 @@ export class DaemonClient {
}
if (waiter) {
this.waiters.delete(waiter);
this.waiters.delete(waiter as Waiter<unknown>);
if (waiter.timeoutHandle) {
clearTimeout(waiter.timeoutHandle);
}
@@ -1647,7 +1647,7 @@ export class AgentManager {
{ agentId, foregroundTurnId },
"cancelAgentRun: foreground turn still active after timeout, force-canceling",
);
void this.dispatchSessionEvent(agent, {
this.dispatchSessionEvent(agent, {
type: "turn_canceled",
provider: agent.provider,
reason: "interrupted",
@@ -22,7 +22,7 @@ interface McpToolResult {
}
interface McpClient {
callTool: (input: { name: string; args?: StructuredContent }) => Promise<McpToolResult>;
callTool: (input: { name: string; args?: StructuredContent }) => Promise<unknown>;
close: () => Promise<void>;
}
@@ -61,29 +61,23 @@ function getStructuredContent(result: McpToolResult): StructuredContent | null {
}
const content = result.content?.[0];
if (content && typeof content === "object" && "structuredContent" in content) {
if (content.structuredContent) return content.structuredContent;
const structured = (content as { structuredContent?: StructuredContent }).structuredContent;
if (structured) return structured;
}
if (content && typeof content === "object") {
return content;
return content as StructuredContent;
}
return null;
}
async function createMcpClient(url: string): Promise<McpClient> {
const transport = new StreamableHTTPClientTransport(new URL(url));
const rawClient = await experimental_createMCPClient({ transport });
const boundCallTool: McpClient["callTool"] = Reflect.get(rawClient, "callTool").bind(rawClient);
return { callTool: boundCallTool, close: () => rawClient.close() };
}
async function waitForAgentCompletion(options: {
client: McpClient;
agentId: string;
}): Promise<void> {
const waitResult = await options.client.callTool({
const waitResult = (await options.client.callTool({
name: "wait_for_agent",
args: { agentId: options.agentId },
});
})) as McpToolResult;
const payload = getStructuredContent(waitResult);
if (!payload) {
throw new Error("wait_for_agent returned no structured payload");
@@ -93,7 +87,7 @@ async function waitForAgentCompletion(options: {
}
const status = payload.status;
if (status === "running" || status === "initializing") {
throw new Error(`Agent still running after wait_for_agent (status=${status})`);
throw new Error(`Agent still running after wait_for_agent (status=${String(status)})`);
}
}
@@ -119,7 +113,10 @@ describe("agent MCP end-to-end (offline)", () => {
const daemon = await createPaseoDaemon(daemonConfig, pino({ level: "silent" }));
await daemon.start();
const client = await createMcpClient(`http://127.0.0.1:${port}/mcp/agents`);
const transport = new StreamableHTTPClientTransport(
new URL(`http://127.0.0.1:${port}/mcp/agents`),
);
const client = (await experimental_createMCPClient({ transport })) as McpClient;
let agentId: string | null = null;
try {
@@ -132,7 +129,7 @@ describe("agent MCP end-to-end (offline)", () => {
"Do not respond before the command finishes.",
].join("\n");
const result = await client.callTool({
const result = (await client.callTool({
name: "create_agent",
args: {
cwd: agentCwd,
@@ -142,10 +139,10 @@ describe("agent MCP end-to-end (offline)", () => {
initialPrompt,
background: false,
},
});
})) as McpToolResult;
const payload = getStructuredContent(result);
agentId = typeof payload?.agentId === "string" ? payload.agentId : null;
agentId = (payload?.agentId as string | undefined) ?? null;
expect(agentId).toBeTruthy();
await waitForAgentCompletion({ client, agentId: agentId! });
@@ -189,7 +186,10 @@ describe("agent MCP end-to-end (offline)", () => {
const daemon = await createPaseoDaemon(daemonConfig, pino({ level: "silent" }));
await daemon.start();
const client = await createMcpClient(`http://127.0.0.1:${port}/mcp/agents`);
const transport = new StreamableHTTPClientTransport(
new URL(`http://127.0.0.1:${port}/mcp/agents`),
);
const client = (await experimental_createMCPClient({ transport })) as McpClient;
const disabledPaseoHome = await mkdtemp(path.join(os.tmpdir(), "paseo-home-disabled-"));
const disabledStaticDir = await mkdtemp(path.join(os.tmpdir(), "paseo-static-disabled-"));
@@ -210,12 +210,17 @@ describe("agent MCP end-to-end (offline)", () => {
const disabledDaemon = await createPaseoDaemon(disabledDaemonConfig, pino({ level: "silent" }));
await disabledDaemon.start();
const disabledClient = await createMcpClient(`http://127.0.0.1:${disabledPort}/mcp/agents`);
const disabledTransport = new StreamableHTTPClientTransport(
new URL(`http://127.0.0.1:${disabledPort}/mcp/agents`),
);
const disabledClient = (await experimental_createMCPClient({
transport: disabledTransport,
})) as McpClient;
let agentId: string | null = null;
let disabledAgentId: string | null = null;
try {
const result = await client.callTool({
const result = (await client.callTool({
name: "create_agent",
args: {
cwd: agentCwd,
@@ -225,9 +230,9 @@ describe("agent MCP end-to-end (offline)", () => {
initialPrompt: "reply with done and stop",
background: true,
},
});
})) as McpToolResult;
const payload = getStructuredContent(result);
agentId = typeof payload?.agentId === "string" ? payload.agentId : null;
agentId = (payload?.agentId as string | undefined) ?? null;
expect(agentId).toBeTruthy();
const injectedAgent = daemon.agentManager.getAgent(agentId!);
@@ -238,7 +243,7 @@ describe("agent MCP end-to-end (offline)", () => {
},
});
const disabledResult = await disabledClient.callTool({
const disabledResult = (await disabledClient.callTool({
name: "create_agent",
args: {
cwd: disabledAgentCwd,
@@ -248,10 +253,9 @@ describe("agent MCP end-to-end (offline)", () => {
initialPrompt: "reply with done and stop",
background: true,
},
});
})) as McpToolResult;
const disabledPayload = getStructuredContent(disabledResult);
disabledAgentId =
typeof disabledPayload?.agentId === "string" ? disabledPayload.agentId : null;
disabledAgentId = (disabledPayload?.agentId as string | undefined) ?? null;
expect(disabledAgentId).toBeTruthy();
const disabledAgent = disabledDaemon.agentManager.getAgent(disabledAgentId!);
@@ -297,7 +301,10 @@ describe("agent MCP end-to-end (offline)", () => {
const daemon = await createPaseoDaemon(daemonConfig, pino({ level: "silent" }));
await daemon.start();
const client = await createMcpClient(`http://127.0.0.1:${port}/mcp/agents`);
const transport = new StreamableHTTPClientTransport(
new URL(`http://127.0.0.1:${port}/mcp/agents`),
);
const client = (await experimental_createMCPClient({ transport })) as McpClient;
let agentId: string | null = null;
try {
@@ -332,7 +339,7 @@ describe("agent MCP end-to-end (offline)", () => {
stdio: "pipe",
});
const result = await withTimeout({
const result = (await withTimeout({
promise: client.callTool({
name: "create_agent",
args: {
@@ -348,12 +355,12 @@ describe("agent MCP end-to-end (offline)", () => {
}),
timeoutMs: 2500,
label: "create_agent should not block on setup",
});
})) as McpToolResult;
const payload = getStructuredContent(result);
agentId = typeof payload?.agentId === "string" ? payload.agentId : null;
agentId = (payload?.agentId as string | undefined) ?? null;
expect(agentId).toBeTruthy();
const worktreePath = typeof payload?.cwd === "string" ? payload.cwd : "";
const worktreePath = (payload?.cwd as string | undefined) ?? "";
expect(worktreePath).toContain(`${path.sep}worktrees${path.sep}`);
expect(existsSync(path.join(worktreePath, "setup-done.txt"))).toBe(false);
expect(existsSync(path.join(worktreePath, "dev-terminal.txt"))).toBe(false);
@@ -94,7 +94,7 @@ function fetchAfter(ctx: FetchContext): AgentTimelineFetchResult {
gap: false,
window,
hasOlder: selected[0].seq > minSeq,
hasNewer: lastSelected !== null && lastSelected !== undefined && lastSelected.seq < maxSeq,
hasNewer: Boolean(lastSelected && lastSelected.seq < maxSeq),
rows: selected.map(cloneRow),
};
}
@@ -5,7 +5,6 @@ import { mkdtemp, rm, writeFile } from "node:fs/promises";
import { afterAll, beforeAll, describe, expect, test } from "vitest";
import { experimental_createMCPClient } from "ai";
import { StreamableHTTPClientTransport } from "@modelcontextprotocol/sdk/client/streamableHttp.js";
import { z } from "zod";
import { AGENT_WAIT_TIMEOUT_MS } from "./mcp-shared.js";
import { createTestPaseoDaemon, type TestPaseoDaemon } from "../test-utils/paseo-daemon.js";
@@ -21,22 +20,10 @@ interface McpToolResult {
}
interface McpClient {
callTool: (input: { name: string; args?: StructuredContent }) => Promise<McpToolResult>;
callTool: (input: { name: string; args?: StructuredContent }) => Promise<unknown>;
close: () => Promise<void>;
}
function str(val: unknown): string {
return z.string().parse(val);
}
function recordArr(val: unknown): StructuredContent[] {
return z.array(z.record(z.unknown())).parse(val);
}
function strArrOptional(val: unknown): string[] | undefined {
return z.array(z.string()).optional().parse(val);
}
function formatHostForHttpUrl(host: string): string {
return host.includes(":") && !host.startsWith("[") ? `[${host}]` : host;
}
@@ -56,21 +43,20 @@ function getStructuredContent(result: McpToolResult): StructuredContent | null {
}
const content = result.content?.[0];
if (content && typeof content === "object" && "structuredContent" in content) {
if (content.structuredContent) {
return content.structuredContent;
const structured = (content as { structuredContent?: StructuredContent }).structuredContent;
if (structured) {
return structured;
}
}
if (content && typeof content === "object") {
return content;
return content as StructuredContent;
}
return null;
}
async function createMcpClient(url: string): Promise<McpClient> {
const transport = new StreamableHTTPClientTransport(new URL(url));
const rawClient = await experimental_createMCPClient({ transport });
const boundCallTool: McpClient["callTool"] = Reflect.get(rawClient, "callTool").bind(rawClient);
return { callTool: boundCallTool, close: () => rawClient.close() };
return (await experimental_createMCPClient({ transport })) as McpClient;
}
async function callToolStructured(
@@ -78,7 +64,7 @@ async function callToolStructured(
name: string,
args?: StructuredContent,
): Promise<StructuredContent> {
const result = await client.callTool({ name, args: args ?? {} });
const result = (await client.callTool({ name, args: args ?? {} })) as McpToolResult;
const payload = getStructuredContent(result);
if (!payload) {
throw new Error(`${name} returned no structured payload`);
@@ -92,14 +78,10 @@ async function expectToolError(
args: StructuredContent,
pattern: RegExp,
): Promise<void> {
const result = await client.callTool({ name, args });
const result = (await client.callTool({ name, args })) as McpToolResult;
expect(result.isError).toBe(true);
const contentItem = result.content?.[0];
const contentText: string | undefined =
contentItem != null && typeof contentItem === "object"
? Reflect.get(contentItem, "text")
: undefined;
expect(contentText ?? "").toMatch(pattern);
const content = result.content?.[0] as { text?: string } | undefined;
expect(content?.text ?? "").toMatch(pattern);
}
async function sleep(ms: number): Promise<void> {
@@ -136,7 +118,7 @@ async function makeCwd(prefix: string): Promise<string> {
}
async function createTopLevelAgent(args?: Partial<StructuredContent>): Promise<string> {
const cwd = typeof args?.cwd === "string" ? args.cwd : await makeCwd("agent-cwd");
const cwd = (args?.cwd as string | undefined) ?? (await makeCwd("agent-cwd"));
const payload = await callToolStructured(topLevelClient, "create_agent", {
cwd,
title: "Parity agent",
@@ -146,7 +128,7 @@ async function createTopLevelAgent(args?: Partial<StructuredContent>): Promise<s
background: true,
...args,
});
return str(payload.agentId);
return payload.agentId as string;
}
async function createChildAgent(args?: Partial<StructuredContent>): Promise<string> {
@@ -157,7 +139,7 @@ async function createChildAgent(args?: Partial<StructuredContent>): Promise<stri
background: true,
...args,
});
return str(payload.agentId);
return payload.agentId as string;
}
async function archiveAgentIfPresent(agentId: string | null | undefined): Promise<void> {
@@ -231,7 +213,7 @@ beforeAll(async () => {
mode: "bypassPermissions",
background: true,
});
parentAgentId = str(parentPayload.agentId);
parentAgentId = parentPayload.agentId as string;
agentScopedClient = await createMcpClient(
`http://127.0.0.1:${daemonHandle.port}/mcp/agents?callerAgentId=${parentAgentId}`,
@@ -380,10 +362,10 @@ describe("Suite B: Terminal Tools", () => {
const created = await callToolStructured(agentScopedClient, "create_terminal", {
name: "Parity terminal",
});
terminalId = str(created.id);
terminalId = created.id as string;
const listed = await callToolStructured(agentScopedClient, "list_terminals");
const terminals = recordArr(listed.terminals);
const terminals = listed.terminals as Array<StructuredContent>;
expect(terminals).toEqual(
expect.arrayContaining([
expect.objectContaining({
@@ -404,7 +386,7 @@ describe("Suite B: Terminal Tools", () => {
const created = await callToolStructured(agentScopedClient, "create_terminal", {
name: "Parity capture terminal",
});
terminalId = str(created.id);
terminalId = created.id as string;
await callToolStructured(agentScopedClient, "send_terminal_keys", {
terminalId,
@@ -422,7 +404,7 @@ describe("Suite B: Terminal Tools", () => {
terminalId,
scrollback: true,
});
const lines = strArrOptional(payload.lines) ?? [];
const lines = (payload.lines as string[] | undefined) ?? [];
return lines.some((line) => line.includes("hello")) ? payload : null;
},
});
@@ -439,7 +421,7 @@ describe("Suite B: Terminal Tools", () => {
const created = await callToolStructured(agentScopedClient, "create_terminal", {
name: "Parity kill terminal",
});
terminalId = str(created.id);
terminalId = created.id as string;
await callToolStructured(agentScopedClient, "kill_terminal", { terminalId });
terminalId = null;
@@ -450,11 +432,11 @@ describe("Suite B: Terminal Tools", () => {
label: "terminal removal",
check: async () => {
const payload = await callToolStructured(agentScopedClient, "list_terminals");
const terminals = recordArr(payload.terminals);
const terminals = payload.terminals as Array<StructuredContent>;
return terminals.some((terminal) => terminal.id === created.id) ? null : payload;
},
});
const terminals = recordArr(listed.terminals);
const terminals = listed.terminals as Array<StructuredContent>;
expect(terminals.some((terminal) => terminal.id === created.id)).toBe(false);
} finally {
await killTerminalIfPresent(terminalId);
@@ -480,10 +462,10 @@ describe("Suite C: Schedule Tools", () => {
every: "5m",
name: "Parity schedule list",
});
scheduleId = str(created.id);
scheduleId = created.id as string;
const listed = await callToolStructured(topLevelClient, "list_schedules");
const schedules = recordArr(listed.schedules);
const schedules = listed.schedules as Array<StructuredContent>;
expect(schedules).toEqual(
expect.arrayContaining([
expect.objectContaining({
@@ -506,7 +488,7 @@ describe("Suite C: Schedule Tools", () => {
name: "Parity provider schedule",
provider: "codex/gpt-5.4",
});
scheduleId = str(created.id);
scheduleId = created.id as string;
expect(created.target).toMatchObject({
type: "new-agent",
config: {
@@ -527,7 +509,7 @@ describe("Suite C: Schedule Tools", () => {
every: "5m",
name: "Parity inspect schedule",
});
scheduleId = str(created.id);
scheduleId = created.id as string;
const inspected = await callToolStructured(topLevelClient, "inspect_schedule", {
id: scheduleId,
@@ -551,7 +533,7 @@ describe("Suite C: Schedule Tools", () => {
every: "5m",
name: "Parity pause schedule",
});
scheduleId = str(created.id);
scheduleId = created.id as string;
await callToolStructured(topLevelClient, "pause_schedule", { id: scheduleId });
const paused = await callToolStructured(topLevelClient, "inspect_schedule", {
@@ -577,13 +559,13 @@ describe("Suite C: Schedule Tools", () => {
every: "5m",
name: "Parity delete schedule",
});
scheduleId = str(created.id);
scheduleId = created.id as string;
await callToolStructured(topLevelClient, "delete_schedule", { id: scheduleId });
scheduleId = null;
const listed = await callToolStructured(topLevelClient, "list_schedules");
const schedules = recordArr(listed.schedules);
const schedules = listed.schedules as Array<StructuredContent>;
expect(schedules.some((schedule) => schedule.id === created.id)).toBe(false);
} finally {
await deleteScheduleIfPresent(scheduleId);
@@ -599,7 +581,7 @@ describe("Suite C: Schedule Tools", () => {
name: "Parity self schedule",
target: "self",
});
scheduleId = str(created.id);
scheduleId = created.id as string;
expect(created.target).toMatchObject({
type: "agent",
agentId: parentAgentId,
@@ -617,7 +599,7 @@ describe("Suite C: Schedule Tools", () => {
every: "5m",
provider: "codex/gpt-5.4",
});
scheduleId = str(created.id);
scheduleId = created.id as string;
expect(created.target).toMatchObject({
type: "new-agent",
config: {
@@ -647,7 +629,7 @@ describe("Suite C: Schedule Tools", () => {
describe("Suite D: Provider Tools", () => {
test("list_providers returns providers", async () => {
const payload = await callToolStructured(topLevelClient, "list_providers");
const providers = recordArr(payload.providers);
const providers = payload.providers as Array<StructuredContent>;
expect(Array.isArray(providers)).toBe(true);
expect(providers.length).toBeGreaterThan(0);
expect(providers[0]).toEqual(
@@ -685,12 +667,12 @@ describe("Suite E: Worktree Tools", () => {
branchName,
baseBranch: "main",
});
worktreePath = str(created.worktreePath);
worktreePath = created.worktreePath as string;
const listed = await callToolStructured(topLevelClient, "list_worktrees", {
cwd: worktreeRepoCwd,
});
const worktrees = recordArr(listed.worktrees);
const worktrees = listed.worktrees as Array<StructuredContent>;
expect(worktrees).toEqual(
expect.arrayContaining([
expect.objectContaining({
@@ -713,7 +695,7 @@ describe("Suite E: Worktree Tools", () => {
branchName,
baseBranch: "main",
});
worktreePath = str(created.worktreePath);
worktreePath = created.worktreePath as string;
await callToolStructured(topLevelClient, "archive_worktree", {
cwd: worktreeRepoCwd,
@@ -724,7 +706,7 @@ describe("Suite E: Worktree Tools", () => {
const listed = await callToolStructured(topLevelClient, "list_worktrees", {
cwd: worktreeRepoCwd,
});
const worktrees = recordArr(listed.worktrees);
const worktrees = listed.worktrees as Array<StructuredContent>;
expect(worktrees.some((worktree) => worktree.path === created.worktreePath)).toBe(false);
} finally {
await archiveWorktreeIfPresent({ cwd: worktreeRepoCwd, worktreePath });
@@ -14,6 +14,7 @@ import { AgentListItemPayloadSchema, AgentSnapshotPayloadSchema } from "../../sh
import type { PersistedProjectRecord, PersistedWorkspaceRecord } from "../workspace-registry.js";
import type { CreateScheduleInput, StoredSchedule } from "../schedule/types.js";
import type { ScheduleService } from "../schedule/service.js";
import type { AgentProvider } from "./agent-sdk-types.js";
import type { WorkspaceGitService } from "../workspace-git-service.js";
import {
createPaseoWorktree as createPaseoWorktreeService,
@@ -47,12 +48,15 @@ interface RegisteredMcpTool {
}>;
}
interface McpServerInternals {
_registeredTools: Record<string, RegisteredMcpTool>;
}
function lookupTool(
server: Awaited<ReturnType<typeof createAgentMcpServer>>,
name: string,
): RegisteredMcpTool | undefined {
const tools: Record<string, RegisteredMcpTool> = Reflect.get(server, "_registeredTools");
return tools[name];
return (server as unknown as McpServerInternals)._registeredTools[name];
}
function registeredTool(
@@ -69,7 +73,7 @@ function registeredTool(
function agentsOf(response: {
structuredContent: LooseStructuredContent;
}): Array<Record<string, unknown>> {
return z.array(z.record(z.unknown())).parse(response.structuredContent.agents);
return response.structuredContent.agents as Array<Record<string, unknown>>;
}
type AgentManagerSpies = ReturnType<typeof buildAgentManagerSpies>;
@@ -732,7 +736,7 @@ describe("create_agent MCP tool", () => {
background: true,
});
const agentCwd = z.string().parse(spies.agentManager.createAgent.mock.calls[0]?.[0].cwd);
const agentCwd = spies.agentManager.createAgent.mock.calls[0]?.[0].cwd as string;
const initialBranch = execSync("git branch --show-current", { cwd: agentCwd, stdio: "pipe" })
.toString()
.trim();
@@ -804,7 +808,7 @@ describe("create_agent MCP tool", () => {
background: true,
});
const agentCwd = z.string().parse(spies.agentManager.createAgent.mock.calls[0]?.[0].cwd);
const agentCwd = spies.agentManager.createAgent.mock.calls[0]?.[0].cwd as string;
expect(
execSync("git branch --show-current", { cwd: agentCwd, stdio: "pipe" }).toString().trim(),
).toBe("existing-feature");
@@ -1607,9 +1611,7 @@ describe("agent snapshot MCP serialization", () => {
const server = await createAgentMcpServer({ agentManager, agentStorage, logger });
const tool = registeredTool(server, "list_agents");
const response = await tool.callback({});
const structured = z
.object({ agents: z.array(z.record(z.unknown())) })
.parse(response.structuredContent);
const structured = response.structuredContent as { agents: Array<Record<string, unknown>> };
expect(structured).toEqual({
agents: [
@@ -1657,7 +1659,7 @@ describe("agent snapshot MCP serialization", () => {
logger,
providerRegistry: {
claude: createProviderDefinition({}),
},
} as unknown as Record<AgentProvider, ProviderDefinition>,
});
const tool = registeredTool(server, "get_agent_status");
const response = await tool.callback({ agentId: "archived-agent" });
@@ -1717,7 +1719,7 @@ describe("agent snapshot MCP serialization", () => {
const server = await createAgentMcpServer({ agentManager, agentStorage, logger });
const tool = registeredTool(server, "get_agent_status");
const response = await tool.callback({ agentId: "full-detail-agent" });
const snapshot = z.record(z.unknown()).parse(response.structuredContent.snapshot);
const snapshot = response.structuredContent.snapshot as Record<string, unknown>;
const parsed = AgentSnapshotPayloadSchema.safeParse(snapshot);
if (!parsed.success) {
@@ -1788,7 +1790,7 @@ describe("agent snapshot MCP serialization", () => {
logger,
providerRegistry: {
claude: createProviderDefinition({}),
},
} as unknown as Record<AgentProvider, ProviderDefinition>,
});
const tool = registeredTool(server, "get_agent_status");
@@ -1832,7 +1834,7 @@ describe("agent snapshot MCP serialization", () => {
logger,
providerRegistry: {
claude: createProviderDefinition({}),
},
} as unknown as Record<AgentProvider, ProviderDefinition>,
callerAgentId: "caller-agent",
});
const tool = registeredTool(server, "list_agents");
@@ -1881,7 +1883,7 @@ describe("agent snapshot MCP serialization", () => {
logger,
providerRegistry: {
claude: createProviderDefinition({}),
},
} as unknown as Record<AgentProvider, ProviderDefinition>,
});
const tool = registeredTool(server, "list_agents");
const response = await tool.callback({
@@ -1922,7 +1924,7 @@ describe("agent snapshot MCP serialization", () => {
logger,
providerRegistry: {
claude: createProviderDefinition({}),
},
} as unknown as Record<AgentProvider, ProviderDefinition>,
});
const tool = registeredTool(server, "list_agents");
const response = await tool.callback({ includeArchived: true });
@@ -1965,7 +1967,7 @@ describe("agent snapshot MCP serialization", () => {
logger,
providerRegistry: {
claude: createProviderDefinition({}),
},
} as unknown as Record<AgentProvider, ProviderDefinition>,
});
const tool = registeredTool(server, "list_agents");
const response = await tool.callback({ cwd: "/tmp/repo", includeArchived: true });
@@ -2073,7 +2075,7 @@ describe("agent snapshot MCP serialization", () => {
logger,
providerRegistry: {
claude: createProviderDefinition({}),
},
} as unknown as Record<AgentProvider, ProviderDefinition>,
});
const tool = registeredTool(server, "list_agents");
const response = await tool.callback({ includeArchived: true });
@@ -2113,7 +2115,7 @@ describe("agent snapshot MCP serialization", () => {
logger,
providerRegistry: {
claude: createProviderDefinition({}),
},
} as unknown as Record<AgentProvider, ProviderDefinition>,
});
const tool = registeredTool(server, "get_agent_activity");
const response = await tool.callback({ agentId: "archived-activity-agent" });
@@ -2151,7 +2153,7 @@ describe("agent snapshot MCP serialization", () => {
logger: createTestLogger(),
providerRegistry: {
claude: createProviderDefinition({}),
},
} as unknown as Record<AgentProvider, ProviderDefinition>,
});
const tool = registeredTool(server, "get_agent_activity");
const response = await tool.callback({ agentId: "live-activity-agent", limit: 1 });
@@ -2182,7 +2184,7 @@ describe("agent snapshot MCP serialization", () => {
logger: createTestLogger(),
providerRegistry: {
claude: createProviderDefinition({}),
},
} as unknown as Record<AgentProvider, ProviderDefinition>,
});
const tool = registeredTool(server, "get_agent_activity");
const response = await tool.callback({ agentId: "live-activity-agent-2", limit: 2 });
@@ -39,7 +39,7 @@ import { scheduleAgentMetadataGeneration } from "./agent-metadata-generator.js";
import type { VoiceCallerContext, VoiceSpeakHandler } from "../voice-types.js";
import { expandUserPath, isSameOrDescendantPath, resolvePathFromBase } from "../path-utils.js";
import type { TerminalManager } from "../../terminal/terminal-manager.js";
import { captureTerminalLines } from "../../terminal/terminal-capture.js";
import { captureTerminalLines } from "../../terminal/terminal.js";
import type {
AgentWorktreeSetupContinuation,
CreatePaseoWorktreeSetupContinuationInput,
@@ -2104,8 +2104,6 @@ function mcpCreateWorktreeInput(
return {
input: { ...base, action: "checkout", githubPrNumber: target.prNumber },
};
default:
throw new Error("unreachable");
}
}
@@ -1,60 +1,49 @@
import { expect, it, vi } from "vitest";
import { createTestLogger } from "../../test-utils/test-logger.js";
import { AgentManager } from "./agent-manager.js";
import { AgentStorage } from "./agent-storage.js";
import { setupFinishNotification } from "./mcp-shared.js";
import type { AgentManagerEvent, ManagedAgent } from "./agent-manager.js";
import type { AgentManager, AgentManagerEvent, ManagedAgent } from "./agent-manager.js";
import type { AgentStorage } from "./agent-storage.js";
it("does not notify archived callers", async () => {
let subscriber: ((event: AgentManagerEvent) => void) | null = null;
const childAgent: ManagedAgent = Object.create(null);
Reflect.set(childAgent, "id", "child-agent");
Reflect.set(childAgent, "lifecycle", "idle");
Reflect.set(childAgent, "config", { title: "Child Agent" });
const childAgent = {
id: "child-agent",
lifecycle: "idle",
config: { title: "Child Agent" },
} as ManagedAgent;
const callerAgent: ManagedAgent = Object.create(null);
Reflect.set(callerAgent, "id", "caller-agent");
Reflect.set(callerAgent, "lifecycle", "idle");
Reflect.set(callerAgent, "config", { title: "Caller Agent" });
const streamAgentSpy = vi.fn(() => (async function* noop() {})());
const replaceAgentRunSpy = vi.fn(() => (async function* noop() {})());
const agentManager: AgentManager = Object.create(AgentManager.prototype);
Reflect.set(
agentManager,
"getAgent",
vi.fn((agentId: string) => {
const agentManager = {
getAgent: vi.fn((agentId: string) => {
if (agentId === "child-agent") {
return childAgent;
}
if (agentId === "caller-agent") {
return callerAgent;
return {
id: "caller-agent",
lifecycle: "idle",
config: { title: "Caller Agent" },
} as ManagedAgent;
}
return null;
}),
);
Reflect.set(
agentManager,
"subscribe",
vi.fn((callback: (event: AgentManagerEvent) => void) => {
subscribe: vi.fn((callback: (event: AgentManagerEvent) => void) => {
subscriber = callback;
return () => {
subscriber = null;
};
}),
);
Reflect.set(agentManager, "hasInFlightRun", vi.fn().mockReturnValue(false));
Reflect.set(agentManager, "streamAgent", streamAgentSpy);
Reflect.set(agentManager, "replaceAgentRun", replaceAgentRunSpy);
hasInFlightRun: vi.fn().mockReturnValue(false),
streamAgent: vi.fn(() => (async function* noop() {})()),
replaceAgentRun: vi.fn(() => (async function* noop() {})()),
} as unknown as AgentManager;
const agentStorageGetSpy = vi.fn(async (agentId: string) =>
agentId === "caller-agent" ? { archivedAt: "2024-01-01" } : null,
);
const agentStorage: AgentStorage = Object.create(AgentStorage.prototype);
Reflect.set(agentStorage, "get", agentStorageGetSpy);
const agentStorage = {
get: vi.fn(async (agentId: string) =>
agentId === "caller-agent" ? { archivedAt: "2024-01-01" } : null,
),
} as unknown as AgentStorage;
setupFinishNotification({
agentManager,
@@ -79,9 +68,13 @@ it("does not notify archived callers", async () => {
});
await vi.waitFor(() => {
expect(agentStorageGetSpy).toHaveBeenCalledWith("caller-agent");
expect(agentStorage.get).toHaveBeenCalledWith("caller-agent");
});
expect(streamAgentSpy).not.toHaveBeenCalled();
expect(replaceAgentRunSpy).not.toHaveBeenCalled();
expect(
(agentManager as unknown as { streamAgent: ReturnType<typeof vi.fn> }).streamAgent,
).not.toHaveBeenCalled();
expect(
(agentManager as unknown as { replaceAgentRun: ReturnType<typeof vi.fn> }).replaceAgentRun,
).not.toHaveBeenCalled();
});
@@ -327,11 +327,11 @@ export function setupFinishNotification(params: SetupFinishNotificationParams):
return;
}
if (event.agent.lifecycle === "error") {
void notify("errored");
notify("errored");
return;
}
if (event.agent.lifecycle === "idle" && hasSeenRunning) {
void notify("finished");
notify("finished");
return;
}
if (event.agent.lifecycle === "closed") {
@@ -343,7 +343,7 @@ export function setupFinishNotification(params: SetupFinishNotificationParams):
}
if (event.event.type === "permission_requested") {
void notify("needs permission");
notify("needs permission");
}
},
{ agentId: childAgentId, replayState: false },
@@ -362,7 +362,7 @@ export function setupFinishNotification(params: SetupFinishNotificationParams):
if (childSnapshot.lifecycle === "running") {
hasSeenRunning = true;
} else if (childSnapshot.lifecycle === "error") {
void notify("errored");
notify("errored");
}
}
@@ -1,6 +1,6 @@
import type { Logger } from "pino";
import { beforeEach, describe, expect, it, vi } from "vitest";
import { createTestLogger } from "../../test-utils/test-logger.js";
import { resolveAgentModel } from "./model-resolver.js";
vi.mock("./provider-registry.js", () => ({
@@ -11,20 +11,10 @@ vi.mock("./provider-registry.js", () => ({
import { buildProviderRegistry } from "./provider-registry.js";
const mockedBuildProviderRegistry = vi.mocked(buildProviderRegistry);
const testLogger = createTestLogger();
const testLoggerWarn = vi.spyOn(testLogger, "warn");
const testLoggerWarn = vi.fn();
const testLogger = { warn: testLoggerWarn } as unknown as Logger;
type ProviderRegistryMock = ReturnType<typeof buildProviderRegistry>;
function makeMockRegistry(
entries: Record<string, { enabled: boolean; fetchModels: ReturnType<typeof vi.fn> }>,
): ProviderRegistryMock {
const registry: ProviderRegistryMock = Object.create(null);
for (const [key, val] of Object.entries(entries)) {
Reflect.set(registry, key, val);
}
return registry;
}
describe("resolveAgentModel", () => {
beforeEach(() => {
mockedBuildProviderRegistry.mockReset();
@@ -32,13 +22,11 @@ describe("resolveAgentModel", () => {
});
it("returns the trimmed requested model when provided", async () => {
mockedBuildProviderRegistry.mockReturnValue(
makeMockRegistry({
claude: { enabled: true, fetchModels: vi.fn() },
codex: { enabled: true, fetchModels: vi.fn() },
opencode: { enabled: true, fetchModels: vi.fn() },
}),
);
mockedBuildProviderRegistry.mockReturnValue({
claude: { enabled: true, fetchModels: vi.fn() },
codex: { enabled: true, fetchModels: vi.fn() },
opencode: { enabled: true, fetchModels: vi.fn() },
} as unknown as ProviderRegistryMock);
const result = await resolveAgentModel({
provider: "codex",
@@ -56,13 +44,11 @@ describe("resolveAgentModel", () => {
{ id: "claude-3.5-haiku", isDefault: false },
{ id: "claude-3.5-sonnet", isDefault: true },
]);
mockedBuildProviderRegistry.mockReturnValue(
makeMockRegistry({
claude: { enabled: true, fetchModels },
codex: { enabled: true, fetchModels: vi.fn() },
opencode: { enabled: true, fetchModels: vi.fn() },
}),
);
mockedBuildProviderRegistry.mockReturnValue({
claude: { enabled: true, fetchModels },
codex: { enabled: true, fetchModels: vi.fn() },
opencode: { enabled: true, fetchModels: vi.fn() },
} as unknown as ProviderRegistryMock);
const result = await resolveAgentModel({
provider: "claude",
@@ -82,13 +68,11 @@ describe("resolveAgentModel", () => {
{ id: "model-a", isDefault: false },
{ id: "model-b", isDefault: false },
]);
mockedBuildProviderRegistry.mockReturnValue(
makeMockRegistry({
claude: { enabled: true, fetchModels: vi.fn() },
codex: { enabled: true, fetchModels },
opencode: { enabled: true, fetchModels: vi.fn() },
}),
);
mockedBuildProviderRegistry.mockReturnValue({
claude: { enabled: true, fetchModels: vi.fn() },
codex: { enabled: true, fetchModels },
opencode: { enabled: true, fetchModels: vi.fn() },
} as unknown as ProviderRegistryMock);
const result = await resolveAgentModel({ provider: "codex", logger: testLogger });
@@ -97,13 +81,11 @@ describe("resolveAgentModel", () => {
it("returns undefined when the catalog lookup fails", async () => {
const fetchModels = vi.fn().mockRejectedValue(new Error("boom"));
mockedBuildProviderRegistry.mockReturnValue(
makeMockRegistry({
claude: { enabled: true, fetchModels: vi.fn() },
codex: { enabled: true, fetchModels },
opencode: { enabled: true, fetchModels: vi.fn() },
}),
);
mockedBuildProviderRegistry.mockReturnValue({
claude: { enabled: true, fetchModels: vi.fn() },
codex: { enabled: true, fetchModels },
opencode: { enabled: true, fetchModels: vi.fn() },
} as unknown as ProviderRegistryMock);
const result = await resolveAgentModel({ provider: "codex", logger: testLogger });
@@ -113,13 +95,11 @@ describe("resolveAgentModel", () => {
it("returns undefined for a disabled provider without fetching default models", async () => {
const fetchModels = vi.fn().mockResolvedValue([{ id: "model-a", isDefault: true }]);
mockedBuildProviderRegistry.mockReturnValue(
makeMockRegistry({
claude: { enabled: true, fetchModels: vi.fn() },
codex: { enabled: false, fetchModels },
opencode: { enabled: true, fetchModels: vi.fn() },
}),
);
mockedBuildProviderRegistry.mockReturnValue({
claude: { enabled: true, fetchModels: vi.fn() },
codex: { enabled: false, fetchModels },
opencode: { enabled: true, fetchModels: vi.fn() },
} as unknown as ProviderRegistryMock);
const result = await resolveAgentModel({ provider: "codex", logger: testLogger });
@@ -130,13 +110,11 @@ describe("resolveAgentModel", () => {
it("returns undefined for a requested model from a disabled provider", async () => {
const fetchModels = vi.fn();
mockedBuildProviderRegistry.mockReturnValue(
makeMockRegistry({
claude: { enabled: true, fetchModels: vi.fn() },
codex: { enabled: false, fetchModels },
opencode: { enabled: true, fetchModels: vi.fn() },
}),
);
mockedBuildProviderRegistry.mockReturnValue({
claude: { enabled: true, fetchModels: vi.fn() },
codex: { enabled: false, fetchModels },
opencode: { enabled: true, fetchModels: vi.fn() },
} as unknown as ProviderRegistryMock);
const result = await resolveAgentModel({
provider: "codex",
@@ -54,8 +54,6 @@ export function renderPromptAttachmentAsText(attachment: AgentAttachment): strin
});
return lines.join("\n");
}
default:
throw new Error("unreachable");
}
}
@@ -23,12 +23,11 @@ const mockState = vi.hoisted(() => {
isCommandAvailable: vi.fn(async (_command: string) => false),
runtimeModels: new Map<string, AgentModelDefinition[]>(),
reset() {
this.constructorArgs.claude = [];
this.constructorArgs.codex = [];
this.constructorArgs.copilot = [];
this.constructorArgs.opencode = [];
this.constructorArgs.pi = [];
this.constructorArgs.genericAcp = [];
for (const key of Object.keys(this.constructorArgs) as Array<
keyof typeof this.constructorArgs
>) {
this.constructorArgs[key] = [];
}
this.isCommandAvailable.mockReset();
this.isCommandAvailable.mockImplementation(async (_command: string) => false);
this.runtimeModels.clear();
@@ -77,10 +76,8 @@ vi.mock("./providers/claude-agent.js", () => ({
}
async isAvailable(): Promise<boolean> {
const command: { mode?: string; argv?: string[] } | undefined =
typeof this.runtimeSettings === "object" && this.runtimeSettings !== null
? Reflect.get(this.runtimeSettings, "command")
: undefined;
const command = (this.runtimeSettings as { command?: { mode?: string; argv?: string[] } })
?.command;
if (command?.mode === "replace") {
const { isCommandAvailable } = await import("../../utils/executable.js");
return await isCommandAvailable(command.argv?.[0] ?? "");
@@ -125,10 +122,8 @@ vi.mock("./providers/codex-app-server-agent.js", () => ({
}
async isAvailable(): Promise<boolean> {
const command: { mode?: string; argv?: string[] } | undefined =
typeof this.runtimeSettings === "object" && this.runtimeSettings !== null
? Reflect.get(this.runtimeSettings, "command")
: undefined;
const command = (this.runtimeSettings as { command?: { mode?: string; argv?: string[] } })
?.command;
if (command?.mode === "replace") {
const { isCommandAvailable } = await import("../../utils/executable.js");
return await isCommandAvailable(command.argv?.[0] ?? "");
@@ -175,10 +170,8 @@ vi.mock("./providers/copilot-acp-agent.js", () => ({
}
async isAvailable(): Promise<boolean> {
const command: { mode?: string; argv?: string[] } | undefined =
typeof this.runtimeSettings === "object" && this.runtimeSettings !== null
? Reflect.get(this.runtimeSettings, "command")
: undefined;
const command = (this.runtimeSettings as { command?: { mode?: string; argv?: string[] } })
?.command;
if (command?.mode === "replace") {
const { isCommandAvailable } = await import("../../utils/executable.js");
return await isCommandAvailable(command.argv?.[0] ?? "");
@@ -552,19 +545,18 @@ test("derived provider inherits and merges disallowedTools from base", () => {
},
});
const zaiArgs = mockState.constructorArgs.claude.find((entry) => {
const disallowedTools: string[] | undefined =
typeof entry.runtimeSettings === "object" && entry.runtimeSettings !== null
? Reflect.get(entry.runtimeSettings, "disallowedTools")
: undefined;
return Array.isArray(disallowedTools) && disallowedTools.includes("ComputerUse");
});
const zaiArgs = mockState.constructorArgs.claude.find(
(entry) =>
Array.isArray((entry.runtimeSettings as { disallowedTools?: string[] })?.disallowedTools) &&
(entry.runtimeSettings as { disallowedTools: string[] }).disallowedTools.includes(
"ComputerUse",
),
);
expect(zaiArgs).toBeDefined();
const zaiDisallowedTools: string[] =
typeof zaiArgs!.runtimeSettings === "object" && zaiArgs!.runtimeSettings !== null
? Reflect.get(zaiArgs!.runtimeSettings, "disallowedTools")
: [];
expect(zaiDisallowedTools).toEqual(["WebSearch", "ComputerUse"]);
expect((zaiArgs!.runtimeSettings as { disallowedTools: string[] }).disallowedTools).toEqual([
"WebSearch",
"ComputerUse",
]);
});
test("extension inherits base override — override claude command, zai extends claude gets overridden command", () => {
@@ -582,13 +574,11 @@ test("extension inherits base override — override claude command, zai extends
expect(mockState.constructorArgs.claude).toHaveLength(2);
expect(
mockState.constructorArgs.claude.every((entry) => {
const command: { argv?: string[] } | undefined =
typeof entry.runtimeSettings === "object" && entry.runtimeSettings !== null
? Reflect.get(entry.runtimeSettings, "command")
: undefined;
return command?.argv?.[0] === "/opt/custom-claude";
}),
mockState.constructorArgs.claude.every(
(entry) =>
(entry.runtimeSettings as { command?: { argv?: string[] } }).command?.argv?.[0] ===
"/opt/custom-claude",
),
).toBe(true);
});
@@ -22,7 +22,6 @@ import {
import { transformPiModels } from "./pi-direct-agent.js";
import type { AgentStreamEvent } from "../agent-sdk-types.js";
import { createTestLogger } from "../../../test-utils/test-logger.js";
import { asInternals } from "../../test-utils/class-mocks.js";
interface ACPSessionInternals {
sessionId: string | null;
@@ -160,7 +159,7 @@ function prepareConfiguredOverrideSession(
const setSessionConfigOption = vi.fn(async () => ({
configOptions: options.configOptions ?? [],
}));
const internals = asInternals<ACPConfiguredOverrideInternals>(session);
const internals = session as unknown as ACPConfiguredOverrideInternals;
internals.sessionId = "session-1";
internals.connection = {
setSessionMode,
@@ -180,7 +179,7 @@ function prepareConfiguredOverrideSession(
test("ACP setModel only uses config-option fallback when the matching select choice contains the model", async () => {
const logger = createTestLogger();
const childLogger = { warn: vi.fn() };
vi.spyOn(logger, "child").mockReturnValue(asInternals<typeof logger>(childLogger));
vi.spyOn(logger, "child").mockReturnValue(childLogger as unknown as typeof logger);
const session = createSessionWithConfig({}, logger);
const setSessionConfigOption = vi.fn(async () => ({
configOptions: [
@@ -194,7 +193,7 @@ test("ACP setModel only uses config-option fallback when the matching select cho
},
],
}));
const internals = asInternals<ACPModelSelectionInternals>(session);
const internals = session as unknown as ACPModelSelectionInternals;
internals.sessionId = "session-1";
internals.connection = { setSessionConfigOption };
internals.configOptions = [
@@ -220,7 +219,7 @@ test("ACP setModel only uses config-option fallback when the matching select cho
await expect(session.setModel("new-provider-model")).resolves.toBeUndefined();
expect(childLogger.warn).toHaveBeenCalledWith(
{ value: "new-provider-model" },
"new-provider-model",
expect.stringContaining("is not a valid claude-acp model config option"),
);
expect(setSessionConfigOption).not.toHaveBeenCalled();
@@ -236,7 +235,7 @@ describe("createLoggedNdJsonStream", () => {
const consoleError = vi.spyOn(console, "error").mockImplementation(() => {});
const stream = createLoggedNdJsonStream(output.writable, input.readable, {
logger: asInternals<ReturnType<typeof createTestLogger>>(logger),
logger: logger as unknown as ReturnType<typeof createTestLogger>,
provider: "gemini",
});
const reader = stream.readable.getReader();
@@ -277,7 +276,7 @@ describe("createLoggedNdJsonStream", () => {
};
const stream = createLoggedNdJsonStream(output.writable, input.readable, {
logger: asInternals<ReturnType<typeof createTestLogger>>(logger),
logger: logger as unknown as ReturnType<typeof createTestLogger>,
provider: "gemini",
});
const reader = stream.readable.getReader();
@@ -476,7 +475,7 @@ describe("ACPAgentSession Zed parity", () => {
modelId: "sonnet",
});
const modeEvents = asInternals<ACPSessionInternals>(validSession).translateSessionUpdate({
const modeEvents = (validSession as unknown as ACPSessionInternals).translateSessionUpdate({
sessionUpdate: "current_mode_update",
currentModeId: "default",
});
@@ -495,7 +494,7 @@ describe("ACPAgentSession Zed parity", () => {
const logger = createTestLogger();
const childLogger = { warn: vi.fn() };
vi.spyOn(logger, "child").mockReturnValue(asInternals<typeof logger>(childLogger));
vi.spyOn(logger, "child").mockReturnValue(childLogger as unknown as typeof logger);
const invalidSession = createSessionWithConfig(
{ modeId: "acceptEdits", model: "opus" },
logger,
@@ -514,11 +513,11 @@ describe("ACPAgentSession Zed parity", () => {
expect(invalid.setSessionMode).not.toHaveBeenCalled();
expect(invalid.unstableSetSessionModel).not.toHaveBeenCalled();
expect(childLogger.warn).toHaveBeenCalledWith(
{ value: expect.stringContaining("acceptEdits") },
expect.stringContaining("acceptEdits"),
expect.stringContaining("not valid"),
);
expect(childLogger.warn).toHaveBeenCalledWith(
{ value: expect.stringContaining("opus") },
expect.stringContaining("opus"),
expect.stringContaining("not a valid"),
);
});
@@ -541,7 +540,7 @@ describe("ACPAgentSession Zed parity", () => {
test("routes config_option_update and refreshes derived mode, model, and thinking state", async () => {
const session = createSession();
const internals = asInternals<ACPSessionInternals>(session);
const internals = session as unknown as ACPSessionInternals;
const events = internals.translateSessionUpdate({
sessionUpdate: "config_option_update",
@@ -596,7 +595,7 @@ describe("ACPAgentSession Zed parity", () => {
test("keeps pushed mode when a later config_option_update has no mode payload", async () => {
const session = createSession();
const internals = asInternals<ACPSessionInternals>(session);
const internals = session as unknown as ACPSessionInternals;
internals.translateSessionUpdate({
sessionUpdate: "current_mode_update",
@@ -617,7 +616,7 @@ describe("ACPAgentSession Zed parity", () => {
test("uses last writer when current_mode_update and config_option_update both include a mode", async () => {
const session = createSession();
const internals = asInternals<ACPSessionInternals>(session);
const internals = session as unknown as ACPSessionInternals;
const configEvents = internals.translateSessionUpdate({
sessionUpdate: "config_option_update",
@@ -635,7 +634,7 @@ describe("ACPAgentSession Zed parity", () => {
test("uses canonical mode returned by setSessionConfigOption response", async () => {
const session = createSession();
const internals = asInternals<ACPModelSelectionInternals>(session);
const internals = session as unknown as ACPModelSelectionInternals;
const events: AgentStreamEvent[] = [];
const unsubscribe = session.subscribe((event) => events.push(event));
internals.sessionId = "session-1";
@@ -665,7 +664,7 @@ describe("ACPAgentSession Zed parity", () => {
test("uses canonical model returned by setSessionConfigOption response", async () => {
const session = createSession();
const internals = asInternals<ACPModelSelectionInternals>(session);
const internals = session as unknown as ACPModelSelectionInternals;
const events: AgentStreamEvent[] = [];
const unsubscribe = session.subscribe((event) => events.push(event));
internals.sessionId = "session-1";
@@ -689,7 +688,7 @@ describe("ACPAgentSession Zed parity", () => {
test("uses canonical thinking option returned by setSessionConfigOption response", async () => {
const session = createSession();
const internals = asInternals<ACPModelSelectionInternals>(session);
const internals = session as unknown as ACPModelSelectionInternals;
const events: AgentStreamEvent[] = [];
const unsubscribe = session.subscribe((event) => events.push(event));
internals.sessionId = "session-1";
@@ -726,7 +725,7 @@ describe("ACPAgentSession Zed parity", () => {
{ optionId: "reject-once", name: "Reject", kind: "reject_once" },
];
asInternals<ACPSessionInternals>(session).sessionId = "session-1";
(session as unknown as ACPSessionInternals).sessionId = "session-1";
session.subscribe((event) => {
events.push(event as { type: string; request?: { id: string } });
});
@@ -870,7 +869,7 @@ describe("ACPAgentClient modelTransformer", () => {
}),
},
initialize: { agentCapabilities: {} },
} as SpawnedACPProcess;
} as unknown as SpawnedACPProcess;
}
protected override async closeProbe(): Promise<void> {}
@@ -916,7 +915,7 @@ describe("ACPAgentClient sessionResponseTransformer", () => {
newSession: vi.fn().mockResolvedValue(response),
},
initialize: { agentCapabilities: {} },
} as SpawnedACPProcess;
} as unknown as SpawnedACPProcess;
}
protected override async closeProbe(): Promise<void> {}
@@ -957,7 +956,7 @@ describe("ACPAgentClient listModes", () => {
child: { kill: vi.fn(), exitCode: 0, signalCode: null, once: vi.fn() },
connection: { newSession },
initialize: { agentCapabilities: {} },
} as SpawnedACPProcess;
} as unknown as SpawnedACPProcess;
}
protected override async closeProbe(): Promise<void> {}
@@ -1008,7 +1007,7 @@ describe("ACPAgentClient listModes", () => {
}),
},
initialize: { agentCapabilities: {} },
} as SpawnedACPProcess;
} as unknown as SpawnedACPProcess;
}
protected override async closeProbe(): Promise<void> {}
@@ -1112,7 +1111,7 @@ describe("ACPAgentSession slash commands", () => {
const listCommandsPromise = session.listCommands();
asInternals<ACPSessionInternals>(session).translateSessionUpdate({
(session as unknown as ACPSessionInternals).translateSessionUpdate({
sessionUpdate: "available_commands_update",
availableCommands: [
{
@@ -1158,7 +1157,7 @@ describe("ACPAgentSession", () => {
test("emits assistant and reasoning chunks as deltas while user chunks stay accumulated", async () => {
const session = createSession();
const events: Array<{ type: string; item?: { type: string; text?: string } }> = [];
asInternals<ACPSessionInternals>(session).sessionId = "session-1";
(session as unknown as ACPSessionInternals).sessionId = "session-1";
session.subscribe((event) => {
events.push(event as { type: string; item?: { type: string; text?: string } });
@@ -1170,7 +1169,7 @@ describe("ACPAgentSession", () => {
sessionUpdate: "agent_message_chunk",
messageId: "assistant-1",
content: { type: "text", text: "Hey!" },
} as SessionUpdate,
} as unknown as SessionUpdate,
});
await session.sessionUpdate({
sessionId: "session-1",
@@ -1178,7 +1177,7 @@ describe("ACPAgentSession", () => {
sessionUpdate: "agent_message_chunk",
messageId: "assistant-1",
content: { type: "text", text: " How are you?" },
} as SessionUpdate,
} as unknown as SessionUpdate,
});
await session.sessionUpdate({
sessionId: "session-1",
@@ -1186,7 +1185,7 @@ describe("ACPAgentSession", () => {
sessionUpdate: "agent_thought_chunk",
messageId: "thought-1",
content: { type: "text", text: "Thinking" },
} as SessionUpdate,
} as unknown as SessionUpdate,
});
await session.sessionUpdate({
sessionId: "session-1",
@@ -1194,7 +1193,7 @@ describe("ACPAgentSession", () => {
sessionUpdate: "agent_thought_chunk",
messageId: "thought-1",
content: { type: "text", text: " more" },
} as SessionUpdate,
} as unknown as SessionUpdate,
});
await session.sessionUpdate({
sessionId: "session-1",
@@ -1202,7 +1201,7 @@ describe("ACPAgentSession", () => {
sessionUpdate: "user_message_chunk",
messageId: "user-1",
content: { type: "text", text: "hel" },
} as SessionUpdate,
} as unknown as SessionUpdate,
});
await session.sessionUpdate({
sessionId: "session-1",
@@ -1210,7 +1209,7 @@ describe("ACPAgentSession", () => {
sessionUpdate: "user_message_chunk",
messageId: "user-1",
content: { type: "text", text: "lo" },
} as SessionUpdate,
} as unknown as SessionUpdate,
});
const timeline = events
@@ -1239,8 +1238,8 @@ describe("ACPAgentSession", () => {
}),
);
asInternals<ACPSessionInternals>(session).sessionId = "session-1";
asInternals<ACPSessionInternals>(session).connection = { prompt };
(session as unknown as ACPSessionInternals).sessionId = "session-1";
(session as unknown as ACPSessionInternals).connection = { prompt };
session.subscribe((event) => {
events.push(event as { type: string; turnId?: string });
@@ -1253,7 +1252,7 @@ describe("ACPAgentSession", () => {
type: "turn_started",
turnId,
});
expect(asInternals<ACPSessionInternals>(session).activeForegroundTurnId).toBe(turnId);
expect((session as unknown as ACPSessionInternals).activeForegroundTurnId).toBe(turnId);
resolvePrompt({ stopReason: "end_turn", usage: { outputTokens: 3 } });
await Promise.resolve();
@@ -1263,7 +1262,7 @@ describe("ACPAgentSession", () => {
type: "turn_completed",
turnId,
});
expect(asInternals<ACPSessionInternals>(session).activeForegroundTurnId).toBeNull();
expect((session as unknown as ACPSessionInternals).activeForegroundTurnId).toBeNull();
});
test("startTurn converts background prompt rejections into turn_failed events", async () => {
@@ -1277,8 +1276,8 @@ describe("ACPAgentSession", () => {
}),
);
asInternals<ACPSessionInternals>(session).sessionId = "session-1";
asInternals<ACPSessionInternals>(session).connection = { prompt };
(session as unknown as ACPSessionInternals).sessionId = "session-1";
(session as unknown as ACPSessionInternals).connection = { prompt };
session.subscribe((event) => {
events.push(event as { type: string; turnId?: string; error?: string });
@@ -1296,6 +1295,6 @@ describe("ACPAgentSession", () => {
turnId,
error: "prompt failed",
});
expect(asInternals<ACPSessionInternals>(session).activeForegroundTurnId).toBeNull();
expect((session as unknown as ACPSessionInternals).activeForegroundTurnId).toBeNull();
});
});
@@ -3,10 +3,6 @@ import { randomUUID } from "node:crypto";
import fs from "node:fs/promises";
import path from "node:path";
import { Readable, Writable } from "node:stream";
import type {
ReadableStream as NodeReadableStream,
WritableStream as NodeWritableStream,
} from "node:stream/web";
import {
ClientSideConnection,
PROTOCOL_VERSION,
@@ -94,18 +90,6 @@ import { appendOrReplaceGrowingAssistantMessage, runProviderTurn } from "./provi
import { findExecutable } from "../../../utils/executable.js";
import { spawnProcess } from "../../../utils/spawn.js";
function assertChildWithPipes(
child: ChildProcess,
): asserts child is ChildProcessWithoutNullStreams {
if (!child.stdin || !child.stdout || !child.stderr) {
throw new Error("Child process did not expose stdio pipes");
}
}
function isRecord(value: unknown): value is Record<string, unknown> {
return value != null && typeof value === "object" && !Array.isArray(value);
}
const DEFAULT_ACP_CAPABILITIES: AgentCapabilityFlags = {
supportsStreaming: true,
supportsSessionPersistence: true,
@@ -136,8 +120,8 @@ function summarizeMalformedACPStdoutError(error: unknown): { type: string; messa
}
export function createLoggedNdJsonStream(
output: NodeWritableStream,
input: NodeReadableStream,
output: WritableStream<Uint8Array>,
input: ReadableStream<Uint8Array>,
options: { logger: Logger; provider: string },
): ACPStream {
const textEncoder = new TextEncoder();
@@ -168,7 +152,7 @@ export function createLoggedNdJsonStream(
}
try {
const message: AnyMessage = JSON.parse(trimmedLine);
const message = JSON.parse(trimmedLine) as AnyMessage;
controller.enqueue(message);
} catch (error) {
options.logger.warn(
@@ -645,8 +629,7 @@ export class ACPAgentClient implements AgentClient {
overlays: [launchEnv],
}),
stdio: ["pipe", "pipe", "pipe"],
});
assertChildWithPipes(child);
}) as ChildProcessWithoutNullStreams;
const stderrChunks: string[] = [];
child.stderr.on("data", (chunk: Buffer | string) => {
@@ -660,9 +643,13 @@ export class ACPAgentClient implements AgentClient {
});
});
if (!child.stdin || !child.stdout) {
throw new Error(`${this.provider} ACP process did not expose stdio pipes`);
}
const stream = createLoggedNdJsonStream(
Writable.toWeb(child.stdin),
Readable.toWeb(child.stdout),
Writable.toWeb(child.stdin) as WritableStream<Uint8Array>,
Readable.toWeb(child.stdout) as ReadableStream<Uint8Array>,
{ logger: this.logger, provider: this.provider },
);
const connection = new ClientSideConnection(() => this.buildProbeClient(), stream);
@@ -1559,8 +1546,7 @@ export class ACPAgentSession implements AgentSession, ACPClient {
overlays: [this.launchEnv],
}),
stdio: ["pipe", "pipe", "pipe"],
});
assertChildWithPipes(child);
}) as ChildProcessWithoutNullStreams;
const stderrChunks: string[] = [];
child.stderr.on("data", (chunk: Buffer | string) => {
@@ -1582,9 +1568,13 @@ export class ACPAgentSession implements AgentSession, ACPClient {
}
});
if (!child.stdin || !child.stdout) {
throw new Error(`${this.provider} ACP process did not expose stdio pipes`);
}
const stream = createLoggedNdJsonStream(
Writable.toWeb(child.stdin),
Readable.toWeb(child.stdout),
Writable.toWeb(child.stdin) as WritableStream<Uint8Array>,
Readable.toWeb(child.stdout) as ReadableStream<Uint8Array>,
{ logger: this.logger, provider: this.provider },
);
const connection = new ClientSideConnection(() => this, stream);
@@ -1640,7 +1630,7 @@ export class ACPAgentSession implements AgentSession, ACPClient {
}
private warnInvalidSelection(value: string, message: string): void {
this.logger.warn({ value }, message);
(this.logger.warn as unknown as (value: string, message: string) => void)(value, message);
}
private translateSessionUpdate(update: SessionUpdate): AgentStreamEvent[] {
@@ -2420,7 +2410,9 @@ function appendTerminalOutput(entry: TerminalEntry, chunk: string): void {
}
function readRecord(value: unknown): Record<string, unknown> | null {
return isRecord(value) ? value : null;
return value && typeof value === "object" && !Array.isArray(value)
? (value as Record<string, unknown>)
: null;
}
function readString(record: Record<string, unknown> | null, keys: string[]): string | undefined {
@@ -2481,7 +2473,7 @@ function stringifyUnknown(value: unknown): string | undefined {
try {
return JSON.stringify(value);
} catch {
return typeof value === "bigint" ? String(value) : "[unserializable]";
return String(value);
}
}
@@ -14,7 +14,6 @@ import type {
AgentTimelineItem,
} from "../agent-sdk-types.js";
import { ACPAgentSession } from "./acp-agent.js";
import { asInternals } from "../../test-utils/class-mocks.js";
const smokeSelection = new Set(
(process.env.ACP_SMOKE ?? "")
@@ -118,7 +117,7 @@ function createSmokeLogger(): SmokeLogger {
error: vi.fn(),
fatal: vi.fn(),
};
return asInternals<SmokeLogger>(logger);
return logger as unknown as SmokeLogger;
}
function createTrace(config: WrapperSmokeConfig): SmokeTrace {
@@ -366,7 +365,7 @@ async function bootSession(
}
async function captureFinalState(session: ACPAgentSession, trace: SmokeTrace): Promise<void> {
const internals = asInternals<SessionInternals>(session);
const internals = session as unknown as SessionInternals;
trace.finalState = {
runtime: await session.getRuntimeInfo(),
availableModes: await session.getAvailableModes(),
@@ -377,7 +376,7 @@ async function captureFinalState(session: ACPAgentSession, trace: SmokeTrace): P
}
function getModelIds(session: ACPAgentSession): string[] {
const internals = asInternals<SessionInternals>(session);
const internals = session as unknown as SessionInternals;
return internals.availableModels?.map((model) => model.modelId) ?? [];
}
@@ -385,7 +384,7 @@ function getSelectOption(
session: ACPAgentSession,
category: string,
): Extract<SessionConfigOption, { type: "select" }> | null {
const internals = asInternals<SessionInternals>(session);
const internals = session as unknown as SessionInternals;
return (
internals.configOptions.find(
(option): option is Extract<SessionConfigOption, { type: "select" }> =>
@@ -516,7 +515,7 @@ for (const config of wrappers) {
const modeAfterSuccess = await session.getCurrentMode();
originalSessionId = session.id;
asInternals<{ sessionId: string }>(session).sessionId = `${originalSessionId}-missing`;
(session as unknown as { sessionId: string }).sessionId = `${originalSessionId}-missing`;
await expect(session.setMode(mode.id)).rejects.toThrow();
expect(await session.getCurrentMode()).toBe(modeAfterSuccess);
trace.notes.push(
@@ -524,7 +523,7 @@ for (const config of wrappers) {
);
} finally {
if (session && originalSessionId) {
asInternals<{ sessionId: string }>(session).sessionId = originalSessionId;
(session as unknown as { sessionId: string }).sessionId = originalSessionId;
}
await captureFinalStateIfOpen(session, trace);
emitEvidence(`${config.id}:b`, trace);
@@ -623,7 +622,7 @@ for (const config of wrappers) {
expect(
trace.notifications.some((entry) => JSON.stringify(entry).includes("tool_call_update")),
).toBe(true);
expect(asInternals<SessionInternals>(session).toolCalls.size).toBeGreaterThan(0);
expect((session as unknown as SessionInternals).toolCalls.size).toBeGreaterThan(0);
trace.criteria.toolSnapshot = {
outcome: "PASS",
detail:
@@ -2,7 +2,6 @@ import { afterEach, beforeEach, expect, test, vi } from "vitest";
import type { Logger } from "pino";
import { createTestLogger } from "../../../test-utils/test-logger.js";
import { asInternals } from "../../test-utils/class-mocks.js";
import { ClaudeAgentClient, readEventIdentifiers } from "./claude-agent.js";
import { streamSession } from "./test-utils/session-stream-adapter.js";
import type { AgentStreamEvent, AgentTimelineItem } from "../agent-sdk-types.js";
@@ -125,7 +124,7 @@ function createSpyLogger(): {
loggerLike.child.mockReturnValue(loggerLike);
return {
logger: asInternals<Logger>(loggerLike),
logger: loggerLike as unknown as Logger,
calls,
debug,
info,
@@ -262,7 +261,7 @@ test("logs redacted query summary and never leaks sentinel secrets", async () =>
test("interruptActiveTurn only interrupts the active query without info logs", async () => {
const spy = createSpyLogger();
const session = await createSessionWithLogger(spy.logger);
const internal: {
const internal = session as unknown as {
query: {
interrupt: () => Promise<void>;
return?: () => Promise<void>;
@@ -271,7 +270,7 @@ test("interruptActiveTurn only interrupts the active query without info logs", a
input: { end: () => void } | null;
queryRestartNeeded: boolean;
interruptActiveTurn: () => Promise<void>;
} = asInternals(session);
};
const interrupt = vi.fn(async () => undefined);
const queryReturn = vi.fn(async () => undefined);
const end = vi.fn(() => undefined);
@@ -384,7 +383,7 @@ test("extracts identifiers from fixture-driven protocol shape variants", () => {
for (const fixture of fixtures) {
expect(
readEventIdentifiers(
asInternals<Parameters<typeof readEventIdentifiers>[0]>(fixture.message),
fixture.message as unknown as Parameters<typeof readEventIdentifiers>[0],
),
).toEqual(fixture.expected);
}
@@ -412,12 +411,12 @@ test("captures session IDs from fixture-driven init message variants", async ()
await Promise.all(
fixtures.map(async (fixture) => {
const session = await createSession();
const internal: {
const internal = session as unknown as {
handleSystemMessage: (message: Record<string, unknown>) => {
threadStartedSessionId: string | null;
notice: AgentTimelineItem | null;
};
} = asInternals(session);
};
try {
const started = internal.handleSystemMessage({
type: "system",
@@ -440,12 +439,12 @@ test("captures session IDs from fixture-driven init message variants", async ()
test("waits for complete JSON values before updating tool input from input_json_delta", async () => {
const session = await createSession();
const internal: {
const internal = session as unknown as {
mapPartialEvent: (event: Record<string, unknown>) => AgentTimelineItem[];
toolUseCache: Map<string, { input?: Record<string, unknown> }>;
toolUseIndexToId: Map<number, string>;
toolUseInputBuffers: Map<string, string>;
} = asInternals(session);
};
const toolUseId = "tool-input-delta";
const index = 7;
@@ -502,7 +501,7 @@ test("waits for complete JSON values before updating tool input from input_json_
] as const;
for (const fixture of deltaFixtures) {
internal.mapPartialEvent(asInternals<Record<string, unknown>>(fixture.event));
internal.mapPartialEvent(fixture.event as unknown as Record<string, unknown>);
expect(readCommand()).toBe(fixture.expectedCommand);
}
@@ -519,10 +518,10 @@ test("waits for complete JSON values before updating tool input from input_json_
test("does not surface incomplete string values from input_json_delta", async () => {
const session = await createSession();
const internal: {
const internal = session as unknown as {
mapPartialEvent: (event: Record<string, unknown>) => AgentTimelineItem[];
toolUseCache: Map<string, { input?: Record<string, unknown> }>;
} = asInternals(session);
};
const toolUseId = "tool-input-preview";
const index = 8;
@@ -556,12 +555,12 @@ test("does not surface incomplete string values from input_json_delta", async ()
test("maps tool_result content shapes into deterministic string output", async () => {
const session = await createSession();
const internal: {
const internal = session as unknown as {
buildToolOutput: (
block: Record<string, unknown>,
entry: Record<string, unknown> | undefined,
) => Record<string, unknown> | undefined;
} = asInternals(session);
};
const toolEntry = {
id: "tool-1",
@@ -629,12 +628,12 @@ test("maps tool_result content shapes into deterministic string output", async (
test("Grep tool_result string content flows to a search detail with content", async () => {
const session = await createSession();
const internal: {
const internal = session as unknown as {
buildToolOutput: (
block: Record<string, unknown>,
entry: Record<string, unknown> | undefined,
) => Record<string, unknown> | undefined;
} = asInternals(session);
};
const grepEntry = {
id: "tool-grep-1",
@@ -891,13 +890,13 @@ test("plan approval exposes a resume-bypass action and can return to bypassPermi
await session.setMode("bypassPermissions");
await session.setMode("plan");
const internal: {
const internal = session as unknown as {
handlePermissionRequest: (
toolName: string,
input: Record<string, unknown>,
options: Record<string, unknown>,
) => Promise<unknown>;
} = asInternals(session);
};
const pendingResolution = internal.handlePermissionRequest(
"ExitPlanMode",
@@ -957,12 +956,12 @@ test("plan approval exposes a resume-bypass action and can return to bypassPermi
test("reuses one autonomous run for unbound stream_event bursts with no foreground run", async () => {
const session = await createSession();
const internal: {
const internal = session as unknown as {
turnState: "idle" | "foreground" | "autonomous";
nextTurnOrdinal: number;
routeSdkMessageFromPump: (message: Record<string, unknown>) => void;
autonomousTurn: { id: string } | null;
} = asInternals(session);
};
internal.turnState = "idle";
internal.routeSdkMessageFromPump({
@@ -1353,9 +1352,9 @@ test("does not use stream_event uuid as assistant message identity when message_
expect(assistantText).toContain("HELLO WORLD");
const assembler: {
const assembler = session as unknown as {
timelineAssembler: { messages: Map<string, unknown> };
} = asInternals(session);
};
expect(assembler.timelineAssembler.messages.size).toBe(0);
await session.close();
@@ -2,11 +2,7 @@ import { describe, expect, test, vi } from "vitest";
import type { SDKMessage } from "@anthropic-ai/claude-agent-sdk";
import { createTestLogger } from "../../../test-utils/test-logger.js";
import {
ClaudeAgentClient,
convertClaudeHistoryEntry,
normalizeClaudeAskUserQuestionUpdatedInput,
} from "./claude-agent.js";
import { ClaudeAgentClient, convertClaudeHistoryEntry } from "./claude-agent.js";
import type { AgentTimelineItem, AgentUsage, AgentStreamEvent } from "../agent-sdk-types.js";
interface TestClaudeSession {
@@ -368,138 +364,6 @@ describe("ClaudeAgentClient.listModels", () => {
});
});
describe("normalizeClaudeAskUserQuestionUpdatedInput", () => {
test("maps frontend header-keyed answers to Claude question text keys", () => {
expect(
normalizeClaudeAskUserQuestionUpdatedInput(
{
questions: [
{
question: "Which provider should I use?",
header: "Provider",
options: [],
multiSelect: false,
},
],
answers: { Provider: "Claude" },
},
undefined,
),
).toEqual({
questions: [
{
question: "Which provider should I use?",
header: "Provider",
options: [],
multiSelect: false,
},
],
answers: { "Which provider should I use?": "Claude" },
});
});
test("uses fallback request questions when response only includes answers", () => {
expect(
normalizeClaudeAskUserQuestionUpdatedInput(
{
answers: { Provider: "Codex" },
},
{
questions: [
{
question: "Which provider should I use?",
header: "Provider",
options: [],
multiSelect: false,
},
],
},
),
).toEqual({
questions: [
{
question: "Which provider should I use?",
header: "Provider",
options: [],
multiSelect: false,
},
],
answers: { "Which provider should I use?": "Codex" },
});
});
test("respondToPermission preserves full question input when UI returns answers-only payload", async () => {
const client = new ClaudeAgentClient({ logger: createTestLogger() });
const session = await client.createSession({
provider: "claude",
cwd: process.cwd(),
});
const request = {
id: "permission-question-1",
provider: "claude",
name: "AskUserQuestion",
kind: "question",
input: {
questions: [
{
question: "Which provider should I use?",
header: "Provider",
options: [],
multiSelect: false,
},
],
},
};
const resultPromise = new Promise<unknown>((resolve, reject) => {
(
session as unknown as {
pendingPermissions: Map<
string,
{
request: typeof request;
resolve: (value: unknown) => void;
reject: (error: Error) => void;
}
>;
}
).pendingPermissions.set(request.id, {
request,
resolve,
reject,
});
});
try {
await session.respondToPermission(request.id, {
behavior: "allow",
updatedInput: {
answers: { Provider: "Claude" },
},
});
await expect(resultPromise).resolves.toEqual({
behavior: "allow",
updatedInput: {
questions: [
{
question: "Which provider should I use?",
header: "Provider",
options: [],
multiSelect: false,
},
],
answers: { "Which provider should I use?": "Claude" },
},
updatedPermissions: undefined,
});
} finally {
await session.close();
}
});
});
describe("ClaudeAgentSession context window usage", () => {
const logger = createTestLogger();
@@ -87,61 +87,6 @@ import { getOrchestratorModeInstructions } from "../orchestrator-instructions.js
const fsPromises = promises;
const CLAUDE_SETTING_SOURCES: NonNullable<Options["settingSources"]> = ["user", "project"];
function readNonEmptyString(value: unknown): string | null {
return typeof value === "string" && value.trim().length > 0 ? value : null;
}
export function normalizeClaudeAskUserQuestionUpdatedInput(
updatedInput: AgentMetadata | undefined,
fallbackInput: AgentMetadata | undefined,
): AgentMetadata {
const fallback = isMetadata(fallbackInput) ? fallbackInput : {};
const base = isMetadata(updatedInput) ? updatedInput : {};
// Paseo's shared question UI serializes answers by question header, but Claude's
// AskUserQuestion tool expects answer keys to match the full question text. Merge
// the original request payload back in so provider callbacks that only return
// `{ answers }` still satisfy Claude's full tool input schema.
const merged = { ...fallback, ...base };
const questions =
(Array.isArray(base.questions) ? base.questions : null) ??
(Array.isArray(fallback.questions) ? fallback.questions : null);
const answers = isMetadata(base.answers) ? base.answers : null;
if (!questions || !answers) {
return merged;
}
const normalizedAnswers: Record<string, string> = {};
for (const item of questions) {
const question = isMetadata(item) ? item : null;
if (!question) {
continue;
}
const questionText = readNonEmptyString(question.question);
if (!questionText) {
continue;
}
const header = readNonEmptyString(question.header);
const answer =
readNonEmptyString(answers[questionText]) ??
(header ? readNonEmptyString(answers[header]) : null);
if (answer) {
normalizedAnswers[questionText] = answer;
}
}
if (Object.keys(normalizedAnswers).length === 0) {
return merged;
}
return {
...merged,
answers: normalizedAnswers,
};
}
function isObjectRecord(value: unknown): value is Record<string, unknown> {
return Boolean(value) && typeof value === "object" && !Array.isArray(value);
}
@@ -1860,16 +1805,9 @@ class ClaudeAgentSession implements AgentSession {
}),
);
}
const updatedInput =
pending.request.kind === "question"
? normalizeClaudeAskUserQuestionUpdatedInput(
response.updatedInput,
pending.request.input ?? undefined,
)
: (response.updatedInput ?? pending.request.input ?? {});
const result: PermissionResult = {
behavior: "allow",
updatedInput,
updatedInput: response.updatedInput ?? pending.request.input ?? {},
updatedPermissions: this.normalizePermissionUpdates(response.updatedPermissions),
};
pending.resolve(result);
@@ -18,7 +18,6 @@ import {
codexAppServerTurnInputFromPrompt,
} from "./codex-app-server-agent.js";
import { createTestLogger } from "../../../test-utils/test-logger.js";
import { asInternals as castInternals, createStub } from "../../test-utils/class-mocks.js";
interface CollaborationModeRecord {
name: string;
@@ -72,7 +71,7 @@ function createSession(configOverrides: Partial<AgentSessionConfig> = {}): Codex
() => {
throw new Error("Test session cannot spawn Codex app-server");
},
) as CodexTestSession;
) as unknown as CodexTestSession;
session.connected = true;
session.currentThreadId = "test-thread";
session.activeForegroundTurnId = "test-turn";
@@ -80,7 +79,7 @@ function createSession(configOverrides: Partial<AgentSessionConfig> = {}): Codex
}
function asInternals(session: CodexTestSession): CodexSessionTestAccess {
return castInternals<CodexSessionTestAccess>(session);
return session as unknown as CodexSessionTestAccess;
}
describe("Codex app-server provider", () => {
@@ -106,9 +105,9 @@ describe("Codex app-server provider", () => {
{},
true,
);
castInternals<{ client: CodexClientLike }>(session).client = fakeClient;
(session as unknown as { client: CodexClientLike }).client = fakeClient;
await castInternals<{ ensureThread: () => Promise<void> }>(session).ensureThread();
await (session as unknown as { ensureThread: () => Promise<void> }).ensureThread();
const startCall = requests.find((req) => req.method === "thread/start");
expect(startCall).toBeDefined();
@@ -135,9 +134,9 @@ describe("Codex app-server provider", () => {
throw new Error("Test session cannot spawn Codex app-server");
},
);
castInternals<{ client: CodexClientLike }>(session).client = fakeClient;
(session as unknown as { client: CodexClientLike }).client = fakeClient;
await castInternals<{ ensureThread: () => Promise<void> }>(session).ensureThread();
await (session as unknown as { ensureThread: () => Promise<void> }).ensureThread();
const startCall = requests.find((req) => req.method === "thread/start");
expect(startCall).toBeDefined();
@@ -350,7 +349,7 @@ describe("Codex app-server provider", () => {
});
session.activeForegroundTurnId = null;
session.client = createStub<CodexClientLike>({ request });
session.client = { request } as unknown as CodexClientLike;
await session.startTurn("Return JSON", {
outputSchema: {
@@ -406,7 +405,7 @@ describe("Codex app-server provider", () => {
});
session.activeForegroundTurnId = null;
session.client = createStub<CodexClientLike>({ request });
session.client = { request } as unknown as CodexClientLike;
await session.startTurn("/paseo-implement in a worktree, remember to use Claude for the UI");
@@ -1440,7 +1439,7 @@ describe("Codex app-server provider", () => {
});
session.activeForegroundTurnId = null;
session.client = createStub<CodexClientLike>({ request });
session.client = { request } as unknown as CodexClientLike;
const events: AgentStreamEvent[] = [];
session.subscribe((event) => events.push(event));
@@ -30,7 +30,7 @@ import type {
import type { Logger } from "pino";
import { homedir } from "node:os";
import type { ChildProcess, ChildProcessWithoutNullStreams } from "node:child_process";
import type { ChildProcessWithoutNullStreams } from "node:child_process";
import { randomUUID } from "node:crypto";
import { Dirent } from "node:fs";
import fs from "node:fs/promises";
@@ -65,18 +65,6 @@ import {
import { runProviderTurn } from "./provider-runner.js";
import type { WorkspaceGitService } from "../../workspace-git-service.js";
function assertChildWithPipes(
child: ChildProcess,
): asserts child is ChildProcessWithoutNullStreams {
if (!child.stdin || !child.stdout || !child.stderr) {
throw new Error("Child process did not expose stdio pipes");
}
}
function isRecord(value: unknown): value is Record<string, unknown> {
return value != null && typeof value === "object" && !Array.isArray(value);
}
const DEFAULT_TIMEOUT_MS = 14 * 24 * 60 * 60 * 1000;
const TURN_START_TIMEOUT_MS = 90 * 1000;
const INTERRUPT_TIMEOUT_MS = 2_000;
@@ -576,24 +564,29 @@ interface JsonRpcNotification {
params?: unknown;
}
function isJsonRpcResponse(msg: unknown): msg is JsonRpcResponse {
if (!isRecord(msg)) return false;
if (typeof msg.id !== "number") return false;
return msg.result !== undefined || !!msg.error;
type JsonRpcMessage = JsonRpcRequest | JsonRpcResponse | JsonRpcNotification;
function isJsonRpcResponse(msg: JsonRpcMessage): msg is JsonRpcResponse {
const m = msg as unknown as Record<string, unknown>;
if (typeof m.id !== "number") return false;
return m.result !== undefined || !!m.error;
}
function isJsonRpcRequest(msg: unknown): msg is JsonRpcRequest {
if (!isRecord(msg)) return false;
return typeof msg.id === "number" && typeof msg.method === "string";
function isJsonRpcRequest(msg: JsonRpcMessage): msg is JsonRpcRequest {
const m = msg as unknown as Record<string, unknown>;
return typeof m.id === "number" && typeof m.method === "string";
}
function isJsonRpcNotification(msg: unknown): msg is JsonRpcNotification {
if (!isRecord(msg)) return false;
return typeof msg.method === "string" && typeof msg.id !== "number";
function isJsonRpcNotification(msg: JsonRpcMessage): msg is JsonRpcNotification {
const m = msg as unknown as Record<string, unknown>;
return typeof m.method === "string" && typeof m.id !== "number";
}
function toObjectRecord(value: unknown): Record<string, unknown> | undefined {
return isRecord(value) ? value : undefined;
if (!value || typeof value !== "object" || Array.isArray(value)) {
return undefined;
}
return value as Record<string, unknown>;
}
interface PendingRequest {
@@ -622,28 +615,9 @@ interface CodexModel {
supportedReasoningEfforts?: CodexReasoningEffortEntry[];
}
const CodexModelListResponseSchema = z.object({
data: z
.array(
z.object({
id: z.string(),
displayName: z.string().optional(),
description: z.string().optional(),
isDefault: z.boolean().optional(),
model: z.string().optional(),
defaultReasoningEffort: z.string().optional(),
supportedReasoningEfforts: z
.array(
z.object({
reasoningEffort: z.string().optional(),
description: z.string().optional(),
}),
)
.optional(),
}),
)
.optional(),
});
interface CodexModelListResponse {
data?: CodexModel[];
}
class CodexAppServerClient {
private readonly rl: readline.Interface;
@@ -766,30 +740,31 @@ class CodexAppServerClient {
private async handleLine(line: string): Promise<void> {
if (!line.trim()) return;
const raw: unknown = JSON.parse(line);
if (!isRecord(raw)) {
const raw = JSON.parse(line);
const msg = toObjectRecord(raw) as unknown as JsonRpcMessage;
if (!msg) {
this.logger.warn({ line }, "Parsed JSON is not an object");
return;
}
if (isJsonRpcResponse(raw)) {
const id = raw.id;
if (raw.result !== undefined || raw.error) {
if (isJsonRpcResponse(msg)) {
const id = msg.id;
if (msg.result !== undefined || msg.error) {
const pending = this.pending.get(id);
if (!pending) return;
clearTimeout(pending.timer);
this.pending.delete(id);
if (raw.error) {
pending.reject(new Error(raw.error.message ?? "Unknown error"));
if (msg.error) {
pending.reject(new Error(msg.error.message ?? "Unknown error"));
} else {
pending.resolve(raw.result);
pending.resolve(msg.result);
}
return;
}
// Server-initiated request
if (isJsonRpcRequest(raw)) {
const request = raw;
if (isJsonRpcRequest(msg)) {
const request = msg;
const handler = this.requestHandlers.get(request.method);
try {
const result = handler ? await handler(request.params) : {};
@@ -804,8 +779,8 @@ class CodexAppServerClient {
}
}
if (isJsonRpcNotification(raw)) {
this.notificationHandler?.(raw.method, raw.params);
if (isJsonRpcNotification(msg)) {
this.notificationHandler?.(msg.method, msg.params);
}
}
}
@@ -4347,16 +4322,14 @@ class CodexAppServerAgentSession implements AgentSession {
}
private handleCommandApprovalRequest(params: unknown): Promise<unknown> {
const parsed = z
.object({
itemId: z.string(),
threadId: z.string(),
turnId: z.string(),
command: z.string().nullable().optional(),
cwd: z.string().nullable().optional(),
reason: z.string().nullable().optional(),
})
.parse(params);
const parsed = params as {
itemId: string;
threadId: string;
turnId: string;
command?: string | null;
cwd?: string | null;
reason?: string | null;
};
const commandPreview = mapCodexExecNotificationToToolCall({
callId: parsed.itemId,
command: parsed.command,
@@ -4398,14 +4371,12 @@ class CodexAppServerAgentSession implements AgentSession {
}
private handleFileChangeApprovalRequest(params: unknown): Promise<unknown> {
const parsed = z
.object({
itemId: z.string(),
threadId: z.string(),
turnId: z.string(),
reason: z.string().nullable().optional(),
})
.parse(params);
const parsed = params as {
itemId: string;
threadId: string;
turnId: string;
reason?: string | null;
};
const requestId = `permission-${parsed.itemId}`;
const request: AgentPermissionRequest = {
id: requestId,
@@ -4435,14 +4406,12 @@ class CodexAppServerAgentSession implements AgentSession {
}
private handleToolApprovalRequest(params: unknown): Promise<unknown> {
const parsed = z
.object({
itemId: z.string(),
threadId: z.string(),
turnId: z.string(),
questions: z.array(z.unknown()),
})
.parse(params);
const parsed = params as {
itemId: string;
threadId: string;
turnId: string;
questions: unknown[];
};
const requestId = `permission-${parsed.itemId}`;
const questions = normalizeCodexQuestionPrompts(parsed.questions);
const request: AgentPermissionRequest = {
@@ -4506,16 +4475,14 @@ export class CodexAppServerAgentClient implements AgentClient {
},
"Spawning Codex app server",
);
const child = spawnProcess(launchPrefix.command, [...launchPrefix.args, "app-server"], {
return spawnProcess(launchPrefix.command, [...launchPrefix.args, "app-server"], {
detached: process.platform !== "win32",
stdio: ["pipe", "pipe", "pipe"],
...createProviderEnvSpec({
runtimeSettings: this.runtimeSettings,
overlays: [launchEnv],
}),
});
assertChildWithPipes(child);
return child;
}) as ChildProcessWithoutNullStreams;
}
async createSession(
@@ -4541,7 +4508,7 @@ export class CodexAppServerAgentClient implements AgentClient {
overrides?: Partial<AgentSessionConfig>,
launchContext?: AgentLaunchContext,
): Promise<AgentSession> {
const storedConfig = (handle.metadata ?? {}) as Partial<AgentSessionConfig>;
const storedConfig = (handle.metadata ?? {}) as unknown as AgentSessionConfig;
const merged: AgentSessionConfig = {
...storedConfig,
...overrides,
@@ -4632,9 +4599,8 @@ export class CodexAppServerAgentClient implements AgentClient {
await client.request("initialize", buildCodexAppServerInitializeParams());
client.notify("initialized", {});
const rawResponse = await client.request("model/list", {});
const parsedResponse = CodexModelListResponseSchema.safeParse(rawResponse);
const models = parsedResponse.success ? (parsedResponse.data.data ?? []) : [];
const response = (await client.request("model/list", {})) as CodexModelListResponse;
const models = Array.isArray(response?.data) ? response.data : [];
const configuredDefaults = await readCodexConfiguredDefaults(client, this.logger);
const configuredDefaultModelId = configuredDefaults.model;
const configuredDefaultThinkingOptionId = configuredDefaults.thinkingOptionId;
@@ -12,9 +12,9 @@ export type ProviderFinalTextReducer = (params: {
}) => string;
export interface ProviderTurnRunner {
startTurn: (prompt: AgentPromptInput, options?: AgentRunOptions) => Promise<{ turnId: string }>;
subscribe: (callback: (event: AgentStreamEvent) => void) => () => void;
getSessionId: () => string | Promise<string>;
startTurn(prompt: AgentPromptInput, options?: AgentRunOptions): Promise<{ turnId: string }>;
subscribe(callback: (event: AgentStreamEvent) => void): () => void;
getSessionId(): string | Promise<string>;
}
export interface RunProviderTurnOptions extends ProviderTurnRunner {
+10 -2
View File
@@ -126,7 +126,6 @@ import type {
AgentProviderRuntimeSettingsMap,
ProviderOverride,
} from "./agent/provider-launch-config.js";
import type { PersistedConfig } from "./persisted-config.js";
import {
ScriptRouteStore,
createScriptProxyMiddleware,
@@ -202,7 +201,6 @@ export interface PaseoDaemonConfig {
downloadTokenTtlMs?: number;
agentProviderSettings?: AgentProviderRuntimeSettingsMap;
providerOverrides?: Record<string, ProviderOverride>;
log?: PersistedConfig["log"];
onLifecycleIntent?: (intent: DaemonLifecycleIntent) => void;
}
@@ -842,6 +840,16 @@ export async function createPaseoDaemon(
github,
);
if (typeof process.send === "function" && process.env.PASEO_SUPERVISED === "1") {
process.send({
type: "paseo:ready",
listen:
boundListenTarget.type === "tcp"
? `${boundListenTarget.host}:${boundListenTarget.port}`
: boundListenTarget.path,
});
}
if (relayEnabled) {
const offer = await createConnectionOfferV2({
serverId,
+1 -33
View File
@@ -3,12 +3,7 @@ import { resolvePaseoNodeEnv } from "./paseo-env.js";
import { z } from "zod";
import type { PaseoDaemonConfig } from "./bootstrap.js";
import {
loadPersistedConfig,
LogFormatSchema,
LogLevelSchema,
type PersistedConfig,
} from "./persisted-config.js";
import { loadPersistedConfig } from "./persisted-config.js";
import type { AgentProvider } from "./agent/agent-sdk-types.js";
import type {
AgentProviderRuntimeSettingsMap,
@@ -40,14 +35,6 @@ function parseBooleanEnv(value: string | undefined): boolean | undefined {
return undefined;
}
function normalizeLogEnv(value: string | undefined): string | undefined {
if (value === undefined) {
return undefined;
}
return value.trim().toLowerCase();
}
export type CliConfigOverrides = Partial<{
listen: string;
relayEnabled: boolean;
@@ -56,24 +43,6 @@ export type CliConfigOverrides = Partial<{
hostnames: HostnamesConfig;
}>;
function resolveLogConfigFromEnv(
env: NodeJS.ProcessEnv,
persisted: ReturnType<typeof loadPersistedConfig>,
): PersistedConfig["log"] {
const envLogLevel = LogLevelSchema.safeParse(normalizeLogEnv(env.PASEO_LOG_LEVEL));
const envLogFormat = LogFormatSchema.safeParse(normalizeLogEnv(env.PASEO_LOG_FORMAT));
if (!envLogLevel.success && !envLogFormat.success) {
return persisted.log;
}
return {
...persisted.log,
...(envLogLevel.success ? { level: envLogLevel.data } : {}),
...(envLogFormat.success ? { format: envLogFormat.data } : {}),
};
}
const OptionalVoiceLlmProviderSchema = z
.union([z.string(), z.null(), z.undefined()])
.transform((value): string | null =>
@@ -302,6 +271,5 @@ export function loadConfig(
voiceLlmModel: voiceLlm.model,
agentProviderSettings: extractAgentProviderSettings(providerOverrides),
providerOverrides,
log: resolveLogConfigFromEnv(env, persisted),
};
}
@@ -134,8 +134,6 @@ export function isProviderAvailable(provider: AgentProvider): Promise<boolean> {
Boolean(process.env.OPENROUTER_API_KEY) ||
existsSync(join(homedir(), ".pi", "agent", "auth.json")))
);
default:
return false;
}
})();
@@ -6,6 +6,7 @@ import os from "node:os";
import { mkdtemp, rm } from "node:fs/promises";
import { Writable } from "node:stream";
import { spawn } from "node:child_process";
import { fileURLToPath } from "node:url";
import { generateLocalPairingOffer } from "../pairing-offer.js";
import { createTestPaseoDaemon } from "../test-utils/paseo-daemon.js";
@@ -201,9 +202,8 @@ describe("ConnectionOfferV2 (daemon E2E)", () => {
const tempHome = await mkdtemp(path.join(os.tmpdir(), "paseo-offer-e2e-"));
const port = await getAvailablePort();
const serverRoot = path.resolve(import.meta.dirname, "../../..");
const supervisorPath = path.join(serverRoot, "scripts/supervisor-entrypoint.ts");
const tsxBin = path.resolve(serverRoot, "../../node_modules/.bin/tsx");
const indexPath = fileURLToPath(new URL("../index.ts", import.meta.url));
const tsxBin = path.resolve(process.cwd(), "../../node_modules/.bin/tsx");
const env = {
...process.env,
@@ -216,7 +216,7 @@ describe("ConnectionOfferV2 (daemon E2E)", () => {
};
const stdoutLines: string[] = [];
const proc = spawn(tsxBin, [supervisorPath, "--dev", "--no-relay"], {
const proc = spawn(tsxBin, [indexPath, "--no-relay"], {
env,
stdio: ["ignore", "pipe", "pipe"],
});
@@ -11,8 +11,6 @@ import { generateLocalPairingOffer } from "../pairing-offer.js";
import { createTestPaseoDaemon } from "../test-utils/paseo-daemon.js";
import { createClientChannel, type Transport } from "@getpaseo/relay/e2ee";
import { buildRelayWebSocketUrl } from "../../shared/daemon-endpoints.js";
import { ConnectionOfferSchema } from "../../shared/connection-offer.js";
import { WSOutboundMessageSchema } from "../../shared/messages.js";
const nodeMajor = Number((process.versions.node ?? "0").split(".")[0] ?? "0");
const shouldRunRelayE2e = process.env.FORCE_RELAY_E2E === "1" || nodeMajor < 25;
@@ -61,7 +59,10 @@ function decodeOfferFromFragmentUrl(url: string): {
}
const encoded = url.slice(idx + marker.length);
const json = Buffer.from(encoded, "base64url").toString("utf8");
const offer = ConnectionOfferSchema.parse(JSON.parse(json));
const offer = JSON.parse(json) as { v?: unknown; serverId?: string; daemonPublicKeyB64?: string };
if (offer.v !== 2) throw new Error("expected offer.v=2");
if (!offer.serverId) throw new Error("offer.serverId missing");
if (!offer.daemonPublicKeyB64) throw new Error("offer.daemonPublicKeyB64 missing");
return { serverId: offer.serverId, daemonPublicKeyB64: offer.daemonPublicKeyB64 };
}
@@ -281,12 +282,27 @@ async function waitForRelayWebSocketReady(port: number, timeout = 60000): Promis
onmessage: (data) => {
try {
const payload = typeof data === "string" ? JSON.parse(data) : data;
const wsMsg = WSOutboundMessageSchema.safeParse(payload);
if (
wsMsg.success &&
wsMsg.data.type === "session" &&
wsMsg.data.message.type === "status" &&
wsMsg.data.message.payload?.status === "server_info"
payload &&
typeof payload === "object" &&
(
payload as {
type?: string;
message?: { type?: string; payload?: { status?: string } };
}
).type === "session" &&
(
payload as {
type?: string;
message?: { type?: string; payload?: { status?: string } };
}
).message?.type === "status" &&
(
payload as {
type?: string;
message?: { type?: string; payload?: { status?: string } };
}
).message?.payload?.status === "server_info"
) {
if (!pingSent && channelRef) {
pingSent = true;
@@ -294,8 +310,17 @@ async function waitForRelayWebSocketReady(port: number, timeout = 60000): Promis
}
return;
}
if (wsMsg.success && wsMsg.data.type === "pong") {
settleResolve(wsMsg.data);
if (
payload &&
typeof payload === "object" &&
(
payload as {
type?: string;
message?: { type?: string; payload?: { status?: string } };
}
).type === "pong"
) {
settleResolve(payload);
ws.close();
}
} catch (err) {
@@ -407,12 +432,27 @@ async function waitForRelayWebSocketReady(port: number, timeout = 60000): Promis
const channel = await createClientChannel(transport, daemonPublicKeyB64, {
onmessage: (data) => {
const payload = typeof data === "string" ? JSON.parse(data) : data;
const wsMsg = WSOutboundMessageSchema.safeParse(payload);
if (
wsMsg.success &&
wsMsg.data.type === "session" &&
wsMsg.data.message.type === "status" &&
wsMsg.data.message.payload?.status === "server_info"
payload &&
typeof payload === "object" &&
(
payload as {
type?: string;
message?: { type?: string; payload?: { status?: string } };
}
).type === "session" &&
(
payload as {
type?: string;
message?: { type?: string; payload?: { status?: string } };
}
).message?.type === "status" &&
(
payload as {
type?: string;
message?: { type?: string; payload?: { status?: string } };
}
).message?.payload?.status === "server_info"
) {
if (!pingSent && channelRef) {
pingSent = true;
@@ -420,9 +460,18 @@ async function waitForRelayWebSocketReady(port: number, timeout = 60000): Promis
}
return;
}
if (wsMsg.success && wsMsg.data.type === "pong") {
if (
payload &&
typeof payload === "object" &&
(
payload as {
type?: string;
message?: { type?: string; payload?: { status?: string } };
}
).type === "pong"
) {
clearTimeout(timeout);
resolve(wsMsg.data);
resolve(payload);
ws.close();
}
},
@@ -2,16 +2,14 @@ import { createPaseoDaemon } from "./bootstrap.js";
import { loadConfig } from "./config.js";
import { resolvePaseoHome } from "./paseo-home.js";
import { createRootLogger } from "./logger.js";
import { loadPersistedConfig } from "./persisted-config.js";
import { acquirePidLock, PidLockError, releasePidLock, updatePidLock } from "./pid-lock.js";
import type { DaemonLifecycleIntent } from "./bootstrap.js";
type SupervisorLifecycleMessage =
| {
type: "paseo:shutdown";
}
| {
type: "paseo:ready";
listen: string;
}
| {
type: "paseo:restart";
reason?: string;
@@ -26,8 +24,9 @@ interface BootstrapResult {
function bootstrapFromEnvironment(): BootstrapResult {
try {
const paseoHome = resolvePaseoHome();
const persistedConfig = loadPersistedConfig(paseoHome);
const logger = createRootLogger(persistedConfig, { paseoHome });
const config = loadConfig(paseoHome);
const logger = createRootLogger({ log: config.log }, { paseoHome, file: false });
return { paseoHome, logger, config };
} catch (err) {
const message = err instanceof Error ? err.message : String(err);
@@ -49,10 +48,12 @@ function applyCliFlagOverrides(config: ReturnType<typeof loadConfig>): void {
}
async function main() {
const { logger, config } = bootstrapFromEnvironment();
const { paseoHome, logger, config } = bootstrapFromEnvironment();
let daemon: Awaited<ReturnType<typeof createPaseoDaemon>> | null = null;
let shutdownPromise: Promise<number> | null = null;
let exitHookInstalled = false;
const supervised = process.env.PASEO_SUPERVISED === "1" && typeof process.send === "function";
let pidLockAcquired = false;
applyCliFlagOverrides(config);
@@ -88,6 +89,10 @@ async function main() {
return 1;
}
await daemon.stop();
if (pidLockAcquired) {
await releasePidLock(paseoHome);
pidLockAcquired = false;
}
clearTimeout(forceExit);
logger.info("Server closed");
return options?.successExitCode ?? 0;
@@ -146,6 +151,11 @@ async function main() {
};
try {
if (!supervised) {
await acquirePidLock(paseoHome, null);
pidLockAcquired = true;
}
daemon = await createPaseoDaemon(
{
...config,
@@ -154,22 +164,40 @@ async function main() {
logger,
);
} catch (err) {
if (pidLockAcquired) {
await releasePidLock(paseoHome);
pidLockAcquired = false;
}
if (err instanceof PidLockError) {
logger.error({ pid: err.existingLock?.pid }, err.message);
process.exit(1);
}
logger.fatal({ err }, "Daemon bootstrap failed");
throw err;
}
try {
await daemon.start();
const listenTarget = daemon.getListenTarget();
const listen =
listenTarget?.type === "tcp"
? `${listenTarget.host}:${listenTarget.port}`
: listenTarget?.path;
if (!listen) {
throw new Error("Daemon did not expose a listen target after startup");
if (!supervised) {
const listenTarget = daemon.getListenTarget();
const listen =
listenTarget?.type === "tcp"
? `${listenTarget.host}:${listenTarget.port}`
: listenTarget?.path;
if (!listen) {
throw new Error("Daemon did not expose a listen target after startup");
}
await updatePidLock(paseoHome, { listen });
}
sendSupervisorLifecycleMessage({ type: "paseo:ready", listen });
} catch (err) {
if (pidLockAcquired) {
await releasePidLock(paseoHome);
pidLockAcquired = false;
}
if (err instanceof PidLockError) {
logger.error({ pid: err.existingLock?.pid }, err.message);
process.exit(1);
}
logger.fatal({ err }, "Daemon failed to start listening");
throw err;
}
+173 -139
View File
@@ -1,82 +1,47 @@
import { existsSync } from "node:fs";
import { mkdir, mkdtemp, readFile, readdir, writeFile } from "node:fs/promises";
import { tmpdir } from "node:os";
import path from "node:path";
import { spawn } from "node:child_process";
import { fileURLToPath } from "node:url";
import { describe, expect, it } from "vitest";
import { describe, it, expect, beforeEach, afterEach } from "vitest";
import { resolveLogConfig } from "./logger.js";
import { loadConfig } from "./config.js";
import type { PersistedConfig } from "./persisted-config.js";
const repoRoot = path.resolve(fileURLToPath(new URL("../../../..", import.meta.url)));
const loggerModuleUrl = new URL("./logger.ts", import.meta.url).href;
async function runLoggerFixture(source: string): Promise<{ stdout: string; stderr: string }> {
const tempDir = await mkdtemp(path.join(tmpdir(), "paseo-logger-fixture-"));
const runnerPath = path.join(tempDir, "runner.mjs");
await writeFile(
runnerPath,
`
import { createRootLogger } from ${JSON.stringify(loggerModuleUrl)};
${source}
`,
);
const child = spawn(process.execPath, ["--import", "tsx", runnerPath], {
cwd: repoRoot,
stdio: ["ignore", "pipe", "pipe"],
});
let stdout = "";
let stderr = "";
child.stdout?.setEncoding("utf8");
child.stderr?.setEncoding("utf8");
child.stdout?.on("data", (chunk) => {
stdout += chunk;
});
child.stderr?.on("data", (chunk) => {
stderr += chunk;
});
const code = await new Promise<number | null>((resolve, reject) => {
child.on("error", reject);
child.on("close", resolve);
});
expect(code, stderr).toBe(0);
return { stdout, stderr };
}
describe("resolveLogConfig", () => {
const originalEnv = process.env;
const paseoHome = "/tmp/paseo-logger-tests";
it("defaults to stdout JSON without file logging", () => {
const result = resolveLogConfig(undefined, { paseoHome });
beforeEach(() => {
process.env = { ...originalEnv };
delete process.env.PASEO_LOG;
delete process.env.PASEO_LOG_FORMAT;
delete process.env.PASEO_LOG_CONSOLE_LEVEL;
delete process.env.PASEO_LOG_FILE_LEVEL;
delete process.env.PASEO_LOG_FILE_PATH;
delete process.env.PASEO_LOG_FILE_ROTATE_SIZE;
delete process.env.PASEO_LOG_FILE_ROTATE_COUNT;
});
afterEach(() => {
process.env = originalEnv;
});
it("returns dual-sink defaults when no config or env vars", () => {
const result = resolveLogConfig(undefined, { paseoHome });
expect(result).toEqual({
level: "info",
level: "debug",
console: {
level: "info",
format: "json",
},
});
});
it("keeps legacy level and format as stdout configuration", () => {
const result = resolveLogConfig({ level: "warn", format: "pretty" }, { paseoHome });
expect(result).toEqual({
level: "warn",
console: {
level: "warn",
format: "pretty",
},
file: {
level: "debug",
path: path.join(paseoHome, "daemon.log"),
rotate: {
maxSize: "10m",
maxFiles: 2,
},
},
});
});
it("enables file output only when log.file is present", () => {
it("uses config.json destination-specific values over defaults", () => {
const config: PersistedConfig = {
log: {
console: {
@@ -85,7 +50,7 @@ describe("resolveLogConfig", () => {
},
file: {
level: "debug",
path: "logs/programmatic.log",
path: "/tmp/custom.log",
rotate: {
maxSize: "25m",
maxFiles: 5,
@@ -93,8 +58,9 @@ describe("resolveLogConfig", () => {
},
},
};
const result = resolveLogConfig(config, { paseoHome });
expect(resolveLogConfig(config, { paseoHome })).toEqual({
expect(result).toEqual({
level: "debug",
console: {
level: "warn",
@@ -102,98 +68,166 @@ describe("resolveLogConfig", () => {
},
file: {
level: "debug",
path: path.join(paseoHome, "logs", "programmatic.log"),
path: "/tmp/custom.log",
rotate: {
maxSize: "25m",
maxFiles: 5,
},
},
});
});
});
describe("loadConfig logger config", () => {
it("applies log format env at the config boundary", async () => {
const root = await mkdtemp(path.join(tmpdir(), "paseo-logger-config-"));
const paseoHome = path.join(root, ".paseo");
await mkdir(paseoHome, { recursive: true });
await writeFile(
path.join(paseoHome, "config.json"),
JSON.stringify({ version: 1, log: { format: "json" } }),
);
it("uses env vars over config.json values", () => {
process.env.PASEO_LOG_CONSOLE_LEVEL = "error";
process.env.PASEO_LOG_FILE_LEVEL = "fatal";
process.env.PASEO_LOG_FORMAT = "json";
process.env.PASEO_LOG_FILE_PATH = "logs/daemon-custom.log";
process.env.PASEO_LOG_FILE_ROTATE_SIZE = "15m";
process.env.PASEO_LOG_FILE_ROTATE_COUNT = "4";
const config = loadConfig(paseoHome, {
env: { PASEO_LOG_FORMAT: "pretty" },
const config: PersistedConfig = {
log: {
console: {
level: "info",
format: "pretty",
},
file: {
level: "trace",
path: "/tmp/will-be-overridden.log",
rotate: {
maxSize: "30m",
maxFiles: 8,
},
},
},
};
const result = resolveLogConfig(config, { paseoHome });
expect(result).toEqual({
level: "error",
console: {
level: "error",
format: "json",
},
file: {
level: "fatal",
path: path.resolve(paseoHome, "logs/daemon-custom.log"),
rotate: {
maxSize: "15m",
maxFiles: 4,
},
},
});
});
expect(config.log?.format).toBe("pretty");
expect(resolveLogConfig(config, { paseoHome }).console.format).toBe("pretty");
});
});
describe("createRootLogger", () => {
it("writes JSON to stdout by default and does not initialize file logging", async () => {
const paseoHome = await mkdtemp(path.join(tmpdir(), "paseo-logger-default-"));
const missingLogDir = path.join(paseoHome, "logs");
const { stdout } = await runLoggerFixture(`
const logger = createRootLogger(undefined, { paseoHome: ${JSON.stringify(paseoHome)} });
logger.info({ proof: "stdout-default" }, "default logger");
logger.flush();
`);
it("keeps backwards compatibility for legacy log.level and log.format", () => {
const config: PersistedConfig = {
log: {
level: "warn",
format: "json",
},
};
expect(stdout).toContain('"proof":"stdout-default"');
expect(stdout).toContain('"msg":"default logger"');
expect(existsSync(path.join(paseoHome, "daemon.log"))).toBe(false);
expect(existsSync(missingLogDir)).toBe(false);
const result = resolveLogConfig(config, { paseoHome });
expect(result).toEqual({
level: "warn",
console: {
level: "warn",
format: "json",
},
file: {
level: "warn",
path: path.join(paseoHome, "daemon.log"),
rotate: {
maxSize: "10m",
maxFiles: 2,
},
},
});
});
it("writes to an explicit file target without creating rotation files", async () => {
const paseoHome = await mkdtemp(path.join(tmpdir(), "paseo-logger-file-"));
const logPath = path.join(paseoHome, "logs", "programmatic.log");
it("keeps backwards compatibility for legacy env vars", () => {
process.env.PASEO_LOG = "error";
process.env.PASEO_LOG_FORMAT = "json";
await runLoggerFixture(`
const logger = createRootLogger(
{ log: { file: { path: ${JSON.stringify(logPath)} } } },
{ paseoHome: ${JSON.stringify(paseoHome)} },
);
logger.info({ proof: "file-explicit" }, "explicit file logger");
logger.flush();
`);
const result = resolveLogConfig(undefined, { paseoHome });
expect(result).toEqual({
level: "error",
console: {
level: "error",
format: "json",
},
file: {
level: "error",
path: path.join(paseoHome, "daemon.log"),
rotate: {
maxSize: "10m",
maxFiles: 2,
},
},
});
});
const logText = await readFile(logPath, "utf8");
const files = await readdir(path.dirname(logPath));
it("supports partial destination config and retains defaults", () => {
const config: PersistedConfig = {
log: {
console: {
level: "warn",
},
},
};
expect(logText).toContain('"proof":"file-explicit"');
expect(logText).toContain('"msg":"explicit file logger"');
expect(files).toEqual(["programmatic.log"]);
const result = resolveLogConfig(config, { paseoHome });
expect(result).toEqual({
level: "debug",
console: {
level: "warn",
format: "pretty",
},
file: {
level: "debug",
path: path.join(paseoHome, "daemon.log"),
rotate: {
maxSize: "10m",
maxFiles: 2,
},
},
});
});
it("can disable file output for supervised workers", async () => {
const paseoHome = await mkdtemp(path.join(tmpdir(), "paseo-logger-no-worker-file-"));
const logPath = path.join(paseoHome, "daemon.log");
const { stdout } = await runLoggerFixture(`
const logger = createRootLogger(
{ log: { file: { path: ${JSON.stringify(logPath)} } } },
{ paseoHome: ${JSON.stringify(paseoHome)}, file: false },
);
logger.info({ proof: "stdout-only" }, "worker logger");
logger.flush();
`);
it("ignores invalid rotate count env var and falls back to config value", () => {
process.env.PASEO_LOG_FILE_ROTATE_COUNT = "0";
const config: PersistedConfig = {
log: {
file: {
rotate: {
maxFiles: 7,
},
},
},
};
expect(stdout).toContain('"proof":"stdout-only"');
expect(stdout).toContain('"msg":"worker logger"');
expect(existsSync(logPath)).toBe(false);
const result = resolveLogConfig(config, { paseoHome });
expect(result.file.rotate.maxFiles).toBe(7);
});
it("keeps pretty output available as a format choice", async () => {
const paseoHome = await mkdtemp(path.join(tmpdir(), "paseo-logger-pretty-"));
const { stdout } = await runLoggerFixture(`
const logger = createRootLogger({ level: "info", format: "pretty" }, {
paseoHome: ${JSON.stringify(paseoHome)},
});
logger.info("pretty logger");
logger.flush();
`);
it("supports all log levels for destination-specific env vars", () => {
const levels: Array<"trace" | "debug" | "info" | "warn" | "error" | "fatal"> = [
"trace",
"debug",
"info",
"warn",
"error",
"fatal",
];
expect(stdout).toContain("pretty logger");
for (const level of levels) {
process.env.PASEO_LOG_CONSOLE_LEVEL = level;
process.env.PASEO_LOG_FILE_LEVEL = level;
const result = resolveLogConfig(undefined, { paseoHome });
expect(result.console.level).toBe(level);
expect(result.file.level).toBe(level);
expect(result.level).toBe(level);
}
});
});
+150 -32
View File
@@ -1,7 +1,8 @@
import { mkdirSync } from "node:fs";
import { existsSync, mkdirSync, readdirSync, renameSync, unlinkSync } from "node:fs";
import path from "node:path";
import pino from "pino";
import pretty from "pino-pretty";
import { createStream as createRotatingFileStream } from "rotating-file-stream";
import type { PersistedConfig } from "./persisted-config.js";
import { resolvePaseoHome } from "./paseo-home.js";
@@ -14,9 +15,13 @@ export interface ResolvedLogConfig {
level: LogLevel;
format: LogFormat;
};
file?: {
file: {
level: LogLevel;
path: string;
rotate: {
maxSize: string;
maxFiles: number;
};
};
}
@@ -29,9 +34,11 @@ type LoggerConfigInput = PersistedConfig | LegacyLogConfig | undefined;
interface ResolveLogConfigOptions {
paseoHome?: string;
file?: boolean;
env?: NodeJS.ProcessEnv;
}
const LOG_LEVELS: Set<LogLevel> = new Set(["trace", "debug", "info", "warn", "error", "fatal"]);
const LOG_FORMATS: Set<LogFormat> = new Set(["pretty", "json"]);
const LOG_LEVEL_PRIORITIES: Record<LogLevel, number> = {
trace: 10,
debug: 20,
@@ -42,8 +49,10 @@ const LOG_LEVEL_PRIORITIES: Record<LogLevel, number> = {
};
const DEFAULT_CONSOLE_LEVEL: LogLevel = "info";
const DEFAULT_CONSOLE_FORMAT: LogFormat = "json";
const DEFAULT_CONSOLE_FORMAT: LogFormat = "pretty";
const DEFAULT_FILE_LEVEL: LogLevel = "debug";
const DEFAULT_FILE_ROTATE_SIZE = "10m";
const DEFAULT_FILE_ROTATE_MAX_FILES = 2;
const DEFAULT_DAEMON_LOG_FILENAME = "daemon.log";
const REDACT_PATHS = [
"authorization",
@@ -60,6 +69,33 @@ const REDACT_PATHS = [
"req.headers.Sec-WebSocket-Protocol",
];
function parseLogLevel(value: string | undefined): LogLevel | undefined {
if (!value || !LOG_LEVELS.has(value as LogLevel)) {
return undefined;
}
return value as LogLevel;
}
function parseLogFormat(value: string | undefined): LogFormat | undefined {
if (!value || !LOG_FORMATS.has(value as LogFormat)) {
return undefined;
}
return value as LogFormat;
}
function parsePositiveInteger(value: string | undefined): number | undefined {
if (!value || value.trim().length === 0) {
return undefined;
}
const parsed = Number.parseInt(value, 10);
if (!Number.isInteger(parsed) || parsed <= 0) {
return undefined;
}
return parsed;
}
function resolveFilePath(paseoHome: string, configuredPath: string | undefined): string {
const fallback = path.join(paseoHome, DEFAULT_DAEMON_LOG_FILENAME);
if (!configuredPath) {
@@ -89,7 +125,7 @@ function resolveConfiguredPaseoHome(options: ResolveLogConfigOptions | undefined
if (options?.paseoHome) {
return options.paseoHome;
}
return resolvePaseoHome();
return resolvePaseoHome(options?.env ?? process.env);
}
function normalizeLoggerConfigInput(config: LoggerConfigInput): PersistedConfig | undefined {
@@ -114,50 +150,126 @@ function normalizeLoggerConfigInput(config: LoggerConfigInput): PersistedConfig
return config as PersistedConfig;
}
function rotateOnRestart(filePath: string, maxFiles: number): void {
if (!existsSync(filePath)) return;
const dir = path.dirname(filePath);
const base = path.basename(filePath);
const now = new Date();
const pad = (n: number) => String(n).padStart(2, "0");
const ts = `${now.getFullYear()}${pad(now.getMonth() + 1)}${pad(now.getDate())}-${pad(now.getHours())}${pad(now.getMinutes())}`;
try {
renameSync(filePath, path.join(dir, `${ts}-00-${base}`));
} catch {
return;
}
// Clean up old rotated logs beyond maxFiles.
// Both our restart-rotated files (YYYYMMDD-HHMM-00-daemon.log) and
// rotating-file-stream's size-rotated files (YYYYMMDD-HHMM-NN-daemon.log)
// end with -${base} and sort chronologically by name.
const rotatedFiles = readdirSync(dir)
.filter((f) => f.endsWith(`-${base}`) && f !== base)
.sort()
.toReversed();
for (const file of rotatedFiles.slice(maxFiles)) {
try {
unlinkSync(path.join(dir, file));
} catch {}
}
}
function toRotatingFileStreamSize(size: string): string {
const trimmed = size.trim();
const match = trimmed.match(/^(\d+)\s*([bBkKmMgG])?$/);
if (!match) {
return trimmed;
}
const value = match[1];
const unit = (match[2] ?? "M").toUpperCase();
return `${value}${unit}`;
}
interface LogLevelResolution {
consoleLevel: LogLevel;
fileLevel?: LogLevel;
fileLevel: LogLevel;
consoleFormat: LogFormat;
}
function resolveLogLevelsAndFormat(
env: NodeJS.ProcessEnv,
persistedLog: NonNullable<ReturnType<typeof normalizeLoggerConfigInput>>["log"] | undefined,
): LogLevelResolution {
const envGlobalLevel = parseLogLevel(env.PASEO_LOG);
const persistedGlobalLevel = persistedLog?.level;
const consoleLevel: LogLevel =
persistedLog?.console?.level ?? persistedGlobalLevel ?? DEFAULT_CONSOLE_LEVEL;
const fileLevel = persistedLog?.file
? (persistedLog.file.level ?? persistedGlobalLevel ?? DEFAULT_FILE_LEVEL)
: undefined;
parseLogLevel(env.PASEO_LOG_CONSOLE_LEVEL) ??
envGlobalLevel ??
persistedLog?.console?.level ??
persistedGlobalLevel ??
DEFAULT_CONSOLE_LEVEL;
const fileLevel: LogLevel =
parseLogLevel(env.PASEO_LOG_FILE_LEVEL) ??
envGlobalLevel ??
persistedLog?.file?.level ??
persistedGlobalLevel ??
DEFAULT_FILE_LEVEL;
const consoleFormat: LogFormat =
persistedLog?.console?.format ?? persistedLog?.format ?? DEFAULT_CONSOLE_FORMAT;
parseLogFormat(env.PASEO_LOG_FORMAT) ??
persistedLog?.console?.format ??
persistedLog?.format ??
DEFAULT_CONSOLE_FORMAT;
return { consoleLevel, fileLevel, consoleFormat };
}
interface RotateResolution {
maxSize: string;
maxFiles: number;
}
function resolveRotateConfig(
env: NodeJS.ProcessEnv,
persistedLog: NonNullable<ReturnType<typeof normalizeLoggerConfigInput>>["log"] | undefined,
): RotateResolution {
return {
maxSize:
env.PASEO_LOG_FILE_ROTATE_SIZE?.trim() ||
persistedLog?.file?.rotate?.maxSize ||
DEFAULT_FILE_ROTATE_SIZE,
maxFiles:
parsePositiveInteger(env.PASEO_LOG_FILE_ROTATE_COUNT) ??
persistedLog?.file?.rotate?.maxFiles ??
DEFAULT_FILE_ROTATE_MAX_FILES,
};
}
export function resolveLogConfig(
configInput: LoggerConfigInput,
options?: ResolveLogConfigOptions,
): ResolvedLogConfig {
const persistedConfig = normalizeLoggerConfigInput(configInput);
const env = options?.env ?? process.env;
const paseoHome = resolveConfiguredPaseoHome(options);
const persistedLog = persistedConfig?.log;
const { consoleLevel, fileLevel, consoleFormat } = resolveLogLevelsAndFormat(persistedLog);
const file =
options?.file !== false && persistedLog?.file
? {
level: fileLevel ?? DEFAULT_FILE_LEVEL,
path: resolveFilePath(paseoHome, persistedLog.file.path),
}
: undefined;
const { consoleLevel, fileLevel, consoleFormat } = resolveLogLevelsAndFormat(env, persistedLog);
const filePath = resolveFilePath(paseoHome, env.PASEO_LOG_FILE_PATH ?? persistedLog?.file?.path);
const rotate = resolveRotateConfig(env, persistedLog);
return {
level: minLogLevel(file ? [consoleLevel, file.level] : [consoleLevel]),
level: minLogLevel([consoleLevel, fileLevel]),
console: {
level: consoleLevel,
format: consoleFormat,
},
...(file ? { file } : {}),
file: {
level: fileLevel,
path: filePath,
rotate,
},
};
}
@@ -166,26 +278,32 @@ export function createRootLogger(
options?: ResolveLogConfigOptions,
): pino.Logger {
const config = resolveLogConfig(configInput, options);
if (config.file) {
mkdirSync(path.dirname(config.file.path), { recursive: true });
}
const stream =
mkdirSync(path.dirname(config.file.path), { recursive: true });
const consoleStream =
config.console.format === "pretty"
? pretty({
colorize: true,
singleLine: true,
ignore: "pid,hostname",
destination: config.file?.path ?? 1,
})
: pino.destination({ dest: config.file?.path ?? 1, sync: false });
: pino.destination({ dest: 1, sync: false });
rotateOnRestart(config.file.path, config.file.rotate.maxFiles);
const fileStream = createRotatingFileStream(path.basename(config.file.path), {
path: path.dirname(config.file.path),
size: toRotatingFileStreamSize(config.file.rotate.maxSize),
maxFiles: config.file.rotate.maxFiles,
});
return pino(
{
level: config.file?.level ?? config.console.level,
redact: { paths: REDACT_PATHS, remove: true },
},
stream,
{ level: config.level, redact: { paths: REDACT_PATHS, remove: true } },
pino.multistream([
{ level: config.console.level, stream: consoleStream },
{ level: config.file.level, stream: fileStream },
]),
);
}
+1 -1
View File
@@ -13,7 +13,7 @@ function parseBooleanEnv(value: string | undefined): boolean | undefined {
function shouldPrintPairingQr(): boolean {
const env = parseBooleanEnv(process.env.PASEO_PAIRING_QR);
if (env !== undefined) return env;
return process.stdout.isTTY ?? false;
return Boolean(process.stdout.isTTY);
}
export async function renderPairingQr(url: string): Promise<string> {
@@ -9,8 +9,8 @@ import {
} from "./agent/provider-launch-config.js";
import type { AgentProviderRuntimeSettingsMap } from "./agent/provider-launch-config.js";
export const LogLevelSchema = z.enum(["trace", "debug", "info", "warn", "error", "fatal"]);
export const LogFormatSchema = z.enum(["pretty", "json"]);
const LogLevelSchema = z.enum(["trace", "debug", "info", "warn", "error", "fatal"]);
const LogFormatSchema = z.enum(["pretty", "json"]);
const LogConfigSchema = z
.object({
+61 -67
View File
@@ -32,21 +32,6 @@ import type {
TurnDetectionProvider,
TurnDetectionSession,
} from "./speech/turn-detection-provider.js";
import {
asSessionInternals as asSessionInternalsHelper,
asAgentManager,
asAgentStorage,
asDownloadTokenStore,
asPushTokenStore,
asChatService,
asScheduleService,
asLoopService,
asCheckoutDiffManager,
asGitHubService,
asWorkspaceGitService,
asDaemonConfigStore,
createProviderSnapshotManagerStub,
} from "./test-utils/session-stubs.js";
interface SessionHandlerInternals {
startVoiceTurnController(): Promise<void>;
@@ -79,14 +64,10 @@ interface SessionHandlerInternals {
handleStashPopRequest(params: unknown): Promise<unknown>;
createPaseoWorktree(params: unknown): Promise<unknown>;
handleStartWorkspaceScriptRequest(params: unknown): Promise<unknown>;
getProviderRegistry(): unknown;
sttManager: {
transcribe(audio: Buffer, format: string): Promise<unknown>;
};
}
function asSessionInternals(session: Session): SessionHandlerInternals {
return asSessionInternalsHelper<SessionHandlerInternals>(session);
return session as unknown as SessionHandlerInternals;
}
function createBinaryMessageHandler(
@@ -269,9 +250,9 @@ interface SessionForTestOptions {
};
workspaceRegistry?: { get: ReturnType<typeof vi.fn> };
projectRegistry?: Partial<SessionOptions["projectRegistry"]>;
terminalManager?: SessionOptions["terminalManager"];
scriptRouteStore?: SessionOptions["scriptRouteStore"];
scriptRuntimeStore?: SessionOptions["scriptRuntimeStore"];
terminalManager?: unknown;
scriptRouteStore?: unknown;
scriptRuntimeStore?: unknown;
getDaemonTcpPort?: () => number | null;
getDaemonTcpHost?: () => string | null;
providerSnapshotManager?: ProviderSnapshotManager;
@@ -309,17 +290,17 @@ function createSessionForTest(options: SessionForTestOptions = {}): Session {
onMessage: (message) => messages.push(message),
onBinaryMessage: createBinaryMessageHandler(options.binaryMessages),
logger,
downloadTokenStore: asDownloadTokenStore(),
pushTokenStore: asPushTokenStore(),
downloadTokenStore: {} as unknown as SessionOptions["downloadTokenStore"],
pushTokenStore: {} as unknown as SessionOptions["pushTokenStore"],
paseoHome: "/tmp/paseo-home",
agentManager: asAgentManager({
agentManager: {
listAgents: vi.fn(() => []),
subscribe: vi.fn(() => () => {}),
}),
agentStorage: asAgentStorage({
} as unknown as SessionOptions["agentManager"],
agentStorage: {
list: vi.fn().mockResolvedValue([]),
}),
projectRegistry: options.projectRegistry ?? {
} as unknown as SessionOptions["agentStorage"],
projectRegistry: (options.projectRegistry ?? {
list: vi.fn().mockResolvedValue([]),
get: vi.fn(),
upsert: vi.fn(),
@@ -327,30 +308,30 @@ function createSessionForTest(options: SessionForTestOptions = {}): Session {
remove: vi.fn(),
initialize: vi.fn(),
existsOnDisk: vi.fn(),
},
workspaceRegistry: options.workspaceRegistry ?? {
}) as unknown as SessionOptions["projectRegistry"],
workspaceRegistry: (options.workspaceRegistry ?? {
get: vi.fn(),
list: vi.fn().mockResolvedValue([]),
},
chatService: asChatService(),
scheduleService: asScheduleService(),
loopService: asLoopService(),
checkoutDiffManager: asCheckoutDiffManager(checkoutDiffManager),
github: asGitHubService(github),
workspaceGitService: asWorkspaceGitService(workspaceGitService),
daemonConfigStore: asDaemonConfigStore({
}) as unknown as SessionOptions["workspaceRegistry"],
chatService: {} as unknown as SessionOptions["chatService"],
scheduleService: {} as unknown as SessionOptions["scheduleService"],
loopService: {} as unknown as SessionOptions["loopService"],
checkoutDiffManager: checkoutDiffManager as unknown as SessionOptions["checkoutDiffManager"],
github: github as unknown as SessionOptions["github"],
workspaceGitService: workspaceGitService as unknown as SessionOptions["workspaceGitService"],
daemonConfigStore: {
get: vi.fn(() => ({
mcp: { injectIntoAgents: false },
providers: {},
})),
onChange: vi.fn(() => () => {}),
}),
} as unknown as SessionOptions["daemonConfigStore"],
stt: options.stt ?? null,
tts: null,
terminalManager: options.terminalManager ?? null,
terminalManager: (options.terminalManager ?? null) as SessionOptions["terminalManager"],
providerSnapshotManager: options.providerSnapshotManager,
scriptRouteStore: options.scriptRouteStore,
scriptRuntimeStore: options.scriptRuntimeStore,
scriptRouteStore: options.scriptRouteStore as SessionOptions["scriptRouteStore"],
scriptRuntimeStore: options.scriptRuntimeStore as SessionOptions["scriptRuntimeStore"],
getDaemonTcpPort: options.getDaemonTcpPort,
getDaemonTcpHost: options.getDaemonTcpHost,
voice: options.voice,
@@ -418,7 +399,16 @@ function createVoiceSessionHarness() {
const sendAgentMessage = vi
.spyOn(internals, "handleSendAgentMessage")
.mockResolvedValue({ ok: true });
const transcribe = vi.spyOn(asSessionInternals(session).sttManager, "transcribe");
const transcribe = vi.spyOn(
(
session as unknown as {
sttManager: {
transcribe(audio: Buffer, format: string): Promise<unknown>;
};
}
).sttManager,
"transcribe",
);
return {
session,
@@ -916,6 +906,20 @@ function createWorkspaceGitSnapshot(
};
}
function createProviderSnapshotManagerStub(): ProviderSnapshotManager {
const stub = {
getSnapshot: vi.fn(() => []),
refreshSnapshotForCwd: vi.fn(async () => {}),
refreshSettingsSnapshot: vi.fn(async () => {}),
warmUpSnapshotForCwd: vi.fn(async () => {}),
on: vi.fn(),
off: vi.fn(),
};
stub.on.mockImplementation(() => stub);
stub.off.mockImplementation(() => stub);
return stub as unknown as ProviderSnapshotManager;
}
afterEach(() => {
vi.clearAllMocks();
});
@@ -1035,11 +1039,7 @@ describe("session PR status payload normalization", () => {
describe("session provider refresh cwd routing", () => {
test("routes no-cwd provider snapshot refreshes through settings refresh", async () => {
const {
manager: providerSnapshotManager,
refreshSettingsSnapshot,
refreshSnapshotForCwd,
} = createProviderSnapshotManagerStub();
const providerSnapshotManager = createProviderSnapshotManagerStub();
const session = createSessionForTest({ providerSnapshotManager });
await session.handleMessage({
@@ -1048,18 +1048,14 @@ describe("session provider refresh cwd routing", () => {
requestId: "refresh-settings",
});
expect(refreshSettingsSnapshot).toHaveBeenCalledWith({
expect(providerSnapshotManager.refreshSettingsSnapshot).toHaveBeenCalledWith({
providers: ["codex"],
});
expect(refreshSnapshotForCwd).not.toHaveBeenCalled();
expect(providerSnapshotManager.refreshSnapshotForCwd).not.toHaveBeenCalled();
});
test("routes cwd provider snapshot refreshes through workspace refresh", async () => {
const {
manager: providerSnapshotManager,
refreshSnapshotForCwd,
refreshSettingsSnapshot,
} = createProviderSnapshotManagerStub();
const providerSnapshotManager = createProviderSnapshotManagerStub();
const session = createSessionForTest({ providerSnapshotManager });
await session.handleMessage({
@@ -1069,11 +1065,11 @@ describe("session provider refresh cwd routing", () => {
requestId: "refresh-workspace",
});
expect(refreshSnapshotForCwd).toHaveBeenCalledWith({
expect(providerSnapshotManager.refreshSnapshotForCwd).toHaveBeenCalledWith({
cwd: "/tmp/workspace-refresh",
providers: ["codex"],
});
expect(refreshSettingsSnapshot).not.toHaveBeenCalled();
expect(providerSnapshotManager.refreshSettingsSnapshot).not.toHaveBeenCalled();
});
test("normalizes legacy model and mode list requests without cwd to home", async () => {
@@ -1081,7 +1077,7 @@ describe("session provider refresh cwd routing", () => {
const session = createSessionForTest({ messages });
const fetchModels = vi.fn(async () => []);
const fetchModes = vi.fn(async () => []);
asSessionInternals(session).getProviderRegistry = () => ({
(session as unknown as { getProviderRegistry: () => unknown }).getProviderRegistry = () => ({
codex: createTestProviderDefinition({
fetchModels,
fetchModes,
@@ -1105,8 +1101,7 @@ describe("session provider refresh cwd routing", () => {
test("legacy model list request treats disabled snapshot entries as unavailable without warming", async () => {
const messages: unknown[] = [];
const { manager: providerSnapshotManager, warmUpSnapshotForCwd } =
createProviderSnapshotManagerStub();
const providerSnapshotManager = createProviderSnapshotManagerStub();
providerSnapshotManager.getSnapshot = vi.fn(() => [
{
provider: "codex",
@@ -1122,7 +1117,7 @@ describe("session provider refresh cwd routing", () => {
requestId: "models-disabled",
});
expect(warmUpSnapshotForCwd).not.toHaveBeenCalled();
expect(providerSnapshotManager.warmUpSnapshotForCwd).not.toHaveBeenCalled();
expect(messages).toContainEqual({
type: "list_provider_models_response",
payload: {
@@ -1136,8 +1131,7 @@ describe("session provider refresh cwd routing", () => {
test("legacy mode list request treats disabled snapshot entries as unavailable without warming", async () => {
const messages: unknown[] = [];
const { manager: providerSnapshotManager, warmUpSnapshotForCwd } =
createProviderSnapshotManagerStub();
const providerSnapshotManager = createProviderSnapshotManagerStub();
providerSnapshotManager.getSnapshot = vi.fn(() => [
{
provider: "codex",
@@ -1153,7 +1147,7 @@ describe("session provider refresh cwd routing", () => {
requestId: "modes-disabled",
});
expect(warmUpSnapshotForCwd).not.toHaveBeenCalled();
expect(providerSnapshotManager.warmUpSnapshotForCwd).not.toHaveBeenCalled();
expect(messages).toContainEqual({
type: "list_provider_modes_response",
payload: {
@@ -1181,7 +1175,7 @@ describe("session provider refresh cwd routing", () => {
label: "Should not fetch",
},
]);
asSessionInternals(session).getProviderRegistry = () => ({
(session as unknown as { getProviderRegistry: () => unknown }).getProviderRegistry = () => ({
codex: createTestProviderDefinition({
enabled: false,
fetchModels,
@@ -1,7 +1,6 @@
import { describe, expect, test, vi } from "vitest";
import type pino from "pino";
import { Session, type SessionOptions } from "./session.js";
import { asInternals, createStub } from "./test-utils/class-mocks.js";
import type {
WorkspaceGitListener,
WorkspaceGitRuntimeSnapshot,
@@ -151,20 +150,20 @@ function createSessionForWorkspaceGitWatchTests(): {
const session = new Session({
clientId: "test-client",
onMessage: (message) => emitted.push(message as { type: string; payload: unknown }),
logger: createStub<pino.Logger>(logger),
downloadTokenStore: createStub<SessionOptions["downloadTokenStore"]>({}),
pushTokenStore: createStub<SessionOptions["pushTokenStore"]>({}),
logger: logger as unknown as pino.Logger,
downloadTokenStore: {} as SessionOptions["downloadTokenStore"],
pushTokenStore: {} as SessionOptions["pushTokenStore"],
paseoHome: "/tmp/paseo-test",
agentManager: createStub<SessionOptions["agentManager"]>({
agentManager: {
subscribe: () => () => {},
listAgents: () => [],
getAgent: () => null,
}),
agentStorage: createStub<SessionOptions["agentStorage"]>({
} as unknown as SessionOptions["agentManager"],
agentStorage: {
list: async () => [],
get: async () => null,
}),
projectRegistry: createStub<SessionOptions["projectRegistry"]>({
} as unknown as SessionOptions["agentStorage"],
projectRegistry: {
initialize: async () => {},
existsOnDisk: async () => true,
list: async () => Array.from(projects.values()),
@@ -180,8 +179,8 @@ function createSessionForWorkspaceGitWatchTests(): {
remove: async (projectId: string) => {
projects.delete(projectId);
},
}),
workspaceRegistry: createStub<SessionOptions["workspaceRegistry"]>({
} as unknown as SessionOptions["projectRegistry"],
workspaceRegistry: {
initialize: async () => {},
existsOnDisk: async () => true,
list: async () => Array.from(workspaces.values()),
@@ -197,8 +196,8 @@ function createSessionForWorkspaceGitWatchTests(): {
remove: async (workspaceId: string) => {
workspaces.delete(workspaceId);
},
}),
checkoutDiffManager: createStub<SessionOptions["checkoutDiffManager"]>({
} as unknown as SessionOptions["workspaceRegistry"],
checkoutDiffManager: {
subscribe: async () => ({
initial: { cwd: "/tmp", files: [], error: null },
unsubscribe: () => {},
@@ -211,15 +210,15 @@ function createSessionForWorkspaceGitWatchTests(): {
checkoutDiffFallbackRefreshTargetCount: 0,
}),
dispose: () => {},
}),
} as unknown as SessionOptions["checkoutDiffManager"],
workspaceGitService,
mcpBaseUrl: null,
stt: null,
tts: null,
terminalManager: null,
});
} as unknown as SessionOptions);
asInternals<SessionInternals>(session).listAgentPayloads = async () => [];
(session as unknown as SessionInternals).listAgentPayloads = async () => [];
return {
session,
@@ -268,7 +267,7 @@ describe("workspace git watch targets", () => {
test("emits one workspace_update when the workspace git service emits a changed snapshot", async () => {
const { session, emitted, projects, workspaces, workspaceGitService, subscriptions } =
createSessionForWorkspaceGitWatchTests();
const sessionAny = asInternals<SessionInternals>(session);
const sessionAny = session as unknown as SessionInternals;
seedGitWorkspace({
projects,
workspaces,
@@ -343,7 +342,7 @@ describe("workspace git watch targets", () => {
test("emits checkout_status_update to a client subscribed to the workspace git target", async () => {
const { session, emitted, projects, workspaces, workspaceGitService, subscriptions } =
createSessionForWorkspaceGitWatchTests();
const sessionAny = asInternals<SessionInternals>(session);
const sessionAny = session as unknown as SessionInternals;
seedGitWorkspace({
projects,
workspaces,
@@ -406,7 +405,7 @@ describe("workspace git watch targets", () => {
test("embeds PR status in checkout_status_update for GitHub-inclusive snapshot pushes", async () => {
const { session, emitted, projects, workspaces, subscriptions } =
createSessionForWorkspaceGitWatchTests();
const sessionAny = asInternals<SessionInternals>(session);
const sessionAny = session as unknown as SessionInternals;
seedGitWorkspace({
projects,
workspaces,
@@ -11,7 +11,6 @@ import { expect, test, vi } from "vitest";
import { Session, type SessionOptions } from "./session.js";
import type { SessionOutboundMessage } from "../shared/messages.js";
import { createNoopWorkspaceGitService } from "./test-utils/workspace-git-service-stub.js";
import { asInternals, createStub } from "./test-utils/class-mocks.js";
import {
createPersistedProjectRecord,
createPersistedWorkspaceRecord,
@@ -88,11 +87,11 @@ function createHarness(input: {
clientId: "test",
appVersion: null,
onMessage: (m) => emitted.push(m),
logger: createStub<SessionOptions["logger"]>(logger),
downloadTokenStore: createStub<SessionOptions["downloadTokenStore"]>({}),
pushTokenStore: createStub<SessionOptions["pushTokenStore"]>({}),
logger: logger as unknown as SessionOptions["logger"],
downloadTokenStore: {} as unknown as SessionOptions["downloadTokenStore"],
pushTokenStore: {} as unknown as SessionOptions["pushTokenStore"],
paseoHome: mkdtempSync(path.join(tmpdir(), "paseo-invariant-test-")),
agentManager: createStub<SessionOptions["agentManager"]>({
agentManager: {
subscribe: () => () => {},
listAgents: () => [],
getAgent: () => null,
@@ -100,12 +99,12 @@ function createHarness(input: {
archiveSnapshot: async () => ({}),
clearAgentAttention: async () => {},
notifyAgentState: () => {},
}),
agentStorage: createStub<SessionOptions["agentStorage"]>({
} as unknown as SessionOptions["agentManager"],
agentStorage: {
list: async () => [],
get: async () => null,
}),
projectRegistry: createStub<SessionOptions["projectRegistry"]>({
} as unknown as SessionOptions["agentStorage"],
projectRegistry: {
initialize: async () => {},
existsOnDisk: async () => true,
list: async () => Array.from(projects.values()),
@@ -120,8 +119,8 @@ function createHarness(input: {
remove: async (id: string) => {
projects.delete(id);
},
}),
workspaceRegistry: createStub<SessionOptions["workspaceRegistry"]>({
} as unknown as SessionOptions["projectRegistry"],
workspaceRegistry: {
initialize: async () => {},
existsOnDisk: async () => true,
list: async () => Array.from(workspaces.values()),
@@ -136,11 +135,11 @@ function createHarness(input: {
remove: async (id: string) => {
workspaces.delete(id);
},
}),
chatService: createStub<SessionOptions["chatService"]>({}),
scheduleService: createStub<SessionOptions["scheduleService"]>({}),
loopService: createStub<SessionOptions["loopService"]>({}),
checkoutDiffManager: createStub<SessionOptions["checkoutDiffManager"]>({
} as unknown as SessionOptions["workspaceRegistry"],
chatService: {} as unknown as SessionOptions["chatService"],
scheduleService: {} as unknown as SessionOptions["scheduleService"],
loopService: {} as unknown as SessionOptions["loopService"],
checkoutDiffManager: {
subscribe: async () => ({
initial: { cwd: "/tmp", files: [], error: null },
unsubscribe: () => {},
@@ -153,12 +152,12 @@ function createHarness(input: {
checkoutDiffFallbackRefreshTargetCount: 0,
}),
dispose: () => {},
}),
} as unknown as SessionOptions["checkoutDiffManager"],
workspaceGitService,
daemonConfigStore: createStub<SessionOptions["daemonConfigStore"]>({
daemonConfigStore: {
get: () => ({ mcp: { injectIntoAgents: false }, providers: {} }),
onChange: () => () => {},
}),
} as unknown as SessionOptions["daemonConfigStore"],
mcpBaseUrl: null,
stt: null,
tts: null,
@@ -169,7 +168,7 @@ function createHarness(input: {
}
async function openProject(session: Session, cwd: string, requestId = "req-1") {
await asInternals<{ handleMessage(m: unknown): Promise<unknown> }>(session).handleMessage({
await (session as unknown as { handleMessage(m: unknown): Promise<unknown> }).handleMessage({
type: "open_project_request",
cwd,
requestId,
@@ -437,9 +436,11 @@ test("S12: findWorkspaceByDirectory does not return archived ancestor via prefix
workspaces: [dirWorkspace("/parent", archivedAt)],
projects: [dirProject("/parent", archivedAt)],
});
const found = await asInternals<{
findWorkspaceByDirectory(cwd: string): Promise<unknown>;
}>(h.session).findWorkspaceByDirectory("/parent/child");
const found = await (
h.session as unknown as {
findWorkspaceByDirectory(cwd: string): Promise<unknown>;
}
).findWorkspaceByDirectory("/parent/child");
expect(found).toBeNull();
});
File diff suppressed because it is too large Load Diff
@@ -6,7 +6,6 @@ import { afterEach, describe, expect, test, vi } from "vitest";
import { Session } from "./session.js";
import type { SessionOptions } from "./session.js";
import { createTestPaseoDaemon } from "./test-utils/paseo-daemon.js";
import { asInternals, createStub } from "./test-utils/class-mocks.js";
interface SessionInternals {
archiveAgentForClose(agentId: string): Promise<{ archivedAt: string }>;
@@ -91,53 +90,51 @@ describe("snapshot mutation ownership boundary", () => {
error: vi.fn(),
};
const session = asInternals<SessionInternals>(
new Session({
clientId: "test-client",
onMessage,
logger: createStub<SessionOptions["logger"]>(logger),
downloadTokenStore: createStub<SessionOptions["downloadTokenStore"]>({}),
pushTokenStore: createStub<SessionOptions["pushTokenStore"]>({}),
paseoHome: "/tmp/paseo-test",
agentManager: createStub<SessionOptions["agentManager"]>({
subscribe: () => () => {},
listAgents: () => [],
getAgent: () => null,
archiveSnapshot,
updateAgentMetadata,
}),
agentStorage: createStub<SessionOptions["agentStorage"]>({
list: async () => [],
get: async () => storedRecord,
applySnapshot: directStorageWrite,
upsert: directStorageWrite,
}),
projectRegistry: createStub<SessionOptions["projectRegistry"]>({
initialize: async () => {},
existsOnDisk: async () => true,
list: async () => [],
get: async () => null,
upsert: async () => {},
archive: async () => {},
remove: async () => {},
}),
workspaceRegistry: createStub<SessionOptions["workspaceRegistry"]>({
initialize: async () => {},
existsOnDisk: async () => true,
list: async () => [],
get: async () => null,
upsert: async () => {},
archive: async () => {},
remove: async () => {},
}),
createAgentMcpTransport: async () => {
throw new Error("not used");
},
stt: null,
tts: null,
terminalManager: null,
}),
);
const session = new Session({
clientId: "test-client",
onMessage,
logger: logger as unknown as SessionOptions["logger"],
downloadTokenStore: {} as unknown as SessionOptions["downloadTokenStore"],
pushTokenStore: {} as unknown as SessionOptions["pushTokenStore"],
paseoHome: "/tmp/paseo-test",
agentManager: {
subscribe: () => () => {},
listAgents: () => [],
getAgent: () => null,
archiveSnapshot,
updateAgentMetadata,
} as unknown as SessionOptions["agentManager"],
agentStorage: {
list: async () => [],
get: async () => storedRecord,
applySnapshot: directStorageWrite,
upsert: directStorageWrite,
} as unknown as SessionOptions["agentStorage"],
projectRegistry: {
initialize: async () => {},
existsOnDisk: async () => true,
list: async () => [],
get: async () => null,
upsert: async () => {},
archive: async () => {},
remove: async () => {},
} as unknown as SessionOptions["projectRegistry"],
workspaceRegistry: {
initialize: async () => {},
existsOnDisk: async () => true,
list: async () => [],
get: async () => null,
upsert: async () => {},
archive: async () => {},
remove: async () => {},
} as unknown as SessionOptions["workspaceRegistry"],
createAgentMcpTransport: async () => {
throw new Error("not used");
},
stt: null,
tts: null,
terminalManager: null,
}) as unknown as SessionInternals;
const archiveResult = await session.archiveAgentForClose("agent-1");
expect(archiveSnapshot).toHaveBeenCalledTimes(1);

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