Files
paseo/packages/client/src/daemon-client.test.ts
T
Mohamed Boudra 28c188513e feat: plugin API for agent options, metadata, composer and New workspace (#8744)
* feat(protocol): option controls, options patch, and provider.controls.list

Add AgentOptionControl descriptors, snapshot options/controls, an options patch on
agent.config.apply, provider.controls.list RPC, and server_info.features.providerOptionControls.
featureValues stays accepted on the wire (COMPAT).

* feat(server): fold featureValues into providerOptions and describe composer controls

Providers emit option controls over top-level providerOptions keys; the daemon stores one
options record per agent, merges config.json defaults as effective values, and applies patches
live via AgentSession.applyOptions. Legacy featureValues (wire, stored records, profiles,
schedules, hooks, MCP) fold into providerOptions; snapshots still carry features built from
controls. Snapshot options expose only keys that have a control.

* fix(server): address Phase 1 audit findings on option controls

- Removing auto_accept (OpenCode, ACP) turns live approval off; ACP selects return to their session default.
- agent.create.before hook featureValues edits override options the create already set.
- Claude commits fast_mode only after the runtime accepts it.
- Codex snapshots show a saved Fast preference as the tier the model resolves it to (AgentSession.displayOptions).
- Profiles are folded into options in the daemon config store; the config API still answers with featureValues.
- Snapshot options and provider.controls.list carry all effective options.
- Test: Claude fast_mode survives a restart through stored providerOptions.

* fix(server): keep snapshot options as configured, restore OMP baseline, cover options through public interfaces

- Codex: drop displayOptions; a select choice may carry aliases so a saved Fast preference selects the model's Fast tier from options + controls alone.
- OMP: removing fast_mode restores the runtime's starting setting.
- ACP/Cursor removal tests run through real session initialization.
- agent.config.apply of a non-control option reaches the provider and shows in the next snapshot (daemon e2e).
- Daemon config store persists legacy-featureValues profiles as options on the next profile save.

* fix(server): read a pre-v0.10 Codex fast_mode record as service_tier "fast" and store it that way

A record holding `fast_mode: true` without `service_tier` ran Fast but the speed control showed
Normal, and picking Normal then wrote "default". The Codex session now normalizes the old boolean
at construction and reports the new shape through its existing options_changed event.

* fix(server): show Fast for a stored Codex fast_mode: true without rewriting the record

Reverts the storage migration from a987b4a43. The Codex speed control now takes its default from
the session's current options with the same rule the runtime uses (an explicit service_tier wins;
fast_mode: true applies only while service_tier is absent). This holds on create, resume, live
apply and for config.json defaults, and stored options stay as set.

* chore(website): regenerate the config schema for profile options

* refactor(server): providers report option differences once, after create, resume and apply

Providers used to write their own reports into the agent's options while a session was being
created, resumed or updated, which needed a growing set of reconciliation, baseline and
suppression rules to stay in step with the daemon.

The contract is now transactional. A provider opens or updates with the options it is given and
emits nothing while doing so; if the result differs it says so once, afterwards, as
`options_changed`. Live apply hands the provider only the keys the patch sets and the daemon stores
them onto the agent's current options when the provider accepts, so the last write wins. A change
the provider makes on its own is stored and forwarded, not undone. Removing an option drops the key
and sends nothing live. Controls carry no state; `options` is the only option state.

A legacy `agent.create.before` hook's `featureValues` edits are applied as a patch over the
returned options, so its deletions and replacements hold. The unreleased `aliases` field on option
choices is gone; a pre-v0.10 Codex Fast preference is reported once as the model's tier id.

* refactor(server): providers return what apply and open ended with instead of reporting it afterwards

applyOptions returns the options the session ended up with and the session
exposes its open difference as openOptionsPatch, so the manager stores the
patch and the provider's answer in one step. Removal is an ordinary patch:
the provider receives the default the key falls back to, or null, and returns
what it kept. Controls no longer take a default from a stored legacy feature
value.

* fix(server): apply an old app's profile featureValues over the profile's stored options

The unchanged app's profile form only knows the controls, so a name-only
edit dropped options it never shows, such as sandbox_mode. Its
featureValues now apply as a patch over the options already stored for the
profile (null removes a key), and a write with no featureValues keeps them.

* fix(server): ACP reports the selects it moved during open or apply as that call's result

A config_option_update that arrived before the write response was emitted
as an event on top of the result the manager stores. Notifications during
open or apply now fold into the open patch or apply result, and a rejected
apply reports what the agent moved since nothing is stored. Live options
are validated with the creation schema. Tests drive a stdio ACP agent.

* fix(server): providers validate live option patches and Claude reports Fast dropped by a model switch

Live patches go through the schema used at creation. A Claude model switch
that cannot keep Fast reports fast_mode as removed so the stored options
match the session.

* fix(server): Claude reports Fast as off, not removed, when a model switch drops it

A removed key lets a config.json default of fast_mode: true fill back in
while Fast is off at runtime.

* fix(server): fold an old app's profile featureValues by the provider and options the write carries

A write with options folds featureValues over those options. A write
without them patches the stored options, unless the profile moved to
another provider, which starts from no options.

* docs(server): a rejected apply reports what the provider moved after the call ends

* test(server): a patch key is resolved against nested configured defaults

* test(server): a setting a plugin changes on its own is forwarded as options_changed

* test(server): assert the forwarded plugin setting without nesting callbacks

* test(server): removal of auto_accept returns ACP to asking; Claude keeps opening restrictions across a patch

* fix(server): Codex returns the tier it resolved and drops a speed the new model lacks

A live service_tier: fast is stored as the catalog's Fast tier id instead of
a value the control cannot show, and a model switch to a model without the
stored speed reports Normal so choosing a supporting model again does not
bring the paid tier back.

* fix(server): Claude applies only the keys a patch sets over its current options

A Fast patch kept a copy of the options taken before the runtime answered, and
assigning it afterwards dropped a patch that landed meanwhile.

* fix(server): a plugin that moves settings and then refuses an apply reports what moved

* refactor(server): the legacy featureValues folds take one named-input object

* fix(server): a live patch is rejected for an option the session only reads at start, and opening reports every option it ended up with

A stored option has to describe what runs. OpenCode, OMP and ACP now reject a live change
to a key they apply only when the agent opens; Claude applies such a change from the next
turn and rejects it while a turn is running. Opening reports every select or setting the
session came up with, including ones nobody set, so a native default (Cursor opening with
Fast on) is stored as option state like any other.

* fix(server): Claude rejects a live change to an option it reads when the query starts

Replacing a started query to apply the option would end its background shells and
tasks. Once the query exists only Fast can change; before the first prompt every option
still applies, because it is read when the query is built.

* fix(server): a live options patch is rejected when the session cannot apply it

A session with no applyOptions (Pi) now rejects a patch instead of storing a value that
fails on reopen. Codex applies only service_tier, plan_mode and fast_mode live, since the
approval policy and sandbox are fixed when the thread starts. Claude also rejects a launch
option change while its first query is starting, after the options have been copied.

* fix(server): plugins reject keys they do not report as settings, and Codex opening reports the speed it runs

A plugin session applied the settings it knew and silently stored the rest; it now rejects
a patch that names a key the plugin does not report. Codex opening on a model without the
saved speed ran Normal while the stored options still said priority; opening now reports
the tier it runs, as a later model switch does.

* refactor(server): option helpers take named inputs, control types come from their schemas, and a plugin accepts launch options it opened with

A whole profile is applied by the unchanged app, so a launch-only plugin option whose value
has not changed must not fail the apply. applyProviderOptionsPatch and featuresFromControls
take one object, and the protocol control types are inferred from the wire schemas.

* fix(server): ACP reports a select that appears after startup with its native value

A model switch can introduce a select (Cursor Fast) that the session did not have at open.
Its value is now stored like any other native selection instead of being skipped because
the previous snapshot did not name it.

* fix(server): a loaded agent's options stay what its session opened with when a provider default is edited

effectiveOptions was recomputed from the current config.json defaults on every state emission, so editing a default changed the snapshot of an agent whose session never received it. It is now computed when the session opens and moves only through an apply or an options_changed. Editing a default affects agents the next time they open.

* fix(server): a live apply shows the merged defaults the provider was given in the options snapshot

The snapshot took the raw patch, so a nested patch over configured defaults dropped the defaults the provider had accepted. The snapshot now takes what the provider was sent plus what it reports back; stored options still keep the raw patch.

* fix(server): an option a session drops when opening is not brought back by a configured default

The opening snapshot merged defaults over the options after the session's openOptionsPatch had removed a key. Defaults now merge into what the session was given and the patch applies after.

* fix(server): Codex keeps the speed it opened with when the legacy Fast key is removed

Opening moves a legacy fast_mode into service_tier, so removing fast_mode afterwards changed the runtime speed to Normal while the stored options still said Fast. Removing the alias now changes no speed.

* fix(server): opening snapshots keep the defaults the session launched with, and config-change pushes carry legacy profile values

An opening resolved defaults once for the session and again at registration; an edit in between showed options the session never received. Create, resume and reload now resolve once, before the provider opens, and registration starts from that. The daemon_config_changed push skipped the featureValues projection that config replies apply, so the unchanged app lost a profile's legacy values when any setting changed.

* fix(server): every opening path hands registration the options it launched with

Registration took the launch options as optional and reread the defaults when a path did not supply them, as provider-session import did. It is now required, so create, resume, reload and import each resolve once before opening. The option helpers take named inputs.

* fix(server): a saved legacy Codex Fast preference wins over a configured service_tier default

The defaults merge put a configured service_tier next to the agent's own legacy fast_mode, and Codex reads service_tier first, so the agent opened Normal. The agent's own alias now replaces the default of the key it stands for.

* fix(server): Codex keeps a Fast chosen by alias as the catalog's tier id

A live fast_mode or service_tier "fast" was stored as the alias while the apply reported the catalog id, so a later switch to a model with a different Fast id ran Fast under an option that said otherwise. The session now stores the id it accepted and drops the folded fast_mode.

* fix(server): the legacy Codex Fast alias folds into one speed on apply and in configured defaults

A patch with both speed keys left the alias in the stored options, so a reload started Fast again after the speed was removed; the apply now reports the folded alias. A configured fast_mode default now supplies the service_tier default, so removing an agent's own speed falls back to the configured Fast like opening does.

* fix(server): an OMP replacement runtime starts with the Fast the session has now

A restart (for example an approval-mode change) restored Fast from the options the session opened with, turning back on a Fast the user had switched off. The session now keeps the Fast it applied or the provider changed, which is what a replacement reads.

* refactor(server): Codex's legacy fast_mode converts to service_tier where old data enters

Stored records, config.json defaults, profiles, older clients' requests, MCP settings and
plugin hooks go through convertLegacyOptions as they come in. Past that point the defaults
merge, the manager and the Codex session never see fast_mode for Codex, so the alias branch
in the defaults merge and Codex's fast_mode handling are deleted.

* fix(server): a legacy hook's replaced speed and an apply request's options convert before they are compared or applied

A hook that swaps the canonical speed it was sent for fast_mode is diffed in canonical form, so
its replacement is not read as a deletion. An apply request carrying featureValues and options
sends one options patch with options winning, so a superseded legacy value is never applied.

* refactor(server): the agent manager converts Codex's legacy fast_mode for the adapter behind the provider id

The rename is adapter-specific, so it lives where the provider id resolves to its adapter:
AgentManager converts an agent's own options on create, resume, import, reload, apply and
draft controls, following derivedFromProviderId, so a custom provider extending codex keeps
working with a saved or requested fast_mode. Storage, MCP, schedules, hub and profiles pass
options through. The featureValues-to-options fold stays provider-agnostic at the legacy edges.

* fix(server): a rejected OpenCode auto_accept leaves the session asking for permission

applyOptions committed the new flag before approving pending requests, so a failed reply left
the session auto-approving while the stored option stayed off. The flag is restored when the
apply throws.

* fix(server): legacy Fast reaches draft discovery and saved profiles, and overlapping or replaced options keep the state they committed

Draft controls and commands convert the config they hand to the provider, so inspecting a
legacy Fast preference no longer fails Codex's strict options. A profile naming codex converts
its saved fast_mode for the app's controls. OpenCode serializes auto_accept applies so a failed
one restores exactly what it changed, and a replacement OMP runtime starts from the Fast the
session opened with.

* fix(server): an OMP replacement runtime that refuses the session's Fast publishes what it accepted

The approval-mode restart installed the new runtime's state without comparing its Fast result to the session's, so a refusal never reached options_changed, the remembered value, or storage. The replacement now goes through the same comparison as refreshState.

* fix(server): OMP turn completion publishes a Fast change, and Claude opens with Fast off on a model that has none

OMP installed runtime state in several places and only one compared Fast; every state read from the runtime now goes through one install step. Claude kept a Fast default on a model without Fast, which made unrelated options reject until the user cleared it; the session now opens with Fast off and reports it in openOptionsPatch, as the model-switch path already does.

* fix(server): a malformed Codex fast_mode is rejected, and profile options are renamed for clients by the agent manager's conversion

convertLegacyOptions read any truthy fast_mode as Fast, so the string "false" ran priority. It now accepts only true, false or null. The profile store no longer renames keys: a literal codex profile was renamed in storage while an extends-codex profile was not. The config API now converts each profile's options with the manager's adapter-aware conversion, so codex and my-codex profiles read the same.

* fix(server): a profile is stored in the current option keys, converted for the adapter its provider extends

Profiles in config.json are old data entering, so the store converts them (stored value and incoming patch) with the same conversion the agent manager uses. Storage and clients now share one vocabulary: resetting a legacy Codex profile's speed removes what was shown, and a malformed fast_mode rejects the write instead of breaking later config reads. The config API goes back to copying the field shape only. The extends chain is followed by one function over either the registry's definitions or config.json's providers.

* fix(server): profile options convert against the provider graph a write commits

A write that edited a provider and a profile together folded the profile against a shallow merge that dropped the provider's extends, so its legacy Fast was not converted. The fold now sees the same deep-merged providers the write commits.

* fix(server): draft speed shows the tier the first turn will run with

Draft discovery reported the options as requested, so a Fast configured as a legacy fast_mode or the fast alias read as Normal while creating sent the model's own Fast tier. The draft now reports the options the scratch session opened with, as registration does.

* fix(server): ACP option applies run one at a time

Two overlapping applies shared one transaction guard, so when the first was refused after the agent had moved a select, its report was dropped as 'still in a transaction' and the second saw no difference. Applies are serialized, as OpenCode's are, so each reconciles against the state the previous one left.

* fix(server): an ACP removal reports the choice the select lands on, and the Codex plan tests enable plan mode

A removal asks the select for its empty choice and compared the result against that
same empty choice, so applyOptions returned nothing and the manager stored the removal
as null while the session ran the Default choice. The difference is now computed against
what was asked (null), so the options hold the value the session runs with.

The Codex plan-mode end-to-end tests called the removed session.setFeature through an
optional call and silently skipped plan mode; they apply the option and fail loudly if
the method is gone. The MCP parity wrapper forwards listControls instead of the removed
listFeatures.

* fix(server): plugin option applies run one at a time, and OpenCode reports the Auto Accept legacy full-access turns on

Plugin sessions let two applyOptions overlap. The second finished and re-recorded the
settings the plugin held, which included a change the first had already made and not yet
reported; when the first then failed, nothing was left to report and the stored options
kept the old value while the plugin ran the new one. Applies now queue on the session,
the way the ACP adapter's do, so each reconciles against what the one before left.

OpenCode normalizes the legacy full-access mode to build with Auto Accept on, but the
session never said so, so the options the manager keeps did not hold Auto Accept and a
reopen turned it off. The session now reports it through openOptionsPatch on create and
resume.

* refactor(server): openCodeOpenOptionsPatch takes one named-fields object

* feat(app): the client and app work on options and controls

The agent snapshot's `options` are the state and `controls` only draw them.
Running and closed agents draw from the snapshot; drafts and profile forms ask
`provider.controls.list.request` with their `providerOptions`, so aliases such
as a legacy Fast profile's `service_tier: "fast"` are resolved once, by the
daemon, and the draft keeps Fast highlighted. A draft adds only the user's
edits and each provider's last-used control values; applying a profile seeds
its non-control options (e.g. `sandbox_mode`) into the draft, and a profile
form saves them back instead of pruning to the controls it can draw.

A daemon without `server_info.features.providerOptionControls` still sends
`features` and `featureValues`. `LegacyProviderOptions` in the client package
is the one place that translates snapshots, draft listings, create and apply
requests, and daemon-config profiles, so the app never branches on it.
Persisted preferences and tab layouts rename `featureValues` through tagged
COMPAT preprocessors.

The features/controls bridge moves from the server into the protocol package
so the client can reuse it.

* fix(app): drafts and profile forms send every option layer to the daemon

A cold create against a daemon without providerOptionControls routed control
options (Claude fast_mode, Codex service_tier) as providerOptions, which that
daemon does not read for them. The client now lists the provider's features
before the first create or workspace create that carries options, so callers
never prime a cache.

Draft options moved into a plain model. Applying a profile replaces provider
and options in one step, so the form's provider change that follows no longer
clears a cross-provider profile's non-control options; choosing a provider by
hand still starts over. The model sends its complete options (seed, last-used
values once the provider says it still draws them, edits) to the daemon and
the controls draw only the answer, so a remembered legacy fast is drawn as
what it runs as. A toggle shows over the last answer until its own arrives.
The profile form does the same: its edits go through resolution too.

* fix(client): a legacy create routes its options from the controls of its own config

Which options a legacy daemon exposes as features depends on the model, mode
and thinking level, not only the provider: a Haiku snapshot with no features
says nothing about Opus. The provider-keyed memory of control keys sent an
Opus create's fast_mode as providerOptions, which that daemon does not read.

A create that carries options now lists the controls for its own draft
config and splits on that answer. The memory, and the snapshot and listing
hooks that fed it, are gone.

* fix(app): a closed agent shows its stored options, and a provider switch asks about its own options

A closed agent's snapshot is projected from its stored record, which kept
controls and provider options but dropped them from the payload, so the
app drew no speed or other controls for it. The stored projection now
carries them, with the legacy features derived from the same source.

The draft read the form's provider and the model's options separately, so
for the render after a manual provider change it asked the new provider
about the previous provider's options. The request is now built from one
snapshot of the model and is absent until the model has observed the new
provider.

* fix: persisted options keep their full shape, and every legacy snapshot gains controls

Draft setups and form preferences validated option values as booleans,
strings or null, but the options they now receive come from agents and
profiles and can be objects, arrays or numbers. One such value failed
validation and wiped the whole saved layout or the saved preferences. Both
schemas now accept any JSON value.

The legacy-daemon translation skipped the snapshots carried by
agent_created, agent_resumed and timeline responses, so a created agent or
a timeline refresh left the SDK handle without options and controls.

The profile journey asserted the create request's options field; the wire
field is providerOptions.

* fix(client): a legacy profile with both featureValues and options keeps both

A legacy daemon keeps an unknown `options` field on a profile it stored
(profiles are passthrough), for example after a downgrade, while its own app
keeps editing `featureValues`. Reading such a profile took `options` and
dropped `featureValues`, so a Fast setting there was lost and the next save
replaced it. Both are merged on read with an explicit `options` key winning,
the same rule the daemon applies when it folds `featureValues`.

The draft form input of draftControlsRequest is a named type.

* fix(app): a draft stops sending a toggle the model in use does not offer

A toggle made on one model stayed in the draft's edits after the form moved
to a model whose controls do not include it, and was sent with the create.
On a legacy daemon that key is not a feature for the new model, so it went
out as a native provider option the daemon rejects. The draft now sends an
edit only while the latest answer draws a control for it, and sends it again
when a control for it returns.

* fix(server): a closed agent's options include the provider defaults, like a live one's

The stored snapshot projected only the options the agent owns, so an agent
that ran with a config.json default (for example Claude fast_mode: true)
showed the option as missing, and its legacy Fast control as off, until it
was resumed. The projection takes the same resolver the live agent uses
(the provider's defaults under the agent's own options).

* fix(app): a draft keeps sending an option no control has drawn

The previous change stopped sending any edit the latest answer drew no control
for, which also dropped options set without a control (a plugin setting a
token budget on the draft). Only an edit of a key an earlier answer for this
draft drew as a control stops applying when a later answer draws none; every
other edit always applies.

* test(server): the MCP test agent manager resolves effective options

* fix(app): a profile stops saving a toggle the model in use does not offer

Switching a profile from Opus to Haiku kept the Fast edit, so the saved
profile carried fast_mode that no control draws and an old daemon's
provider rejects at create. The profile editor now drops a key an earlier
answer drew as a control and the latest one does not, the rule the draft
already follows; options no control ever drew are never touched.

* fix(client): resetting an option on an old daemon writes the default it can take

setAgentOptions({ service_tier: null }) sent a feature write with value
null, which the old Codex setter rejects, leaving the paid Fast tier on.
A toggle now resets to off and a choice to its default; an option with
no control to name a default fails with the host-update message before
anything is written.

* fix(client): applying a config with a reset option on an old daemon writes the default it can take

applyAgentConfig({ options: { service_tier: null } }) reached an old
daemon as featureValues with null, which its Codex setter rejects. The
config now takes the same translation as setAgentOptions: a toggle goes
off, a choice goes to its default, and an option with no named default
fails with the host-update message before anything is sent.

* feat(plugin): provider plugins declare option controls and receive option changes

A provider plugin reports `controls` and the `providerOptions` it runs in
`session.config`, and receives running-session changes as
`session.configure` `changes.providerOptions` (null returns a key to its
default), under the same apply-or-throw contract as built-in providers.
A control may declare a `defaultValue`; a value a plugin reports at that
default for a key nobody set stays unset instead of being pinned to the agent.

A plugin that still reports `settings` keeps working: the daemon reads each
setting as a control plus an option and sends changes in both shapes
(COMPAT(plugin-provider-settings)).

The app resolves a control's icon as a Lucide name when it is not one of the
built-in kebab-case names, so a plugin's icon shows live and in drafts.
Public docs and the provider-direct example use only `providerOptions` and
controls; the migration doc gains the rows.

* fix(server): a plugin provider's reported options are stored like any provider's

A control's `defaultValue` only says what the control shows for a key with no
value. Opening and applying store every option the session reports, defaults
and values nobody set included, as built-in providers do; the special case that
left a value at the default unset is removed.

* fix(server): a plugin decides which options it applies live

A plugin reporting `controls` and `providerOptions` receives every key a live
change touches, with or without a control, and applies them or rejects the
whole request. The daemon's live-key guard now runs only for a plugin still
reporting `settings`, which cannot refuse a key.

* fix(server): a rewound plugin session reopens with its options as they stand

The reopen merged the plugin's reported options over the launch record, so a
key the plugin reset by leaving it out came back at its launch value. The
session now keeps the committed options, removals included. The provider-direct
example also refuses a combined request before changing anything.

* fix(server): a plugin that stops reporting an option has reset it

The adapter only visited keys present in a new report, so a key the plugin
dropped to return to its default kept its stored value and its rewind value.
A previously reported key that disappears is now reported as removed.

* fix(server): an apply reports the options the plugin reset along the way

Applying and unsolicited reports now share one conversion from the plugin's
report to a patch, so a key the plugin stops reporting is a removal on both.

* fix(server): an apply never reads a requested option as one the plugin dropped

A plugin may run an option it never reports. Only a key it reported before can be
reported as removed, and a rewind reopens with the accepted request plus what the
plugin reported back.

* test(server): an apply to a plugin that reports controls sends its changes as provider options

* feat(plugins): agents and workspaces carry plugin metadata

Plugins had nowhere to keep their own data on an agent or workspace, so
Phase 5 and 6 surfaces would each have invented a side store. Both now
hold an opaque Record<string, JsonValue> that Paseo stores, snapshots and
hands to hooks without reading a key.

Set at creation or changed with agent.metadata.update.request /
workspace.metadata.update.request (top-level keys replace, null removes).
Persisted on the agent and workspace records, so it survives a restart.
Reaches agent.create.before (which may add to it), agent.open.before
(read-only), agent.create.after and workspace.create.before/after.

Gated once on server_info.features.entityMetadata; the client throws
"Update the host" instead of sending to a host without it.

* fix(plugins): an agent.open.before result may omit metadata

The result schema required metadata, so a plugin written before metadata
existed that returns its own object instead of spreading the request
failed validation and the session open failed, even for agents with no
metadata. An omitted metadata now means unchanged and is filled back in
from the request so later plugins in the chain still see it; a present
value that differs is still rejected.

The restart test now sets metadata through the PaseoApi handles and
checks that agent.open.before sees the patched value after the restart.

* fix(plugins): omitted metadata in a hook result means unchanged

An agent.create.before hook that returned its own { config, env } dropped
metadata that an earlier hook in the chain had added, because only the
caller's original metadata was restored at the end. The omitted field is
now carried forward at each hook handoff, as agent.open.before already
does.

The agent.open.before result type also required metadata, so an older
TypeScript plugin that returned its own object no longer compiled even
though the daemon accepted it. The result type now makes it optional.

* fix(plugins): omitted metadata in a workspace.create.before result means unchanged

Same handoff as agent.create.before: a legacy hook that returned only
{ source, ... } dropped the caller's metadata and anything an earlier
hook added, and workspace provisioning then defaulted it to {}. The
omitted field is carried forward at each handoff; an explicit {} still
replaces.

* test(server): fixtures for agents and workspaces carry metadata

Agent snapshots and workspace records now always carry metadata, so the
hand-built fixtures and expectations in three existing suites name it.

* docs(plugins): the hook result comment names metadata as the field that stays

* fix(server): reopening a closed agent keeps metadata saved just before

The loader reads the agent record, then queues a resume. A setMetadata
queued ahead of that resume saves a newer record, and the open hooks saw
it, but registration took metadata from the loader's older read and wrote
it back over the saved patch. Registration now uses the metadata read
inside the lifecycle lane.

* docs(server): name the metadata gap when a handle-less agent is recreated on load

* docs(server): the handle-less recreate gap spans the agent.create.before hook

* docs(plugins): an agent.open.before hook starts writes to its own agent without awaiting

An agent.open.before hook that awaits setMetadata (or any write) on the
agent being opened deadlocks: the write joins the agent's lifecycle lane
behind the open, and the open waits on the hook until it times out. The
hooks reference now says so and shows the supported shape, a write
started without awaiting, which runs right after the open.

Deferred, not in this stack: reject such a write immediately instead of
letting the hook time out. The host knows which plugin hook it is
awaiting for the agent and which channel the write arrives on, so the
rejection can be keyed on "this plugin is inside an open hook for this
agent" without cross-process tagging, and an unrelated client's write
would still wait as it does today.

* feat(plugins): composer items and controls in all three composers

A plugin registers a component with addComposerItem and reads and changes the
composer through useComposer(): provider, model, mode, thinking, options,
prompt, attachments and agent metadata. The agent, draft and New workspace
composers provide the same ComposerInput and ComposerAgent halves, so a
command reaches the right model without the plugin knowing which composer
it sits in.

Toggle, Select and Action are drawn by one ComposerControl renderer that
the thinking select and provider option controls now use too. Controls sit in
the toolbar when there is room and become rows in the existing overflow sheet
otherwise, so on a phone they appear in the overflow. Plugin items count in
the density presence so the composer condenses to make room. Model and mode
stay specialized but share the trigger, density and overflow.

Draft metadata is kept locally, sent at creation and reset when the prompt is
sent. PluginAgentSnapshot.metadata, deferred from the metadata phase, lands
here. The unused provider badge is removed.

vitest resolves @floating-ui/react-native to its ESM build: the CJS entry
requires the real react-native, whose Flow sources do not parse.

* fix(plugins): composer commands run on current state, report failures, and reach the overflow sheet

Draft commands read the state captured at render, so a plugin's
`await setProvider(); await setModel()` acted on a stale provider. The draft
form and options are now plain stores that commands read live, and
`setProvider`/`setModel` reject an unavailable provider or a model the loaded
catalog does not list. Draft `options` include the plugin's pending edits.

Shared composer controls own the pending state and show a rejected command as
an error toast, like the built-in controls. Callback payloads are objects
(`{ value }`, `onPress(event)`) so they can grow without breaking plugins.

The overflow sheet's content portals to the app root and lost the composer's
agent, input, toast and query contexts, so plugin items drew nothing in it.
`useComposerSheetBridge` carries them across.

* test(plugins): composer commands tested on the models, overflow sheet in Playwright

The draft composer's command rules (unavailable provider, unlisted model,
edits belonging to the provider the form has now) moved from the React hooks
into the form store and the draft options model. They take the host's catalog
as a plain input, so the command tests run on the real models with no module
mocks and no renderer.

The overflow-sheet test faked the portal it was meant to verify. It is
replaced by a Playwright spec that installs the composer-controls example and
checks its rows in the narrow composer's Features sheet; it fails when the
sheet's context bridge is removed.

* test(plugins): lock draft option ownership without a subscription, name the overflow-sheet steps

The draft option test subscribed the options model to the form, which in the
app happens in a render effect, so it passed without the model observing the
form's provider itself. A case with no subscription now fails when that
observation is removed.

The overflow-sheet spec reads as steps through a helper.

* fix(plugins): composer option resets and model checks match the documented commands

A draft kept `null` as an option value, so `setOptions({ key: null })` sent the null to the
provider and creation failed. A null edit now removes the key from every draft layer, as the patch
does on a running agent. A running agent's `setModel` accepted ids its catalog does not list; it
now rejects them the way a draft does.

The composer controls imported the toast host through the toast context, which pulled the host's
animation module into every module that loads plugins. They use the API-only context instead.
The migration guide's registration table gets the `addComposerItem` row its test requires.

* fix(plugins): draft metadata survives a remount, plugin commands follow the composer lock

Draft metadata was component state while the prompt lived in the draft store, so a composer that
remounted across the compact breakpoint kept its text and lost the plugin's choices. It now lives
in the draft store beside the prompt, is cleared when the draft is sent, and is not cleared when
the prompt is emptied. It is not persisted across app restarts.

While the New workspace composer is creating the agent, the built-in controls are disabled but
plugin controls and commands were not. They read the composer's own lock: Paseo controls render
disabled, and setPrompt, insertText and the agent commands refuse.

The options and metadata a plugin reads are frozen copies, through the same helper the timeline
projection uses. The composer control test mounted components in JSDOM, which the testing rules
ban; it now runs in the browser project.

* fix(plugins): a kept command honors the composer lock, and useAgent metadata is frozen

A plugin action that awaits an RPC holds the composer it was created with. The lock was captured
at that render, so a submit that started meanwhile did not stop its insertText. Commands now ask
the composer whether it is locked when they run.

The agent snapshot a plugin gets from useAgent froze only the outer metadata record. It now uses
the same deep frozen copy as the composer.

* fix(plugins): attachment commands honor the composer lock

The creation request captures the attachments when it starts, so an attachment a plugin added or
removed afterwards was lost or left in the request. They refuse like the prompt commands.

* fix(plugins): a built-in select in the compact sheet keeps its action name

The shared control named every icon-only select by its value ("Speed: Fast"),
including in the compact sheet, where the row already shows the value. The sheet
row is named by its action again ("Select speed"), as before the shared renderer.

* test(app): composer item test installation carries the subscribe main added to InstalledPlugin

Main's plugin streaming (#8527) made subscribe a required field of InstalledPlugin; the
composer item model test builds its own installation and predates it.

* feat(plugins): New workspace items set the branch and workspace metadata

addNewWorkspaceItem mounts a plugin field on the New workspace screen, and
useNewWorkspace() exposes host, project, isolation, base ref, branch and
metadata with commands that follow the composer item rules: they act on the
screen's state when they run, resolve once applied, reject while the screen
is locked, and take JSON payloads. Host and project are read-only because a
command that switched them would unmount the item that called it.

The screen's pickable state (base selection, manual isolation, branch,
metadata) moves into one plain store so pickers and plugin commands write to
the same place; the screen reports derived facts to it on each render. The
branch is sent as source.branchName only for a branch-off worktree, and
metadata on workspace.create.

Items render through ComposerControl with a new "field" surface (badge on
desktop, full-width field when compact). Combobox ships here: Paseo owns the
loading and error states, keeps previous results, and drops superseded
searches.

* fix(plugins): New workspace commands work from mount effects, and branch and base ref match what creation uses

A command called from a New workspace item's mount effect rejected because the
setup model only received its inputs and the pickers' handlers from a passive
effect on the screen, which runs after a child's. The model now takes its
inputs at construction, picks the base and isolation itself, and is refreshed
in a layout effect; the screen supplies the side effects of a pick (the PR
attachment, the remembered isolation) as listeners.

The branch a plugin sets was dropped when no base was resolved, such as on a
detached HEAD, because it was sent only for an explicit branch-off. It now
applies whenever the worktree cuts a new branch, decided in one place that
mirrors the daemon.

baseRef.name was the display-shortened branch name, so setting back what was
read based the worktree on the local branch instead of the origin one. It is
now the exact ref creation uses, and host and baseRef are frozen like project
and metadata.

* fix(plugins): New workspace commands have the same effects as a pick, even from a mount effect

The screen attached the pick side effects (remembering the isolation, syncing the picker's PR
attachment) as listeners in its own useEffect, which runs after a child item's mount effect. A
mount-time setIsolation("worktree") switched the screen but was not saved, and setBaseRef skipped
the attachment sync. The effects are now injected when the setup model is built, so a command and
a pick run the same code from the first render. Closing the picker stays in the picker's handler.

* fix(plugins): a Combobox shows a failed refresh instead of the old results

The query keeps its cached data when a reopened list fails to refresh, so the old items stayed
selectable and the error never showed. A failed search now clears the list so its error shows.
The migration guide maps addNewWorkspaceItem, which its registration-coverage test requires, and
the superseded-search test waits on a later search instead of sleeping.

* fix(plugins): a branch and metadata chosen for one project are not sent for another

The setup model outlives the project and host it was chosen for, so an issue picked for project A
was sent on workspace.create after the user switched to project B. The model now drops the branch
and metadata when the project changes (project ids are host-scoped, so a host switch counts). A
project that is still resolving is not a switch. The base selection is left alone: the built-in
picker keeps a branch pick across a project switch, and that behavior is unchanged.

* test(plugins): the project-switch spec expects the other project's metadata to be empty

* fix(plugins): a host switch drops the branch and metadata even when the project id repeats

A remote repository keeps the same project id on every host, so comparing the project id alone
missed a switch from host A to host B for the same repository. The target is now the host and the
project id together.

* fix(server): a branch the caller named is not renamed from the first agent context

The daemon marked every new branch-off worktree for automatic naming, recording the chosen branch
as the placeholder, so the first agent's prompt could rename a branch a plugin or client had
named through branchName. Only a branch the daemon derived from the worktree slug is a
placeholder now.

* fix(plugins): New workspace items remount when the project or host changes

An item stayed mounted across a project switch, and its Combobox kept the previous project's
results (same query key, cached data) selectable until the next search returned, so an issue from
the old project could set the branch and metadata on the new one. A Combobox cannot detect that
its inline search now means another project, so the item host keys the items by host and project
id. Items and their search results start fresh for the new target, and a mount effect sets up
again after the model clears the branch and metadata.

* docs(plugins): a layout-effect command on mount also races the reset on a project switch

* fix(plugins): a New workspace item's commands stop working once its project or host is left

Every item received the same command functions, which act on the current screen state. A lookup
that finished after the user switched projects could still call setBranch or setMetadata and
overwrite the new project's choices; the reset on a switch only clears what was already written.

The setup model now hands out commands per target with commandsFor({ serverId, projectId }).
They reject with a "moved to another project or host" error when the screen is on another target,
like the lock rejects, so the promise only resolves when the change took effect.

* feat(plugins): reach a running agent's composer by reference

useComposers(selector) in any plugin component and composers(selector) on every command context return the composer of a running agent, named by id or by focus, whether or not its tab is open.

A running agent now has one store-backed composer model apart from any mounted tab. The mounted composer plugs its cursor-aware insert in while mounted; otherwise insertText and setPrompt write the agent's saved prompt. model and thinking resolve through the provider catalog with the picker's own function, so a read equals what the picker shows.

The host reads one ComposerModel from context and no longer knows whether it is in an agent, a draft or New workspace. The daemon rejects an unlisted model or thinking option for a running agent, replacing the app-side guard.

* fix(plugins): composers by reference follow the focused agent and reach retained composers

- `{ focused: true }` is the agent Command Center hands to agent items, published by its
  registration through plugins/focused-agent.ts and empty outside a workspace. The composer source
  reads that module instead of the remembered workspace selection, the navigation store and the
  layout store, which also removes the require cycles those imports added.
- A composer the app retains in a workspace the user left has no live input. `insertText` and
  `setPrompt` now store the text with `replaceDraftText`, which asks the mounted input to show it;
  before, the text reached the store but the retained input never displayed it.
- `model` and `thinking` read exactly what the picker resolves before the catalog loads (no
  models), then re-resolve when it lands, instead of reading null.
- The daemon validates a thinking option against the model the agent runs: the runtime model when
  the provider lists it, else the configured one.
- The composer sources subscribe to query-cache updates only. A query another component creates
  during its render is announced as `added`, and a subscriber that updates then made React warn
  about updating a component while rendering a different one. A catalog read no longer starts its
  load synchronously either.

* test(plugins): a Command Center agent item reaches the composer through the real client state

The command-path test now reads workspace and agent through
createPluginClientStateSource over seeded session stores, so an empty
lookup, which makes the Command Center return before calling the item,
would fail it. Also records at the focused-agent publisher that it
publishes in an effect.

* fix(plugins): a running agent's composer validates and refreshes against the agent's own catalog

setAgentModel and setAgentThinkingOption checked the provider's global catalog,
while the picker reads the catalog of the agent's directory, so a model only
the project lists was rejected. The validation input now carries the directory.

The composer read the catalog once with no observer, so reconnect repair, which
refetches only active queries, never refreshed it and a failed load never
retried. While anything watches, the composer now observes the catalog query.

The shared composer map kept every agent's composer for the session. It is now
dropped when nothing watches it and no composer is mounted.

* fix(plugins): useComposers holds the composer it names, and a composer lives as long as its agent

PluginComposersSource.subscribe watched the stores directly and never the
composer model, so a component using useComposers did not keep the model's
catalog observer alive and reconnect repair skipped it. subscribe now takes the
selector and holds the model it names, following focus.

Dropping a composer when it went idle left a command a plugin had saved calling
a dead model after the tab reopened. A composer is now dropped when its agent
leaves the session and nothing watches it or has one mounted.

* fix(plugins): a composer refreshes a stale catalog on resumed demand, shares its store watches, and is released with its last holder

Watching again, or reading once, after the catalog was invalidated or failed
while nothing watched now starts one deduplicated refresh; the observer
refetches on mount. Reconnect repair only refetches watched queries, so an
unwatched catalog stayed stale.

Each subscribe installed its own session and draft store callbacks, so N
watchers received N squared notifications, including for other agents' drafts.
One pair of subscriptions now serves all listeners, and the draft callback
ignores drafts of other agents.

A composer whose agent was removed while it was held was only released on the
next session change. The last holder letting go now checks too, and an archived
agent's idle composer is releasable.

* test(server): live model and thinking switches use values the provider lists

The daemon rejects a model or thinking option its provider does not list, so the live-preferences
e2e can no longer switch to a made-up id. A provider that lists nothing to switch to skips the case.

* test(server): the test providers list two models with thinking options, and the live switches run

The fake providers listed one model and no thinking options, so once the daemon validated
selections against the catalog the live model and thinking switches had nothing valid to switch to
and skipped. Every fake provider now lists two models with thinking options, and the switches run
for all three providers without depending on installed binaries.

* fix(plugins): a retained composer that is not showing takes plugin writes through the draft

On native a retained composer keeps its input mounted under display none, so the ref alone said
the input was live and a plugin insertText replaced the stale selection instead of appending to
the draft. The input is live only while its retained panel is showing.

* test(plugins): the retained input liveness test runs in real Chromium

* fix(plugins): a model or thinking change, an unreported option key, and a draft model reach the provider unchecked

Paseo no longer checks these in front of the provider. A running agent's model or thinking
change no longer waits for the catalog or checks it, a plugin provider is sent every option
key and refuses what it cannot apply, and a draft setModel keeps the id as the model picker
does.

* fix(plugins): a plugin reporting settings is sent every key of an options patch

* Run option, model, thinking and mode changes in the agent's lifecycle lane

The agent manager owns all sequencing for one agent: applyOptions, setModel,
setThinkingOption and setMode now run in the same lane as open, close, archive,
labels and metadata, so a provider session never sees two of them at once.
Provider-side serialization (the applying chains in the ACP, plugin and OpenCode
sessions, and Claude's apply-over-current-options handling) is deleted.

* Reopen a handleless agent inside its lifecycle lane

A stored agent with no resumable session is now reopened by
AgentManager.createAgentFromRecord, which rereads the record inside the agent's
lane before creating the first session. A metadata or label write queued ahead
is in the record it reads; one queued behind applies to the live agent. The
loader no longer passes a record it read outside the lane. createAgent itself
also runs in the lane, like resume.

* Return the draft's options from AgentClient.listControls

listControls now answers { controls, options } like AgentManager.listDraftControls:
the options a session opened from the draft would run with, with config
defaults merged in and anything the provider resolves natively for the ones
nobody set. ACP fills unset feature selects from the probe session's
currentValue, so a fresh Cursor draft shows the Fast selection the agent opens
with instead of no selection. The legacy features response and the MCP draft
listing already project from the manager's result, so they follow.

* Send a profile's options on every write, empty included

The profile form always sends options. The daemon reads a write without them as
one from an app that predates options and folds featureValues over the stored
ones; a write that carries them, empty included, replaces them. A profile left
with no options is stored without the key, so removing the last one persists.

* Read a remembered Codex fast_mode as its service tier

The create-agent preferences convert the legacy key on read, keyed on the
built-in codex provider id, so the Codex draft shows the Fast selection the user
last used. Tagged COMPAT(featureValues) with the other legacy-option shims.

* Document one option change at a time per agent

The agent manager runs option, model, thinking and mode changes in the agent's
lifecycle lane; providers do no sequencing. The hook deadlock warning now covers
agent.create.before and option writes.

* Flatten the profile fold's base selection and drop an unused import

* Carry the New workspace context through portal hosts and the draft's metadata through retry

* Convert legacy options when resolving a stored agent's effective options

* Keep stored-record reads tolerant of a legacy option the conversion rejects

* Keep plugin attachment items separate from the draft's own

* Hand plugin command inputs to the host as its own copies
2026-11-09 10:10:16 +07:00

10136 lines
297 KiB
TypeScript

import { readFileSync } from "node:fs";
import { afterEach, describe, expect, expectTypeOf, test, vi } from "vitest";
import { z } from "zod";
import {
DaemonClient,
DaemonConnectionError,
supportsUsageReports,
type DaemonClientTrace,
type CreateAgentRequestOptions,
type DaemonTransport,
type Logger,
type PluginClientBundle,
type PluginClientBundleStore,
} from "./daemon-client";
import { CLIENT_CAPS } from "@getpaseo/protocol/client-capabilities";
import { ChunkedTransport } from "@getpaseo/protocol/chunked-transport";
import { BROWSER_AUTOMATION_COMMAND_NAMES } from "@getpaseo/protocol/browser-automation/rpc-schemas";
import {
decodeFileTransferFrame,
encodeFileTransferFrame,
FileTransferOpcode,
} from "@getpaseo/protocol/binary-frames/index";
import {
encodeTerminalSnapshotPayload,
encodeTerminalStreamFrame,
TerminalStreamOpcode,
} from "@getpaseo/protocol/terminal-stream-protocol";
expectTypeOf<"getGitDiff" extends keyof DaemonClient ? true : false>().toEqualTypeOf<false>();
expectTypeOf<
"getHighlightedDiff" extends keyof DaemonClient ? true : false
>().toEqualTypeOf<false>();
expectTypeOf<
"exploreFileSystem" extends keyof DaemonClient ? true : false
>().toEqualTypeOf<false>();
function createMockLogger() {
return {
debug: vi.fn(),
info: vi.fn(),
warn: vi.fn(),
error: vi.fn(),
};
}
interface TraceRecord {
phase: "begin" | "end";
name?: string;
args?: Record<string, string>;
}
function createTraceRecorder(): { trace: DaemonClientTrace; records: TraceRecord[] } {
const records: TraceRecord[] = [];
return {
trace: {
isEnabled: () => true,
beginSection: (name, args) => records.push({ phase: "begin", name, args }),
endSection: () => records.push({ phase: "end" }),
},
records,
};
}
function createMockTransport() {
const sent: Array<string | Uint8Array | ArrayBuffer> = [];
let onMessage: (data: unknown) => void = () => {};
let onOpen: () => void = () => {};
let onClose: (_event?: unknown) => void = () => {};
let onError: (_event?: unknown) => void = () => {};
let serverInfoOrdinal = 1;
const transport: DaemonTransport = {
send: (data) => {
sent.push(data);
if (typeof data !== "string") {
return;
}
const frame = JSON.parse(data) as { type?: string };
if (frame.type === "ping") {
onMessage(JSON.stringify({ type: "pong" }));
}
},
close: () => {},
onMessage: (handler) => {
onMessage = (data) => handler(data, typeof data !== "string");
return () => {};
},
onOpen: (handler) => {
onOpen = handler;
return () => {};
},
onClose: (handler) => {
onClose = handler;
return () => {};
},
onError: (handler) => {
onError = handler;
return () => {};
},
};
return {
transport,
sent,
triggerOpen: (options?: { preserveSent?: boolean; features?: Record<string, boolean> }) => {
onOpen();
if (!options?.preserveSent) {
// Ignore HELLO handshake payloads in assertions.
sent.length = 0;
}
onMessage(
JSON.stringify({
type: "session",
message: {
type: "status",
payload: {
status: "server_info",
serverId: `srv_test_${serverInfoOrdinal++}`,
hostname: null,
version: null,
features: options?.features ?? { ownedSubscriptions: true },
},
},
}),
);
},
triggerClose: (event?: unknown) => onClose(event),
triggerError: (event?: unknown) => onError(event),
triggerMessage: (data: unknown) => onMessage(data),
};
}
function wrapSessionMessage(message: unknown): string {
return JSON.stringify({
type: "session",
message,
});
}
function assertStr(data: string | Uint8Array | ArrayBuffer | undefined): string {
if (typeof data !== "string") throw new Error("Expected string frame");
return data;
}
function assertUint8Array(data: string | Uint8Array | ArrayBuffer | undefined): Uint8Array {
if (data instanceof Uint8Array) return data;
if (data instanceof ArrayBuffer) return new Uint8Array(data);
throw new Error("Expected binary frame");
}
function parseSentFrame(
data: string | Uint8Array | ArrayBuffer | undefined,
): Record<string, unknown> {
return z
.object({
type: z.literal("session"),
message: z.record(z.string(), z.unknown()),
})
.parse(JSON.parse(assertStr(data))).message;
}
function respondToScheduleRequest(
mock: ReturnType<typeof createMockTransport>,
request: Record<string, unknown>,
): void {
const responseType =
request.type === "schedule/create" ? "schedule/create/response" : "schedule/update/response";
mock.triggerMessage(
wrapSessionMessage({
type: responseType,
payload: {
requestId: request.requestId,
schedule: null,
error: null,
},
}),
);
}
const clients: DaemonClient[] = [];
afterEach(async () => {
await Promise.all(clients.map((client) => client.close()));
clients.length = 0;
vi.useRealTimers();
vi.unstubAllGlobals();
});
test("traces WebSocket frames, message types, and JSON parse duration", async () => {
const mock = createMockTransport();
const recorder = createTraceRecorder();
const client = new DaemonClient({
url: "ws://test",
clientId: "trace_unit_test",
transportFactory: () => mock.transport,
reconnect: { enabled: false },
trace: recorder.trace,
});
clients.push(client);
const connectPromise = client.connect();
mock.triggerOpen({ preserveSent: true });
await connectPromise;
const response = client.fetchAgents({ requestId: "diagnostic-request", scope: "active" });
mock.triggerMessage(
wrapSessionMessage({
type: "fetch_agents_response",
payload: {
requestId: "diagnostic-request",
entries: [],
pageInfo: { nextCursor: null, prevCursor: null, hasMore: false },
},
}),
);
await response;
const spans = recorder.records.filter((record) => record.phase === "begin");
expect(spans).toEqual(
expect.arrayContaining([
expect.objectContaining({
name: "paseo.ws.frame.inbound",
args: {
kind: "text",
size: expect.any(String),
sizeUnit: "utf16-code-units",
connectionAttempt: "1",
},
}),
expect.objectContaining({ name: "paseo.ws.json.parse" }),
expect.objectContaining({ name: "paseo.ws.validate" }),
expect.objectContaining({
name: "paseo.ws.message.outbound",
args: {
envelopeType: "session",
messageType: "fetch_agents_request",
requestId: "diagnostic-request",
connectionAttempt: "1",
},
}),
expect.objectContaining({
name: "paseo.ws.message.inbound",
args: {
envelopeType: "session",
messageType: "fetch_agents_response",
requestId: "diagnostic-request",
connectionAttempt: "1",
},
}),
expect.objectContaining({
name: "paseo.ws.dispatch",
args: {
messageType: "fetch_agents_response",
requestId: "diagnostic-request",
connectionAttempt: "1",
},
}),
]),
);
const stack: string[] = [];
const parentage: [string, string | undefined][] = [];
for (const record of recorder.records) {
if (record.phase === "begin") {
parentage.push([record.name, stack.at(-1)]);
stack.push(record.name);
} else {
expect(stack.length).toBeGreaterThan(0);
stack.pop();
}
}
expect(stack).toEqual([]);
expect(parentage).toContainEqual(["paseo.ws.frame.outbound", "paseo.ws.message.outbound"]);
expect(parentage).toContainEqual(["paseo.ws.dispatch", "paseo.ws.frame.inbound"]);
expect(JSON.stringify(recorder.records)).not.toContain("trace_unit_test");
});
test("does not infer browser automation capabilities from Electron runtime", async () => {
vi.stubGlobal("navigator", {
userAgent:
"Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) Paseo/0.1.89 Chrome/146 Electron/41.2.0 Safari/537.36",
});
const mock = createMockTransport();
const client = new DaemonClient({
url: "ws://test",
clientId: "electron_unit_test",
transportFactory: () => mock.transport,
reconnect: { enabled: false },
});
clients.push(client);
const connectPromise = client.connect();
mock.triggerOpen({ preserveSent: true });
await connectPromise;
const hello = z
.object({
type: z.literal("hello"),
capabilities: z.record(z.string(), z.unknown()),
})
.parse(JSON.parse(assertStr(mock.sent[0])));
expect(hello.capabilities[CLIENT_CAPS.browserHost]).toBeUndefined();
expect(hello.capabilities[CLIENT_CAPS.selectiveAgentTimeline]).toBe(true);
});
test("advertises consumer-provided browser automation capabilities", async () => {
const mock = createMockTransport();
const client = new DaemonClient({
url: "ws://test",
clientId: "browser_capability_unit_test",
transportFactory: () => mock.transport,
reconnect: { enabled: false },
capabilities: {
[CLIENT_CAPS.browserHost]: {
supportedCommands: [...BROWSER_AUTOMATION_COMMAND_NAMES],
hostKind: "desktop app",
},
},
});
clients.push(client);
const connectPromise = client.connect();
mock.triggerOpen({ preserveSent: true });
await connectPromise;
const hello = z
.object({
type: z.literal("hello"),
capabilities: z.record(z.string(), z.unknown()),
})
.parse(JSON.parse(assertStr(mock.sent[0])));
expect(hello.capabilities[CLIENT_CAPS.browserHost]).toEqual({
supportedCommands: [...BROWSER_AUTOMATION_COMMAND_NAMES],
hostKind: "desktop app",
});
});
test.each([
{ receipts: false, structured: false },
{ receipts: true, structured: false },
{ receipts: false, structured: true },
{ receipts: true, structured: true },
])(
"legacy creation preserves the original payload with receipts=$receipts, structured=$structured",
async ({ receipts, structured }) => {
const transport = createMockTransport();
const client = new DaemonClient({
url: "ws://test",
clientId: "receipt-gate",
transportFactory: () => transport.transport,
reconnect: { enabled: false },
});
clients.push(client);
const connecting = client.connect();
transport.triggerOpen({ features: { agentRequestReceipts: receipts } });
await connecting;
const input: CreateAgentRequestOptions = {
config: {
provider: "codex",
cwd: "/project",
title: "Explicit title",
model: "gpt-5",
modeId: "full-access",
},
workspaceId: "workspace",
callerAgentId: "parent",
env: { CREATION_CONTEXT: "preserved" },
labels: { source: "test" },
idempotencyKey: "creation",
clientMessageId: "first-message",
initialPrompt: "Start this agent",
images: [{ data: "aGVsbG8=", mimeType: "image/png" }],
attachments: [
{
type: "github_pr",
mimeType: "application/github-pr",
number: 123,
title: "Review this PR",
url: "https://github.com/getpaseo/paseo/pull/123",
},
],
...(structured ? { outputSchema: { type: "object" } } : {}),
};
const created = client.createAgent(input);
void created.catch(() => {});
const duplicate = client.createAgent(input);
void duplicate.catch(() => {});
expect(transport.sent).toHaveLength(1);
const request = parseSentFrame(transport.sent[0]);
expect(request).toMatchObject({
type: "create_agent_request",
config: input.config,
workspaceId: input.workspaceId,
callerAgentId: input.callerAgentId,
env: input.env,
labels: input.labels,
clientMessageId: input.clientMessageId,
initialPrompt: input.initialPrompt,
images: input.images,
attachments: input.attachments,
...(structured ? { outputSchema: input.outputSchema } : {}),
});
expect(request).not.toHaveProperty("idempotencyKey");
transport.triggerMessage(
wrapSessionMessage({
type: "status",
payload: {
status: "agent_create_failed",
requestId: request.requestId,
error: "Provider failed",
},
}),
);
await expect(created).rejects.toThrow("Provider failed");
await expect(duplicate).rejects.toThrow("Provider failed");
},
);
test.each([false, true])(
"legacy workspace creation preserves availability with receipt support=%s",
async (receipts) => {
const transport = createMockTransport();
const client = new DaemonClient({
url: "ws://test",
clientId: "legacy-workspace",
transportFactory: () => transport.transport,
reconnect: { enabled: false },
});
clients.push(client);
const connecting = client.connect();
transport.triggerOpen({ features: { workspaceRequestReceipts: receipts } });
await connecting;
const input = {
source: { kind: "directory" as const, path: "/project" },
idempotencyKey: "workspace",
};
const created = client.createWorkspace(input);
void created.catch(() => {});
const duplicate = client.createWorkspace(input);
void duplicate.catch(() => {});
expect(transport.sent).toHaveLength(1);
const request = parseSentFrame(transport.sent[0]);
expect(request).toMatchObject({ type: "workspace.create.request", source: input.source });
expect(request.idempotencyKey).toBe(receipts ? input.idempotencyKey : undefined);
transport.triggerMessage(
wrapSessionMessage({
type: "workspace.create.response",
payload: {
requestId: request.requestId,
workspace: null,
error: "Directory unavailable",
setupTerminalId: null,
},
}),
);
await expect(created).resolves.toMatchObject({ error: "Directory unavailable" });
await expect(duplicate).resolves.toMatchObject({ error: "Directory unavailable" });
},
);
test.each(["agent", "workspace"] as const)(
"a lost legacy %s response is not automatically replayed on reconnect",
async (kind) => {
const transport = createMockTransport();
const client = new DaemonClient({
url: "ws://test",
clientId: "legacy-disconnect",
transportFactory: () => transport.transport,
reconnect: { enabled: false },
});
clients.push(client);
const connecting = client.connect();
transport.triggerOpen({ features: {} });
await connecting;
const creation =
kind === "agent"
? client.createAgent({
provider: "codex",
cwd: "/project",
initialPrompt: "Start once",
idempotencyKey: "intent",
})
: client.createWorkspace({
source: { kind: "directory", path: "/project" },
idempotencyKey: "intent",
});
void creation.catch(() => {});
expect(transport.sent).toHaveLength(1);
transport.triggerClose({ code: 1006, reason: "Connection lost after dispatch" });
await expect(creation).rejects.toThrow();
const reconnecting = client.connect();
transport.triggerOpen({ features: {} });
await reconnecting;
expect(transport.sent).toEqual([]);
},
);
test("Hub management requires daemon support before dispatching requests", async () => {
const mock = createMockTransport();
const client = new DaemonClient({
url: "ws://test",
clientId: "hub_feature_gate_unit_test",
transportFactory: () => mock.transport,
reconnect: { enabled: false },
});
clients.push(client);
const connecting = client.connect();
mock.triggerOpen();
await connecting;
await expect(client.getHubStatus()).rejects.toThrow(
"Update the host to use Hub relationship management.",
);
expect(mock.sent).toEqual([]);
});
test("timeline observation consumes broadcasts from a host without selective delivery", async () => {
const mock = createMockTransport();
const client = new DaemonClient({
url: "ws://test",
clientId: "timeline_legacy",
transportFactory: () => mock.transport,
reconnect: { enabled: false },
});
clients.push(client);
const connecting = client.connect();
mock.triggerOpen({ features: {} });
await connecting;
const observation = client.observeTimeline(["agent"]);
await expect(observation.ready).resolves.toMatchObject({ agentIds: ["agent"] });
const updates: unknown[] = [];
observation.subscribe({ snapshot: () => {}, update: (message) => updates.push(message) });
mock.triggerMessage(
wrapSessionMessage({
type: "agent_stream",
payload: {
agentId: "agent",
timestamp: "2026-09-11T00:00:00Z",
event: { type: "turn_completed", provider: "codex" },
},
}),
);
expect(updates).toEqual([expect.objectContaining({ type: "agent_stream" })]);
await observation.release();
expect(mock.sent).toEqual([]);
});
test("normalizes legacy and dedicated agent attention notifications", async () => {
const mock = createMockTransport();
const client = new DaemonClient({
url: "ws://test",
clientId: "attention_normalization",
transportFactory: () => mock.transport,
reconnect: { enabled: false },
});
clients.push(client);
const connect = client.connect();
mock.triggerOpen();
await connect;
const notifications: unknown[] = [];
client.onAgentAttentionRequired((notification) => notifications.push(notification));
const payload = {
agentId: "agent-a",
reason: "finished",
timestamp: "2026-07-12T00:00:00.000Z",
shouldNotify: true,
} as const;
mock.triggerMessage(
wrapSessionMessage({
type: "agent_stream",
payload: {
agentId: payload.agentId,
timestamp: payload.timestamp,
event: { type: "attention_required", provider: "codex", ...payload },
},
}),
);
mock.triggerMessage(wrapSessionMessage({ type: "agent_attention_required", payload }));
expect(notifications).toEqual([payload, payload]);
});
const noopLogger: Logger = {
debug: () => {},
info: () => {},
warn: () => {},
error: () => {},
};
type PongMode = { kind: "answer"; delayMs: number } | { kind: "silent" };
class FakeDaemon {
private onMessage: (data: unknown) => void = () => {};
private onOpen: () => void = () => {};
private onClose: (event?: unknown) => void = () => {};
private onError: (event?: unknown) => void = () => {};
private pongMode: PongMode = { kind: "answer", delayMs: 0 };
private withheldPongs = 0;
private pingsSentAt: number[] = [];
private closeEvents: Array<{ code?: number; reason?: string }> = [];
readonly transport: DaemonTransport = {
send: (data) => {
if (typeof data !== "string") {
return;
}
const frame = JSON.parse(data) as {
type?: string;
message?: { type?: string; requestId: string; clientSentAt: number };
};
const sessionPing =
frame.type === "session" && frame.message?.type === "ping" ? frame.message : null;
if (frame.type !== "ping" && !sessionPing) {
return;
}
this.pingsSentAt.push(performance.now());
if (this.withheldPongs > 0) {
this.withheldPongs -= 1;
return;
}
if (this.pongMode.kind === "silent") {
return;
}
const pong = sessionPing
? wrapSessionMessage({
type: "pong",
payload: { ...sessionPing, serverReceivedAt: Date.now(), serverSentAt: Date.now() },
})
: JSON.stringify({ type: "pong" });
if (this.pongMode.delayMs === 0) {
this.onMessage(pong);
return;
}
setTimeout(() => {
this.onMessage(pong);
}, this.pongMode.delayMs);
},
close: (code?: number, reason?: string) => {
this.closeEvents.push({ code, reason });
},
onMessage: (handler) => {
this.onMessage = (data) => handler(data, typeof data !== "string");
return () => {};
},
onOpen: (handler) => {
this.onOpen = handler;
return () => {};
},
onClose: (handler) => {
this.onClose = handler;
return () => {};
},
onError: (handler) => {
this.onError = handler;
return () => {};
},
};
finishTransfer(): void {
// Four MiB of JSON whitespace precedes this valid application message.
const bytes = new TextEncoder().encode(
JSON.stringify({
type: "session",
message: {
type: "status",
payload: {
status: "server_info",
serverId: "srv_heartbeat_test",
hostname: null,
version: null,
features: { binaryChunkingV1: true },
},
},
}),
);
const frame = new Uint8Array(bytes.length + 4);
frame.set([0x31, 1, 0, 2]);
frame.set(bytes, 4);
this.onMessage(frame);
this.onMessage(JSON.stringify({ type: "pong" }));
this.daemonAnswersPingsAfter("fast");
}
receiveChunk(start: boolean): void {
const frame = new Uint8Array(1024 * 1024 + 4);
frame.set([0x31, 1, 0, start ? 1 : 0]);
frame.fill(32, 4);
this.onMessage(frame);
}
openConnection(): void {
this.onOpen();
this.onMessage(
JSON.stringify({
type: "session",
message: {
type: "status",
payload: {
status: "server_info",
serverId: "srv_heartbeat_test",
features: { binaryChunkingV1: true },
hostname: null,
version: null,
},
},
}),
);
}
daemonAnswersPingsAfter(delay: "fast" | `${number}s`): void {
this.pongMode = {
kind: "answer",
delayMs: delay === "fast" ? 0 : Number.parseFloat(delay) * 1000,
};
}
daemonGoesSilent(): void {
this.pongMode = { kind: "silent" };
}
daemonWithholdsNextPongThenAnswersFast(): void {
this.withheldPongs += 1;
this.daemonAnswersPingsAfter("fast");
}
daemonClosesWith(reason: string): void {
this.onClose({ reason });
}
daemonSendsPong(): void {
this.onMessage(JSON.stringify({ type: "pong" }));
}
triggerError(event?: unknown): void {
this.onError(event);
}
pingTimestamps(): string[] {
return this.pingsSentAt.map((timestamp) => `${timestamp / 1000}s`);
}
teardownCount(): number {
return this.closeEvents.filter((event) => event.reason === "Liveness check timed out").length;
}
closesFromClient(): Array<{ code?: number; reason?: string }> {
return this.closeEvents;
}
}
class DaemonClientSession {
private readonly daemon = new FakeDaemon();
private readonly client: DaemonClient;
constructor() {
this.client = new DaemonClient({
url: "ws://test",
clientId: "clsk_heartbeat_test",
logger: noopLogger,
reconnect: { enabled: false },
transportFactory: () => this.daemon.transport,
});
clients.push(this.client);
}
async connect(): Promise<void> {
const connection = this.client.connect();
this.daemon.openConnection();
await connection;
}
async advance(ms: number): Promise<void> {
await vi.advanceTimersByTimeAsync(ms);
}
daemonAnswersPingsAfter(delay: "fast" | `${number}s`): void {
this.daemon.daemonAnswersPingsAfter(delay);
}
daemonGoesSilent(): void {
this.daemon.daemonGoesSilent();
}
daemonWithholdsNextPongThenAnswersFast(): void {
this.daemon.daemonWithholdsNextPongThenAnswersFast();
}
daemonClosesWith(reason: string): void {
this.daemon.daemonClosesWith(reason);
}
pingTimestamps(): string[] {
return this.daemon.pingTimestamps();
}
state(): ReturnType<DaemonClient["getConnectionState"]> {
return this.client.getConnectionState();
}
lastError(): string | null {
return this.client.lastError;
}
teardownCount(): number {
return this.daemon.teardownCount();
}
closesFromClient(): Array<{ code?: number; reason?: string }> {
return this.daemon.closesFromClient();
}
lastLivenessRttMs(): number | null {
return this.client.getLastLivenessRttMs();
}
measureLatency(input: { timeoutMs: number }): Promise<number> {
return this.client.measureLatency(input);
}
}
function useHeartbeatClock(): void {
vi.useFakeTimers({
toFake: ["Date", "setTimeout", "clearTimeout", "setInterval", "clearInterval", "performance"],
});
}
test("dedupes in-flight checkout status requests per agentId", async () => {
const logger = createMockLogger();
const mock = createMockTransport();
const client = new DaemonClient({
url: "ws://test",
clientId: "clsk_unit_test",
logger,
reconnect: { enabled: false },
transportFactory: () => mock.transport,
});
clients.push(client);
const connectPromise = client.connect();
mock.triggerOpen();
await connectPromise;
const p1 = client.getCheckoutStatus("/tmp/project");
const p2 = client.getCheckoutStatus("/tmp/project");
expect(mock.sent).toHaveLength(1);
const request = parseSentFrame(mock.sent[0]);
const response = {
type: "session",
message: {
type: "checkout_status_response",
payload: {
cwd: "/tmp/project",
error: null,
requestId: request.requestId,
isGit: false,
isPaseoOwnedWorktree: false,
repoRoot: null,
currentBranch: null,
isDirty: null,
baseRef: null,
aheadBehind: null,
aheadOfOrigin: null,
behindOfOrigin: null,
hasRemote: false,
remoteUrl: null,
},
},
};
mock.triggerMessage(JSON.stringify(response));
const [r1, r2] = await Promise.all([p1, p2]);
expect(r1).toMatchObject({
cwd: "/tmp/project",
requestId: request.requestId,
isGit: false,
});
expect(r2).toMatchObject({
cwd: "/tmp/project",
requestId: request.requestId,
isGit: false,
});
// After completion, a new call should issue a new request.
const p3 = client.getCheckoutStatus("/tmp/project");
expect(mock.sent).toHaveLength(2);
const request2 = parseSentFrame(mock.sent[1]);
mock.triggerMessage(
JSON.stringify({
...response,
message: {
...response.message,
payload: { ...response.message.payload, requestId: request2.requestId },
},
}),
);
await expect(p3).resolves.toMatchObject({
cwd: "/tmp/project",
requestId: request2.requestId,
isGit: false,
});
});
test("passes password as HTTP bearer header and WebSocket subprotocol", async () => {
const logger = createMockLogger();
const mock = createMockTransport();
const transportFactory = vi.fn(() => mock.transport);
const client = new DaemonClient({
url: "ws://test",
clientId: "clsk_unit_test",
password: "shared-secret",
logger,
reconnect: { enabled: false },
transportFactory,
});
clients.push(client);
const connectPromise = client.connect();
mock.triggerOpen();
await connectPromise;
expect(transportFactory).toHaveBeenCalledWith({
url: "ws://test",
headers: { Authorization: "Bearer shared-secret" },
protocols: ["paseo.bearer.shared-secret"],
});
});
test("keeps relay upgrade credentials out of the socket request", async () => {
const mock = createMockTransport();
const requests: Array<{ url: string; headers?: Record<string, string>; protocols?: string[] }> =
[];
const client = new DaemonClient({
url: "ws://relay.test/ws?role=client&serverId=srv_test&v=2",
clientId: "clsk_relay_auth_test",
password: "shared-secret",
authHeader: "Bearer shared-secret",
e2ee: { enabled: true, daemonPublicKeyB64: "daemon-public-key" },
reconnect: { enabled: false },
transportFactory: (request) => {
requests.push(request);
return mock.transport;
},
});
clients.push(client);
const connecting = client.connect();
void connecting.catch(() => {});
await vi.waitFor(() => expect(requests).toHaveLength(1));
expect(requests[0]).toEqual({ url: "ws://relay.test/ws?role=client&serverId=srv_test&v=2" });
await client.close();
await expect(connecting).rejects.toThrow("Daemon client closed");
});
test("keeps relay upgrade credentials out of the socket request without E2EE", async () => {
const mock = createMockTransport();
const requests: Array<{ url: string; headers?: Record<string, string>; protocols?: string[] }> =
[];
const client = new DaemonClient({
url: "ws://relay.test/ws?role=client&serverId=srv_test&v=2",
clientId: "clsk_relay_header_test",
authHeader: "Bearer synthetic-review-token",
reconnect: { enabled: false },
transportFactory: (request) => {
requests.push(request);
return mock.transport;
},
});
clients.push(client);
const connecting = client.connect();
void connecting.catch(() => {});
await vi.waitFor(() => expect(requests).toHaveLength(1));
expect(requests[0]).toEqual({ url: "ws://relay.test/ws?role=client&serverId=srv_test&v=2" });
await client.close();
await expect(connecting).rejects.toThrow("Daemon client closed");
});
test("refuses relay password auth without an encrypted hello", async () => {
const requests: unknown[] = [];
const client = new DaemonClient({
url: "ws://relay.test/ws?role=client&serverId=srv_test&v=2",
clientId: "clsk_unencrypted_relay_test",
password: "shared-secret",
connectTimeoutMs: 50,
reconnect: { enabled: false },
transportFactory: (request) => {
requests.push(request);
return createMockTransport().transport;
},
});
clients.push(client);
await expect(client.connect()).rejects.toThrow("Relay credentials require E2EE");
expect(requests).toEqual([]);
});
test("stops reconnecting after a password rejection on an established connection", async () => {
const socket = createMockTransport();
let attempts = 0;
const client = new DaemonClient({
url: "ws://test",
clientId: "clsk_reconnect_auth_test",
password: "old-secret",
reconnect: { enabled: true, baseDelayMs: 1, maxDelayMs: 1 },
transportFactory: () => {
attempts += 1;
return socket.transport;
},
});
clients.push(client);
const connected = client.connect();
socket.triggerOpen();
await connected;
socket.triggerMessage(JSON.stringify({ type: "hello.rejected", reason: "incorrect_password" }));
socket.triggerClose({ code: 4003, reason: "Incorrect password" });
await new Promise((resolve) => setTimeout(resolve, 30));
expect(client.authFailureReason).toBe("incorrect_password");
expect(attempts).toBe(1);
});
test("sends a password containing spaces in hello without an invalid WebSocket subprotocol", async () => {
const mock = createMockTransport();
const transportFactory = vi.fn(() => mock.transport);
const client = new DaemonClient({
url: "ws://test",
clientId: "clsk_unit_test",
password: "two words",
logger: createMockLogger(),
reconnect: { enabled: false },
transportFactory,
});
clients.push(client);
const connected = client.connect();
mock.triggerOpen({ preserveSent: true });
await connected;
expect(transportFactory).toHaveBeenCalledWith({
url: "ws://test",
headers: {},
});
expect(JSON.parse(assertStr(mock.sent[0]))).toMatchObject({
type: "hello",
auth: { kind: "password", password: "two words" },
});
});
test("uses a local credential over a saved password when the desktop bridge provides one", async () => {
const mock = createMockTransport();
const transportFactory = vi.fn(() => mock.transport);
const client = new DaemonClient({
url: "ws://test",
clientId: "desktop-managed",
password: "stale-password",
localCredential: async () => "current-local-token",
reconnect: { enabled: false },
transportFactory,
});
clients.push(client);
const connected = client.connect();
await vi.waitFor(() => expect(transportFactory).toHaveBeenCalled());
mock.triggerOpen({ preserveSent: true });
await connected;
expect(transportFactory).toHaveBeenCalledWith({ url: "ws://test", headers: {} });
expect(JSON.parse(assertStr(mock.sent[0]))).toMatchObject({
type: "hello",
auth: { kind: "localCredential", token: "current-local-token" },
});
});
test("uses the saved host password when the desktop bridge has no credential for the target", async () => {
const mock = createMockTransport();
const transportFactory = vi.fn(() => mock.transport);
const localCredential = vi.fn(async () => undefined);
const client = new DaemonClient({
url: "ws://remote-host",
clientId: "remote-saved-host",
password: "saved-password",
localCredential,
reconnect: { enabled: false },
transportFactory,
});
clients.push(client);
const connected = client.connect();
await vi.waitFor(() => expect(transportFactory).toHaveBeenCalled());
mock.triggerOpen({ preserveSent: true });
await connected;
expect(localCredential).toHaveBeenCalledOnce();
expect(JSON.parse(assertStr(mock.sent[0]))).toMatchObject({
type: "hello",
auth: { kind: "password", password: "saved-password" },
});
});
test("advertises client capabilities in hello", async () => {
const logger = createMockLogger();
const mock = createMockTransport();
const client = new DaemonClient({
url: "ws://test",
clientId: "clsk_unit_test",
logger,
reconnect: { enabled: false },
transportFactory: () => mock.transport,
capabilities: {
browser_host: {
supportedCommands: ["list_tabs"],
hostKind: "desktop app",
},
},
});
clients.push(client);
const connectPromise = client.connect();
mock.triggerOpen({ preserveSent: true });
await connectPromise;
expect(mock.sent).toHaveLength(1);
expect(JSON.parse(assertStr(mock.sent[0]))).toEqual({
type: "hello",
clientId: "clsk_unit_test",
clientType: "cli",
protocolVersion: 1,
capabilities: {
all_providers: true,
selective_agent_timeline: true,
timeline_replacement_invalidation: true,
provider_snapshot_references: true,
explicit_event_subscriptions: true,
owned_subscriptions: true,
binary_chunking_v1: true,
compact_provider_snapshots: true,
custom_mode_icons: true,
project_updates: true,
provider_subagents: true,
projected_subagent_timeline: true,
reasoning_merge_enum: true,
terminal_reflowable_snapshot: true,
terminal_chunked_replay: true,
timeline_notifications: true,
plugin_timeline_items: true,
plugin_binary_payloads: true,
workspace_setup_blocked: true,
workspace_setup_observation: true,
hello_rejection: true,
background_agents: true,
browser_host: {
supportedCommands: ["list_tabs"],
hostKind: "desktop app",
},
},
});
});
test("allows callers to disable default client capabilities", async () => {
const mock = createMockTransport();
const client = new DaemonClient({
url: "ws://test",
clientId: "clsk_capability_override_test",
reconnect: { enabled: false },
transportFactory: () => mock.transport,
capabilities: {
[CLIENT_CAPS.projectUpdates]: false,
},
});
clients.push(client);
const connectPromise = client.connect();
mock.triggerOpen({ preserveSent: true });
await connectPromise;
const hello = z
.object({
type: z.literal("hello"),
capabilities: z.record(z.string(), z.unknown()),
})
.parse(JSON.parse(assertStr(mock.sent[0])));
expect(hello.capabilities[CLIENT_CAPS.projectUpdates]).toBe(false);
});
test("sends new-agent run options when creating schedules", async () => {
const logger = createMockLogger();
const mock = createMockTransport();
const client = new DaemonClient({
url: "ws://test",
clientId: "clsk_unit_test",
logger,
reconnect: { enabled: false },
transportFactory: () => mock.transport,
});
clients.push(client);
const connectPromise = client.connect();
mock.triggerOpen();
await connectPromise;
const createPromise = client.scheduleCreate({
requestId: "request-1",
prompt: "Run the task",
cadence: { type: "cron", expression: "* * * * *" },
target: {
type: "new-agent",
config: {
provider: "claude",
cwd: "/tmp/project",
thinkingOptionId: "think-hard",
archiveOnFinish: false,
isolation: "worktree",
},
},
});
const request = parseSentFrame(mock.sent[0]);
expect(request).toEqual({
type: "schedule/create",
requestId: "request-1",
prompt: "Run the task",
cadence: { type: "cron", expression: "* * * * *" },
target: {
type: "new-agent",
config: {
provider: "claude",
cwd: "/tmp/project",
thinkingOptionId: "think-hard",
archiveOnFinish: false,
isolation: "worktree",
},
},
});
respondToScheduleRequest(mock, request);
await expect(createPromise).resolves.toEqual({
requestId: "request-1",
schedule: null,
error: null,
});
});
test("sends new-agent run options when updating schedules", async () => {
const logger = createMockLogger();
const mock = createMockTransport();
const client = new DaemonClient({
url: "ws://test",
clientId: "clsk_unit_test",
logger,
reconnect: { enabled: false },
transportFactory: () => mock.transport,
});
clients.push(client);
const connectPromise = client.connect();
mock.triggerOpen();
await connectPromise;
const updatePromise = client.scheduleUpdate({
id: "schedule-1",
requestId: "request-1",
newAgentConfig: {
thinkingOptionId: "think-hard",
archiveOnFinish: false,
isolation: "worktree",
},
});
const request = parseSentFrame(mock.sent[0]);
expect(request).toEqual({
type: "schedule/update",
requestId: "request-1",
scheduleId: "schedule-1",
newAgentConfig: {
thinkingOptionId: "think-hard",
archiveOnFinish: false,
isolation: "worktree",
},
});
respondToScheduleRequest(mock, request);
await expect(updatePromise).resolves.toEqual({
requestId: "request-1",
schedule: null,
error: null,
});
});
test("sends typed browser automation execute responses", async () => {
const logger = createMockLogger();
const mock = createMockTransport();
const client = new DaemonClient({
url: "ws://test",
clientId: "clsk_unit_test",
logger,
reconnect: { enabled: false },
transportFactory: () => mock.transport,
});
clients.push(client);
const connectPromise = client.connect();
mock.triggerOpen();
await connectPromise;
client.sendBrowserAutomationExecuteResponse({
type: "browser.automation.execute.response",
payload: {
requestId: "req-1",
ok: true,
result: { command: "list_tabs", tabs: [] },
},
});
expect(parseSentFrame(mock.sent[0])).toEqual({
type: "browser.automation.execute.response",
payload: {
requestId: "req-1",
ok: true,
result: { command: "list_tabs", tabs: [] },
},
});
});
test("does not reconnect after close when ensureConnected is called", async () => {
const logger = createMockLogger();
const mock = createMockTransport();
const client = new DaemonClient({
url: "ws://test",
clientId: "clsk_unit_test",
logger,
reconnect: { enabled: false },
transportFactory: () => mock.transport,
});
clients.push(client);
const connectPromise = client.connect();
mock.triggerOpen();
await connectPromise;
expect(client.getConnectionState().status).toBe("connected");
await client.close();
expect(client.getConnectionState().status).toBe("disposed");
client.ensureConnected();
expect(client.getConnectionState().status).toBe("disposed");
});
test("ensureConnected reconnects immediately without leaving the scheduled retry armed", async () => {
useHeartbeatClock();
try {
const first = createMockTransport();
const second = createMockTransport();
const third = createMockTransport();
const transports = [first, second, third];
let transportIndex = 0;
const client = new DaemonClient({
url: "ws://test",
clientId: "clsk_immediate_reconnect",
reconnect: { enabled: true, baseDelayMs: 1_500, maxDelayMs: 1_500 },
transportFactory: () => {
const transport = transports[transportIndex];
if (!transport) throw new Error("unexpected extra reconnect");
transportIndex += 1;
return transport.transport;
},
});
clients.push(client);
const initialConnect = client.connect();
first.triggerOpen();
await initialConnect;
first.triggerClose({ code: 1001, reason: "app resumed" });
expect(client.getConnectionState().status).toBe("disconnected");
client.ensureConnected();
expect(client.getConnectionState().status).toBe("connecting");
expect(transportIndex).toBe(2);
second.triggerOpen();
expect(client.getConnectionState().status).toBe("connected");
await vi.advanceTimersByTimeAsync(1_500);
expect(client.getConnectionState().status).toBe("connected");
expect(transportIndex).toBe(2);
} finally {
vi.useRealTimers();
}
});
test("connect during a scheduled retry reconnects immediately without leaving the retry armed", async () => {
useHeartbeatClock();
try {
const first = createMockTransport();
const second = createMockTransport();
const third = createMockTransport();
const transports = [first, second, third];
let transportIndex = 0;
const client = new DaemonClient({
url: "ws://test",
clientId: "clsk_explicit_reconnect",
reconnect: { enabled: true, baseDelayMs: 1_500, maxDelayMs: 1_500 },
transportFactory: () => {
const transport = transports[transportIndex];
if (!transport) throw new Error("unexpected extra reconnect");
transportIndex += 1;
return transport.transport;
},
});
clients.push(client);
const initialConnect = client.connect();
first.triggerOpen();
await initialConnect;
first.triggerClose({ code: 1001, reason: "daemon restarted" });
expect(client.getConnectionState().status).toBe("disconnected");
const reconnect = client.connect();
expect(client.getConnectionState().status).toBe("connecting");
expect(transportIndex).toBe(2);
second.triggerOpen();
await reconnect;
expect(client.getConnectionState().status).toBe("connected");
await vi.advanceTimersByTimeAsync(1_500);
expect(client.getConnectionState().status).toBe("connected");
expect(transportIndex).toBe(2);
} finally {
vi.useRealTimers();
}
});
test("disabling reconnect cancels a pending retry until explicitly resumed", async () => {
useHeartbeatClock();
try {
const first = createMockTransport();
const second = createMockTransport();
const transports = [first, second];
let transportIndex = 0;
const client = new DaemonClient({
url: "ws://test",
clientId: "clsk_paused_reconnect",
reconnect: { enabled: true, baseDelayMs: 1_500, maxDelayMs: 1_500 },
transportFactory: () => {
const transport = transports[transportIndex];
if (!transport) throw new Error("unexpected extra reconnect");
transportIndex += 1;
return transport.transport;
},
});
clients.push(client);
const initialConnect = client.connect();
first.triggerOpen();
await initialConnect;
first.triggerClose({ code: 1001, reason: "app backgrounded" });
client.setReconnectEnabled(false);
await vi.advanceTimersByTimeAsync(1_500);
expect(client.getConnectionState().status).toBe("disconnected");
expect(transportIndex).toBe(1);
client.setReconnectEnabled(true);
client.ensureConnected();
expect(client.getConnectionState().status).toBe("connecting");
expect(transportIndex).toBe(2);
} finally {
vi.useRealTimers();
}
});
test("keeps the transport connected when a session RPC ping times out", async () => {
const logger = createMockLogger();
const mock = createMockTransport();
const client = new DaemonClient({
url: "ws://test",
clientId: "clsk_unit_test",
logger,
reconnect: { enabled: false },
transportFactory: () => mock.transport,
});
clients.push(client);
const connectPromise = client.connect();
mock.triggerOpen();
await connectPromise;
expect(client.getConnectionState().status).toBe("connected");
await expect(client.ping({ timeoutMs: 1 })).rejects.toThrow("Timeout waiting for message");
expect(client.getConnectionState().status).toBe("connected");
});
test("waits for the daemon to acknowledge push token revocation", async () => {
const mock = createMockTransport();
const client = new DaemonClient({
url: "ws://test",
clientId: "clsk_push_revocation",
reconnect: { enabled: false },
transportFactory: () => mock.transport,
});
clients.push(client);
const connectPromise = client.connect();
mock.triggerOpen({ features: { pushTokenRevocation: true } });
await connectPromise;
const revocation = client.unregisterPushToken("ExponentPushToken[test-device]");
const request = parseSentFrame(mock.sent.at(-1));
expect(request).toMatchObject({
type: "push.unregister.request",
token: "ExponentPushToken[test-device]",
});
mock.triggerMessage(
wrapSessionMessage({
type: "push.unregister.response",
payload: { requestId: request.requestId },
}),
);
await revocation;
});
test("bounds the wait for push token revocation", async () => {
useHeartbeatClock();
const mock = createMockTransport();
const client = new DaemonClient({
url: "ws://test",
clientId: "clsk_push_revocation_timeout",
reconnect: { enabled: false },
transportFactory: () => mock.transport,
});
clients.push(client);
const connectPromise = client.connect();
mock.triggerOpen({ features: { pushTokenRevocation: true } });
await connectPromise;
const revocation = client.unregisterPushToken("ExponentPushToken[test-device]");
const rejection = expect(revocation).rejects.toThrow("Timeout waiting for message (2000ms)");
await vi.advanceTimersByTimeAsync(1_999);
expect(client.getConnectionState().status).toBe("connected");
await vi.advanceTimersByTimeAsync(1);
await rejection;
expect(client.getConnectionState().status).toBe("connected");
});
test("defaults session RPC waiters to sixty seconds", async () => {
useHeartbeatClock();
const logger = createMockLogger();
const mock = createMockTransport();
const client = new DaemonClient({
url: "ws://test",
clientId: "clsk_unit_test",
logger,
reconnect: { enabled: false },
transportFactory: () => mock.transport,
});
clients.push(client);
const connectPromise = client.connect();
mock.triggerOpen();
await connectPromise;
const responsePromise = client.fetchAgent({
agentId: "agent-1",
requestId: "req-agent-1",
});
let settled = false;
void responsePromise.then(
() => {
settled = true;
return undefined;
},
() => {
settled = true;
return undefined;
},
);
expect(parseSentFrame(mock.sent[0])).toEqual({
type: "fetch_agent_request",
requestId: "req-agent-1",
agentId: "agent-1",
});
await vi.advanceTimersByTimeAsync(59_999);
expect(settled).toBe(false);
await vi.advanceTimersByTimeAsync(1);
await expect(responsePromise).rejects.toThrow("Timeout waiting for message (60000ms)");
});
test("honors explicit fetchAgent timeout below the session RPC default", async () => {
useHeartbeatClock();
const logger = createMockLogger();
const mock = createMockTransport();
const client = new DaemonClient({
url: "ws://test",
clientId: "clsk_unit_test",
logger,
reconnect: { enabled: false },
transportFactory: () => mock.transport,
});
clients.push(client);
const connectPromise = client.connect();
mock.triggerOpen();
await connectPromise;
const responsePromise = client.fetchAgent({
agentId: "agent-1",
requestId: "req-agent-1",
timeout: 5_000,
});
let settled = false;
void responsePromise.then(
() => {
settled = true;
return undefined;
},
() => {
settled = true;
return undefined;
},
);
expect(parseSentFrame(mock.sent[0])).toEqual({
type: "fetch_agent_request",
requestId: "req-agent-1",
agentId: "agent-1",
});
await vi.advanceTimersByTimeAsync(4_999);
expect(settled).toBe(false);
await vi.advanceTimersByTimeAsync(1);
await expect(responsePromise).rejects.toThrow("Timeout waiting for message (5000ms)");
});
test("preserves legacy fetchAgent id overload", async () => {
const logger = createMockLogger();
const mock = createMockTransport();
const client = new DaemonClient({
url: "ws://test",
clientId: "clsk_unit_test",
logger,
reconnect: { enabled: false },
transportFactory: () => mock.transport,
});
clients.push(client);
const connectPromise = client.connect();
mock.triggerOpen();
await connectPromise;
const responsePromise = client.fetchAgent("agent-1", "req-agent-legacy");
expect(parseSentFrame(mock.sent[0])).toEqual({
type: "fetch_agent_request",
requestId: "req-agent-legacy",
agentId: "agent-1",
});
mock.triggerMessage(
wrapSessionMessage({
type: "fetch_agent_response",
payload: {
requestId: "req-agent-legacy",
agent: null,
project: null,
error: "legacy fetch sentinel",
},
}),
);
await expect(responsePromise).rejects.toThrow("legacy fetch sentinel");
});
test.each([undefined, false, true])(
"loads history with whole-turn support advertised as %s",
async (wholeTurnTimeline) => {
const transport = createMockTransport();
const client = new DaemonClient({
url: "ws://test",
clientId: "history-compatibility",
reconnect: { enabled: false },
transportFactory: () => transport.transport,
});
clients.push(client);
const connected = client.connect();
transport.triggerOpen({
features: wholeTurnTimeline === undefined ? {} : { wholeTurnTimeline },
});
await connected;
const requests = [
{ direction: "tail" as const },
{ direction: "before" as const, cursor: { epoch: "epoch-1", seq: 50 } },
{ direction: "after" as const, cursor: { epoch: "epoch-1", seq: 50 } },
{ direction: "before" as const, cursor: { epoch: "epoch-1", seq: 80 }, mergeWindow: true },
];
for (const [index, request] of requests.entries()) {
const requestId = `history-${index}`;
const result = client.fetchAgentTimeline("agent-1", {
...request,
requestId,
wholeTurns: true,
limit: 40,
projection: "projected",
});
// Attach immediately so the old rejection is captured by the regression.
const completed = expect(result).resolves.toMatchObject({ requestId, entries: [] });
expect(parseSentFrame(transport.sent.at(-1))).toEqual({
type: "fetch_agent_timeline_request",
agentId: "agent-1",
requestId,
...request,
limit: 40,
projection: "projected",
...(wholeTurnTimeline === true ? { wholeTurns: true } : {}),
});
transport.triggerMessage(
wrapSessionMessage({
type: "fetch_agent_timeline_response",
payload: {
requestId,
agentId: "agent-1",
agent: null,
direction: request.direction,
projection: "projected",
epoch: "epoch-1",
reset: false,
staleCursor: false,
gap: false,
window: { minSeq: 0, maxSeq: 0, nextSeq: 1 },
startCursor: null,
endCursor: null,
hasOlder: false,
hasNewer: false,
entries: [],
error: null,
},
}),
);
await completed;
}
},
);
test("honors explicit fetchAgentTimeline timeout below the session RPC default", async () => {
useHeartbeatClock();
const logger = createMockLogger();
const mock = createMockTransport();
const client = new DaemonClient({
url: "ws://test",
clientId: "clsk_unit_test",
logger,
reconnect: { enabled: false },
transportFactory: () => mock.transport,
});
clients.push(client);
const connectPromise = client.connect();
mock.triggerOpen();
await connectPromise;
const responsePromise = client.fetchAgentTimeline("agent-1", {
requestId: "req-timeline-1",
direction: "tail",
limit: 0,
projection: "projected",
timeout: 2_000,
});
let settled = false;
void responsePromise.then(
() => {
settled = true;
return undefined;
},
() => {
settled = true;
return undefined;
},
);
expect(parseSentFrame(mock.sent[0])).toEqual({
type: "fetch_agent_timeline_request",
requestId: "req-timeline-1",
agentId: "agent-1",
direction: "tail",
limit: 0,
projection: "projected",
});
await vi.advanceTimersByTimeAsync(1_999);
expect(settled).toBe(false);
await vi.advanceTimersByTimeAsync(1);
await expect(responsePromise).rejects.toThrow("Timeout waiting for message (2000ms)");
});
test("lists the full agent prompt index", async () => {
const logger = createMockLogger();
const mock = createMockTransport();
const client = new DaemonClient({
url: "ws://test",
clientId: "clsk_unit_test",
logger,
reconnect: { enabled: false },
transportFactory: () => mock.transport,
});
clients.push(client);
const connectPromise = client.connect();
mock.triggerOpen();
await connectPromise;
const responsePromise = client.listAgentTimelinePrompts("agent-1", {
requestId: "req-prompts-1",
});
expect(parseSentFrame(mock.sent[0])).toEqual({
type: "agent.timeline.list_prompts.request",
requestId: "req-prompts-1",
agentId: "agent-1",
});
mock.triggerMessage(
wrapSessionMessage({
type: "agent.timeline.list_prompts.response",
payload: {
requestId: "req-prompts-1",
agentId: "agent-1",
epoch: "epoch-1",
prompts: [{ seq: 1, timestamp: "2026-01-01T00:00:00.000Z", preview: "First prompt" }],
error: null,
},
}),
);
await expect(responsePromise).resolves.toMatchObject({
epoch: "epoch-1",
prompts: [{ seq: 1, preview: "First prompt" }],
});
});
test("honors explicit fetchAgents timeout below the session RPC default", async () => {
useHeartbeatClock();
const logger = createMockLogger();
const mock = createMockTransport();
const client = new DaemonClient({
url: "ws://test",
clientId: "clsk_unit_test",
logger,
reconnect: { enabled: false },
transportFactory: () => mock.transport,
});
clients.push(client);
const connectPromise = client.connect();
mock.triggerOpen();
await connectPromise;
const responsePromise = client.fetchAgents({
requestId: "req-agents-1",
scope: "active",
timeout: 1_200,
});
let settled = false;
void responsePromise.then(
() => {
settled = true;
return undefined;
},
() => {
settled = true;
return undefined;
},
);
expect(parseSentFrame(mock.sent[0])).toEqual({
type: "fetch_agents_request",
requestId: "req-agents-1",
scope: "active",
});
await vi.advanceTimersByTimeAsync(1_199);
expect(settled).toBe(false);
await vi.advanceTimersByTimeAsync(1);
await expect(responsePromise).rejects.toThrow("Timeout waiting for message (1200ms)");
});
test("honors explicit shutdownServer timeout below the session RPC default", async () => {
useHeartbeatClock();
const logger = createMockLogger();
const mock = createMockTransport();
const client = new DaemonClient({
url: "ws://test",
clientId: "clsk_unit_test",
logger,
reconnect: { enabled: false },
transportFactory: () => mock.transport,
});
clients.push(client);
const connectPromise = client.connect();
mock.triggerOpen();
await connectPromise;
const responsePromise = client.shutdownServer({
requestId: "req-shutdown-1",
timeout: 1_500,
});
let settled = false;
void responsePromise.then(
() => {
settled = true;
return undefined;
},
() => {
settled = true;
return undefined;
},
);
expect(parseSentFrame(mock.sent[0])).toEqual({
type: "shutdown_server_request",
requestId: "req-shutdown-1",
});
await vi.advanceTimersByTimeAsync(1_499);
expect(settled).toBe(false);
await vi.advanceTimersByTimeAsync(1);
await expect(responsePromise).rejects.toThrow("Timeout waiting for message (1500ms)");
});
test("honors explicit getDaemonStatus timeout below the session RPC default", async () => {
useHeartbeatClock();
const logger = createMockLogger();
const mock = createMockTransport();
const client = new DaemonClient({
url: "ws://test",
clientId: "clsk_unit_test",
logger,
reconnect: { enabled: false },
transportFactory: () => mock.transport,
});
clients.push(client);
const connectPromise = client.connect();
mock.triggerOpen({ features: { daemonStatusRpc: true } });
await connectPromise;
const responsePromise = client.getDaemonStatus({
requestId: "req-daemon-status-1",
timeout: 1_500,
});
let settled = false;
void responsePromise.then(
() => {
settled = true;
return undefined;
},
() => {
settled = true;
return undefined;
},
);
expect(parseSentFrame(mock.sent[0])).toEqual({
type: "daemon.get_status.request",
requestId: "req-daemon-status-1",
});
await vi.advanceTimersByTimeAsync(1_499);
expect(settled).toBe(false);
await vi.advanceTimersByTimeAsync(1);
await expect(responsePromise).rejects.toThrow("Timeout waiting for message (1500ms)");
});
test("honors explicit getDaemonPairingOffer timeout below the session RPC default", async () => {
useHeartbeatClock();
const logger = createMockLogger();
const mock = createMockTransport();
const client = new DaemonClient({
url: "ws://test",
clientId: "clsk_unit_test",
logger,
reconnect: { enabled: false },
transportFactory: () => mock.transport,
});
clients.push(client);
const connectPromise = client.connect();
mock.triggerOpen();
await connectPromise;
const responsePromise = client.getDaemonPairingOffer({
requestId: "req-pairing-offer-1",
timeout: 1_500,
});
let settled = false;
void responsePromise.then(
() => {
settled = true;
return undefined;
},
() => {
settled = true;
return undefined;
},
);
expect(parseSentFrame(mock.sent[0])).toEqual({
type: "daemon.get_pairing_offer.request",
requestId: "req-pairing-offer-1",
});
await vi.advanceTimersByTimeAsync(1_499);
expect(settled).toBe(false);
await vi.advanceTimersByTimeAsync(1);
await expect(responsePromise).rejects.toThrow("Timeout waiting for message (1500ms)");
});
test("gates config reload on the daemon capability", async () => {
const mock = createMockTransport();
const client = new DaemonClient({
url: "ws://test",
clientId: "clsk_unit_test",
logger: createMockLogger(),
reconnect: { enabled: false },
transportFactory: () => mock.transport,
});
clients.push(client);
const connectPromise = client.connect();
mock.triggerOpen();
await connectPromise;
await expect(client.reloadDaemonConfig("reload-old-host")).rejects.toThrow(
"Update the host to reload daemon configuration.",
);
expect(mock.sent).toEqual([]);
});
test("sends and parses daemon config reload", async () => {
const mock = createMockTransport();
const client = new DaemonClient({
url: "ws://test",
clientId: "clsk_unit_test",
logger: createMockLogger(),
reconnect: { enabled: false },
transportFactory: () => mock.transport,
});
clients.push(client);
const connectPromise = client.connect();
mock.triggerOpen({ features: { daemonConfigReload: true } });
await connectPromise;
const response = client.reloadDaemonConfig("reload-new-host");
expect(parseSentFrame(mock.sent[0])).toEqual({
type: "daemon.config.reload.request",
requestId: "reload-new-host",
});
mock.triggerMessage(
wrapSessionMessage({
type: "daemon.config.reload.response",
payload: {
requestId: "reload-new-host",
appliedPaths: ["daemon.browserTools.enabled"],
restartRequiredPaths: ["daemon.listen"],
overrideControlledPaths: [],
},
}),
);
await expect(response).resolves.toEqual({
requestId: "reload-new-host",
appliedPaths: ["daemon.browserTools.enabled"],
restartRequiredPaths: ["daemon.listen"],
overrideControlledPaths: [],
});
});
test("gets a structured plugin log snapshot", async () => {
const mock = createMockTransport();
const client = new DaemonClient({
url: "ws://test",
clientId: "clsk_unit_test",
logger: createMockLogger(),
reconnect: { enabled: false },
transportFactory: () => mock.transport,
});
clients.push(client);
const connectPromise = client.connect();
mock.triggerOpen({ features: { pluginLogs: true } });
await connectPromise;
const response = client.getPluginLogs("example");
const request = parseSentFrame(mock.sent[0]);
expect(request).toMatchObject({ type: "plugin.logs.get.request", pluginId: "example" });
mock.triggerMessage(
wrapSessionMessage({
type: "plugin.logs.get.response",
payload: {
requestId: request.requestId,
pluginId: "example",
entries: [
{
sequence: 3,
timestamp: "2026-08-16T12:00:00.000Z",
stream: "stdout",
message: "ready",
},
],
},
}),
);
await expect(response).resolves.toEqual([
{
sequence: 3,
timestamp: "2026-08-16T12:00:00.000Z",
stream: "stdout",
message: "ready",
},
]);
});
describe("plugin catalog", () => {
async function connectedClient(createStore?: (serverId: string) => PluginClientBundleStore) {
const mock = createMockTransport();
const client = new DaemonClient({
url: "ws://test",
clientId: "clsk_unit_test",
logger: createMockLogger(),
createPluginClientBundleStore: createStore,
reconnect: { enabled: false },
transportFactory: () => mock.transport,
});
clients.push(client);
const connectPromise = client.connect();
mock.triggerOpen({ features: { plugins: true } });
await connectPromise;
return { client, mock };
}
async function fetchCatalog(
harness: Awaited<ReturnType<typeof connectedClient>>,
respond: (request: Record<string, unknown>) => Array<Record<string, unknown>>,
) {
const before = harness.mock.sent.length;
const response = harness.client.getPluginCatalog();
await vi.waitFor(() => expect(harness.mock.sent.length).toBeGreaterThan(before));
const request = parseSentFrame(harness.mock.sent.at(-1));
harness.mock.triggerMessage(
wrapSessionMessage({
type: "plugin.catalog.get.response",
payload: { requestId: request.requestId, plugins: respond(request) },
}),
);
return { request, plugins: await response };
}
test("reuses retained client bundles the daemon leaves out", async () => {
const harness = await connectedClient();
const first = await fetchCatalog(harness, () => [
{ id: "kept", clientBundle: "kept bundle", clientBundleHash: "hash-kept" },
{ id: "changed", clientBundle: "old bundle", clientBundleHash: "hash-old" },
]);
expect(first.request).not.toHaveProperty("cachedClientBundles");
const second = await fetchCatalog(harness, () => [
{ id: "kept", clientBundleHash: "hash-kept" },
{ id: "changed", clientBundle: "new bundle", clientBundleHash: "hash-new" },
]);
expect(second.request.cachedClientBundles).toEqual([
{ id: "kept", clientBundleHash: "hash-kept" },
{ id: "changed", clientBundleHash: "hash-old" },
]);
expect(second.plugins).toEqual([
{ id: "kept", clientBundle: "kept bundle", clientBundleHash: "hash-kept" },
{ id: "changed", clientBundle: "new bundle", clientBundleHash: "hash-new" },
]);
const third = await fetchCatalog(harness, () => []);
expect(third.request.cachedClientBundles).toEqual([
{ id: "kept", clientBundleHash: "hash-kept" },
{ id: "changed", clientBundleHash: "hash-new" },
]);
const fourth = await fetchCatalog(harness, () => []);
expect(fourth.request).not.toHaveProperty("cachedClientBundles");
});
test("keeps fetching every bundle from a daemon that sends no bundle hashes", async () => {
const harness = await connectedClient();
const legacyCatalog = () => [{ id: "legacy", clientBundle: "legacy bundle" }];
await fetchCatalog(harness, legacyCatalog);
const second = await fetchCatalog(harness, legacyCatalog);
expect(second.request).not.toHaveProperty("cachedClientBundles");
expect(second.plugins).toEqual([{ id: "legacy", clientBundle: "legacy bundle" }]);
});
test("a fresh client advertises persisted bundles on its first fetch and expands omitted code", async () => {
let persisted: PluginClientBundle[] = [];
let loads = 0;
const store: PluginClientBundleStore = {
async load() {
loads++;
return persisted;
},
async replace(bundles) {
persisted = bundles;
},
};
const createdFor: string[] = [];
const createStore = (serverId: string) => {
createdFor.push(serverId);
return store;
};
const bundle = { id: "kept", clientBundleHash: "hash-kept", clientBundle: "stored code" };
const warm = await connectedClient(createStore);
await fetchCatalog(warm, () => [bundle]);
const cold = await connectedClient(createStore);
const first = await fetchCatalog(cold, () => [{ id: "kept", clientBundleHash: "hash-kept" }]);
expect(first.request.cachedClientBundles).toEqual([
{ id: "kept", clientBundleHash: "hash-kept" },
]);
expect(first.plugins).toEqual([bundle]);
expect(loads).toBe(2);
await fetchCatalog(cold, () => []);
expect(loads).toBe(2);
expect(createdFor).toEqual([
warm.client.getLastServerInfoMessage()!.serverId,
cold.client.getLastServerInfoMessage()!.serverId,
]);
expect(persisted).toEqual([]);
});
test.each(["missing", "unreadable"])(
"a %s persistent cache requests full bundles",
async (state) => {
const store: PluginClientBundleStore = {
async load() {
if (state === "unreadable") throw new Error("Storage unavailable");
return [];
},
async replace() {},
};
const bundle = { id: "full", clientBundleHash: "hash-full", clientBundle: "full code" };
const result = await fetchCatalog(await connectedClient(() => store), () => [bundle]);
expect(result.request).not.toHaveProperty("cachedClientBundles");
expect(result.plugins).toEqual([bundle]);
},
);
test("failed cache writes never fail plugin loading", async () => {
const store: PluginClientBundleStore = {
async load() {
return [];
},
async replace() {
throw new Error("Disk full");
},
};
const bundle = { id: "full", clientBundleHash: "hash-full", clientBundle: "full code" };
expect(
(await fetchCatalog(await connectedClient(() => store), () => [bundle])).plugins,
).toEqual([bundle]);
});
test.each([false, true])(
"usable catalogs do not wait for storage; latest write survives a failed first write: %s",
async (failFirstWrite) => {
const writes: PluginClientBundle[][] = [];
let finishWrite = () => {};
const blockedWrite = new Promise<void>((resolve) => {
finishWrite = resolve;
});
const store: PluginClientBundleStore = {
async load() {
return [];
},
async replace(bundles) {
writes.push(bundles);
if (writes.length === 1) {
await blockedWrite;
if (failFirstWrite) throw new Error("Disk full");
}
},
};
const harness = await connectedClient(() => store);
const first = { id: "plugin", clientBundle: "old", clientBundleHash: "hash-old" };
const changed = { id: "plugin", clientBundle: "new", clientBundleHash: "hash-new" };
try {
expect((await fetchCatalog(harness, () => [first])).plugins).toEqual([first]);
await vi.waitFor(() => expect(writes).toEqual([[first]]));
expect((await fetchCatalog(harness, () => [changed])).plugins).toEqual([changed]);
expect((await fetchCatalog(harness, () => [])).plugins).toEqual([]);
expect(writes).toEqual([[first]]);
finishWrite();
await vi.waitFor(() => expect(writes).toEqual([[first], []]));
} finally {
finishWrite();
}
},
);
});
test("keeps a plugin reload pending past the session RPC deadline", async () => {
useHeartbeatClock();
try {
const mock = createMockTransport();
const client = new DaemonClient({
url: "ws://test",
clientId: "clsk_unit_test",
logger: createMockLogger(),
reconnect: { enabled: false },
transportFactory: () => mock.transport,
});
clients.push(client);
const connectPromise = client.connect();
mock.triggerOpen();
await connectPromise;
const reload = client.reloadPlugin("example");
let settled: "pending" | "resolved" | "rejected" = "pending";
void reload.then(
() => {
settled = "resolved";
return null;
},
() => {
settled = "rejected";
return null;
},
);
const request = parseSentFrame(mock.sent[0]);
expect(request).toEqual({
type: "plugin.reload.request",
requestId: expect.any(String),
pluginId: "example",
});
// A daemon that reloads for almost two minutes is still reloading, not a failed request.
await vi.advanceTimersByTimeAsync(114_034);
expect(settled).toBe("pending");
// Another plugin's reload finishing early must not settle this request.
mock.triggerMessage(
wrapSessionMessage({
type: "plugin.reload.response",
payload: {
requestId: "req-other-plugin",
plugin: { id: "other", path: "/plugins/other", enabled: true, status: "running" },
},
}),
);
await vi.advanceTimersByTimeAsync(0);
expect(settled).toBe("pending");
mock.triggerMessage(
wrapSessionMessage({
type: "plugin.reload.response",
payload: {
requestId: request.requestId,
plugin: { id: "example", path: "/plugins/example", enabled: true, status: "running" },
},
}),
);
await expect(reload).resolves.toEqual({
id: "example",
path: "/plugins/example",
enabled: true,
status: "running",
});
} finally {
vi.useRealTimers();
}
});
test("keeps enable and disable inside the session RPC deadline", async () => {
useHeartbeatClock();
try {
for (const action of ["enable", "disable"] as const) {
const mock = createMockTransport();
const client = new DaemonClient({
url: "ws://test",
clientId: "clsk_unit_test",
logger: createMockLogger(),
reconnect: { enabled: false },
transportFactory: () => mock.transport,
});
clients.push(client);
const connectPromise = client.connect();
mock.triggerOpen();
await connectPromise;
const pending =
action === "enable" ? client.enablePlugin("example") : client.disablePlugin("example");
let settled = false;
void pending.then(
() => {
settled = true;
return null;
},
() => {
settled = true;
return null;
},
);
expect(parseSentFrame(mock.sent[0])).toEqual({
type: `plugin.${action}.request`,
requestId: expect.any(String),
pluginId: "example",
});
await vi.advanceTimersByTimeAsync(59_999);
expect(settled).toBe(false);
await vi.advanceTimersByTimeAsync(1);
await expect(pending).rejects.toThrow("Timeout waiting for message (60000ms)");
}
} finally {
vi.useRealTimers();
}
});
test("reports a plugin reload failure that arrives after the session RPC deadline", async () => {
useHeartbeatClock();
try {
const mock = createMockTransport();
const client = new DaemonClient({
url: "ws://test",
clientId: "clsk_unit_test",
logger: createMockLogger(),
reconnect: { enabled: false },
transportFactory: () => mock.transport,
});
clients.push(client);
const connectPromise = client.connect();
mock.triggerOpen();
await connectPromise;
const reload = client.reloadPlugin("example");
const request = parseSentFrame(mock.sent[0]);
await vi.advanceTimersByTimeAsync(114_034);
mock.triggerMessage(
wrapSessionMessage({
type: "rpc_error",
payload: {
requestId: request.requestId,
requestType: "plugin.reload.request",
error: "Request failed: Plugin failed to start: example",
code: "handler_error",
},
}),
);
await expect(reload).rejects.toThrow(
"Request failed: Plugin failed to start: example requestType=plugin.reload.request code=handler_error",
);
} finally {
vi.useRealTimers();
}
});
test("rejects a pending plugin reload when the connection closes", async () => {
const mock = createMockTransport();
const client = new DaemonClient({
url: "ws://test",
clientId: "clsk_unit_test",
logger: createMockLogger(),
reconnect: { enabled: false },
transportFactory: () => mock.transport,
});
clients.push(client);
const connectPromise = client.connect();
mock.triggerOpen();
await connectPromise;
const reload = client.reloadPlugin("example");
expect(parseSentFrame(mock.sent[0])).toMatchObject({ type: "plugin.reload.request" });
mock.triggerClose({ code: 1006, reason: "network lost" });
await expect(reload).rejects.toThrow(/network lost|disconnected|closed/i);
// Reconnecting must not resend the lost reload: only the caller decides to reload again.
const reconnecting = client.connect();
mock.triggerOpen();
await reconnecting;
expect(mock.sent).toEqual([]);
});
test("keeps waitForAgentUpsert initial fetch inside the requested deadline", async () => {
useHeartbeatClock();
const logger = createMockLogger();
const mock = createMockTransport();
const client = new DaemonClient({
url: "ws://test",
clientId: "clsk_unit_test",
logger,
reconnect: { enabled: false },
transportFactory: () => mock.transport,
});
clients.push(client);
const connectPromise = client.connect();
mock.triggerOpen();
await connectPromise;
const waitPromise = client.waitForAgentUpsert("agent-1", () => false, 5_000);
let settled = false;
void waitPromise.then(
() => {
settled = true;
return undefined;
},
() => {
settled = true;
return undefined;
},
);
expect(parseSentFrame(mock.sent[0])).toEqual({
type: "fetch_agent_request",
requestId: expect.any(String),
agentId: "agent-1",
});
await vi.advanceTimersByTimeAsync(4_999);
expect(settled).toBe(false);
await vi.advanceTimersByTimeAsync(1);
await expect(waitPromise).rejects.toThrow("Timed out waiting for agent agent-1");
});
test("keeps default connect timeout shorter than session RPC waiters", async () => {
useHeartbeatClock();
try {
const logger = createMockLogger();
const mock = createMockTransport();
const client = new DaemonClient({
url: "ws://test",
clientId: "clsk_unit_test",
logger,
reconnect: { enabled: false },
transportFactory: () => mock.transport,
});
clients.push(client);
let settled = false;
const pendingConnect = client.connect().then(
() => {
settled = true;
return { ok: true as const };
},
(error) => {
settled = true;
return { ok: false as const, error };
},
);
await vi.advanceTimersByTimeAsync(14_999);
expect(settled).toBe(false);
expect(client.getConnectionState().status).toBe("connecting");
await vi.advanceTimersByTimeAsync(1);
const result = await pendingConnect;
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(client.getConnectionState().status).toBe("disconnected");
} finally {
vi.useRealTimers();
}
});
test("foreground tears down a silently dead socket after two fresh five-second windows and reconnects at once", async () => {
useHeartbeatClock();
const first = new FakeDaemon();
const second = new FakeDaemon();
first.daemonGoesSilent();
let attempts = 0;
const client = new DaemonClient({
url: "ws://test",
clientId: "foreground-recovery",
logger: noopLogger,
transportFactory: () => (++attempts === 1 ? first : second).transport,
});
clients.push(client);
const connection = client.connect();
first.openConnection();
await connection;
client.ensureConnected({ verify: true });
expect(first.pingTimestamps()).toEqual(["0s"]);
await vi.advanceTimersByTimeAsync(5_000);
// The first silent window is followed by a second check at once, not ten seconds later.
expect(first.closesFromClient()).toEqual([]);
expect(first.pingTimestamps()).toEqual(["0s", "5s"]);
await vi.advanceTimersByTimeAsync(4_999);
expect(first.closesFromClient()).toEqual([]);
await vi.advanceTimersByTimeAsync(1);
expect(first.closesFromClient()).toEqual([{ code: 1001, reason: "Liveness check timed out" }]);
// The reconnect is immediate: no 1.5s backoff after a foreground liveness teardown.
expect(attempts).toBe(2);
second.openConnection();
expect(client.getConnectionState()).toEqual({ status: "connected" });
// The replacement runs the normal heartbeat cadence.
await vi.advanceTimersByTimeAsync(10_000);
expect(second.pingTimestamps()).toEqual(["20s"]);
expect(second.closesFromClient()).toEqual([]);
});
test("foreground verification preserves a healthy socket and deduplicates simultaneous checks", async () => {
useHeartbeatClock();
const daemon = new FakeDaemon();
daemon.daemonAnswersPingsAfter("0.1s");
const client = new DaemonClient({
url: "ws://test",
clientId: "healthy-resume",
logger: noopLogger,
transportFactory: () => daemon.transport,
});
clients.push(client);
const connection = client.connect();
daemon.openConnection();
await connection;
client.ensureConnected({ verify: true });
client.ensureConnected({ verify: true });
await vi.advanceTimersByTimeAsync(3_000);
expect(client.getConnectionState()).toEqual({ status: "connected" });
expect(daemon.pingTimestamps()).toEqual(["0s"]);
expect(daemon.closesFromClient()).toEqual([]);
});
test("chunks prevent heartbeat misses until silence causes two misses", async () => {
useHeartbeatClock();
const daemon = new FakeDaemon();
daemon.daemonGoesSilent();
const client = new DaemonClient({
url: "ws://test",
clientId: "bulk-resume",
logger: noopLogger,
reconnect: { enabled: false },
transportFactory: () => daemon.transport,
});
clients.push(client);
const connection = client.connect();
daemon.openConnection();
await connection;
client.ensureConnected({ verify: true });
let measurementError: unknown;
void client.measureLatency({ timeoutMs: 5_000 }).catch((error: unknown) => {
measurementError = error;
});
// Chunks complete every four seconds for the first 36 seconds; no pong ever arrives.
for (let index = 0; index < 9; index++) {
await vi.advanceTimersByTimeAsync(4_000);
daemon.receiveChunk(index === 0);
expect(daemon.closesFromClient()).toEqual([]);
}
expect(measurementError).toEqual(
expect.objectContaining({
message: "Latency measurement timed out (5000ms)",
}),
);
// Progress inside the foreground window returned the heartbeat to its normal cadence.
expect(daemon.pingTimestamps()).toEqual(["0s", "15s"]);
await vi.advanceTimersByTimeAsync(19_000);
expect(daemon.closesFromClient()).toEqual([]);
expect(daemon.pingTimestamps()).toEqual(["0s", "15s", "40s"]);
await vi.advanceTimersByTimeAsync(24_999);
expect(daemon.closesFromClient()).toEqual([]);
await vi.advanceTimersByTimeAsync(1);
expect(daemon.pingTimestamps()).toEqual(["0s", "15s", "40s", "65s"]);
expect(daemon.closesFromClient()).toEqual([{ code: 1001, reason: "Liveness check timed out" }]);
});
test("foreground preserves a multi-MB transfer with its pong queued behind chunks", async () => {
useHeartbeatClock();
const daemon = new FakeDaemon();
daemon.daemonGoesSilent();
const client = new DaemonClient({
url: "ws://test",
clientId: "bulk-resume",
logger: noopLogger,
reconnect: { enabled: false },
transportFactory: () => daemon.transport,
});
clients.push(client);
const connection = client.connect();
daemon.openConnection();
await connection;
client.ensureConnected({ verify: true });
await vi.advanceTimersByTimeAsync(3_001);
expect(daemon.closesFromClient()).toEqual([]);
for (let index = 0; index < 5; index++) {
await vi.advanceTimersByTimeAsync(4_000);
daemon.receiveChunk(index === 0);
expect(daemon.closesFromClient()).toEqual([]);
}
// The first chunk landed seven seconds after foreground: the first window was silent, the
// second saw progress, so the cycle ended without a teardown and the cadence is normal again.
expect(daemon.pingTimestamps()).toEqual(["0s", "5s", "20s"]);
daemon.finishTransfer();
await vi.advanceTimersByTimeAsync(30_000);
expect(client.getConnectionState()).toEqual({ status: "connected" });
expect(daemon.closesFromClient()).toEqual([]);
});
test("chunk progress beyond the daemon lease keeps sending heartbeat pings", async () => {
useHeartbeatClock();
const daemon = new FakeDaemon();
daemon.daemonGoesSilent();
const client = new DaemonClient({
url: "ws://test",
clientId: "long-download",
logger: noopLogger,
reconnect: { enabled: false },
transportFactory: () => daemon.transport,
});
clients.push(client);
const connection = client.connect();
daemon.openConnection();
await connection;
client.ensureConnected({ verify: true });
for (let index = 0; index < 12; index++) {
await vi.advanceTimersByTimeAsync(4_000);
daemon.receiveChunk(index === 0);
// Each completed window starts the next ping ten seconds later,
// even though the response and all queued pongs are still in flight.
const now = (index + 1) * 4;
const expected = ["0s"];
if (now >= 15) expected.push("15s");
if (now >= 40) expected.push("40s");
expect(daemon.pingTimestamps()).toEqual(expected);
expect(daemon.closesFromClient()).toEqual([]);
}
daemon.finishTransfer();
await vi.advanceTimersByTimeAsync(30_000);
expect(client.getConnectionState()).toEqual({ status: "connected" });
expect(daemon.closesFromClient()).toEqual([]);
});
function connectThroughReplacements(
clientId: string,
daemons: FakeDaemon[],
options?: { reconnect?: { enabled: boolean } },
): { client: DaemonClient; attempts: () => number } {
let attempts = 0;
const client = new DaemonClient({
url: "ws://test",
clientId,
logger: noopLogger,
...(options?.reconnect ? { reconnect: options.reconnect } : {}),
transportFactory: () => {
const daemon = daemons[Math.min(attempts, daemons.length - 1)]!;
attempts += 1;
return daemon.transport;
},
});
clients.push(client);
return { client, attempts: () => attempts };
}
test("foreground supersedes a heartbeat that straddled the suspension without counting a miss", async () => {
useHeartbeatClock();
const first = new FakeDaemon();
const second = new FakeDaemon();
const { client, attempts } = connectThroughReplacements("resume-before-expiry", [first, second]);
const connection = client.connect();
first.openConnection();
await connection;
first.daemonGoesSilent();
await vi.advanceTimersByTimeAsync(10_000); // heartbeat ping outstanding when the OS suspends JS
await vi.advanceTimersByTimeAsync(14_990); // resume: AppState lands before the stale timer
client.ensureConnected({ verify: true });
expect(first.pingTimestamps()).toEqual(["10s", "24.99s"]);
await vi.advanceTimersByTimeAsync(10); // the stale deadline passes without a miss
await vi.advanceTimersByTimeAsync(4_990);
expect(first.closesFromClient()).toEqual([]);
expect(first.pingTimestamps()).toEqual(["10s", "24.99s", "29.99s"]);
await vi.advanceTimersByTimeAsync(5_000);
expect(first.closesFromClient()).toEqual([{ code: 1001, reason: "Liveness check timed out" }]);
expect(attempts()).toBe(2);
});
test("foreground does not inherit a miss recorded before the app was suspended", async () => {
useHeartbeatClock();
const first = new FakeDaemon();
const second = new FakeDaemon();
const { client, attempts } = connectThroughReplacements("resume-after-expiry", [first, second]);
const connection = client.connect();
first.openConnection();
await connection;
first.daemonGoesSilent();
await vi.advanceTimersByTimeAsync(25_000); // ping at 10s missed at 25s, before or during suspension
client.ensureConnected({ verify: true });
expect(first.pingTimestamps()).toEqual(["10s", "25s"]);
await vi.advanceTimersByTimeAsync(5_000);
// Two fresh windows: the old miss would have torn down here.
expect(first.closesFromClient()).toEqual([]);
expect(first.pingTimestamps()).toEqual(["10s", "25s", "30s"]);
await vi.advanceTimersByTimeAsync(5_000);
expect(first.closesFromClient()).toEqual([{ code: 1001, reason: "Liveness check timed out" }]);
expect(attempts()).toBe(2);
});
test("a slow but healthy socket survives foreground after a miss before the suspension", async () => {
useHeartbeatClock();
const daemon = new FakeDaemon();
const { client, attempts } = connectThroughReplacements("resume-slow-pong", [daemon]);
const connection = client.connect();
daemon.openConnection();
await connection;
daemon.daemonGoesSilent();
await vi.advanceTimersByTimeAsync(25_000); // one miss
daemon.daemonAnswersPingsAfter("5.5s");
client.ensureConnected({ verify: true });
await vi.advanceTimersByTimeAsync(60_000);
expect(daemon.closesFromClient()).toEqual([]);
expect(client.getConnectionState()).toEqual({ status: "connected" });
expect(attempts()).toBe(1);
});
test("a pong that arrives after the first foreground window keeps the connection and restores the cadence", async () => {
useHeartbeatClock();
const daemon = new FakeDaemon();
daemon.daemonAnswersPingsAfter("5.5s");
const { client, attempts } = connectThroughReplacements("slow-pong", [daemon]);
const connection = client.connect();
daemon.openConnection();
await connection;
client.ensureConnected({ verify: true });
await vi.advanceTimersByTimeAsync(5_000);
expect(daemon.pingTimestamps()).toEqual(["0s", "5s"]);
await vi.advanceTimersByTimeAsync(55_000);
expect(client.getConnectionState()).toEqual({ status: "connected" });
expect(daemon.closesFromClient()).toEqual([]);
expect(attempts()).toBe(1);
// After the late pong the heartbeat runs ten seconds after each settled probe again.
expect(daemon.pingTimestamps()).toEqual(["0s", "5s", "15.5s", "31s", "46.5s"]);
});
test("a latency measurement outstanding at foreground keeps its own deadline, then the cycle runs", async () => {
useHeartbeatClock();
const first = new FakeDaemon();
const second = new FakeDaemon();
first.daemonGoesSilent();
const { client, attempts } = connectThroughReplacements("latency-then-foreground", [
first,
second,
]);
const connection = client.connect();
first.openConnection();
await connection;
const measurement = client.measureLatency({ timeoutMs: 1_000 });
const measurementError = measurement.then(
() => null,
(error: unknown) => error,
);
client.ensureConnected({ verify: true });
expect(first.pingTimestamps()).toEqual(["0s"]);
await vi.advanceTimersByTimeAsync(1_000);
await expect(measurementError).resolves.toEqual(
expect.objectContaining({ message: "Latency measurement timed out (1000ms)" }),
);
expect(first.closesFromClient()).toEqual([]);
await vi.advanceTimersByTimeAsync(5_000);
expect(first.pingTimestamps()).toEqual(["0s", "1s", "6s"]);
expect(first.closesFromClient()).toEqual([]);
await vi.advanceTimersByTimeAsync(5_000);
expect(first.closesFromClient()).toEqual([{ code: 1001, reason: "Liveness check timed out" }]);
expect(attempts()).toBe(2);
});
test("a shared latency probe's activity from before foreground does not satisfy the fresh check", async () => {
useHeartbeatClock();
const first = new FakeDaemon();
const second = new FakeDaemon();
first.daemonGoesSilent();
const { client, attempts } = connectThroughReplacements("stale-activity", [first, second]);
const connection = client.connect();
first.openConnection();
await connection;
void client.measureLatency({ timeoutMs: 5_000 }).catch(() => {});
first.receiveChunk(true); // a frame arrived while the measurement was outstanding, then the socket died
client.ensureConnected({ verify: true });
expect(first.pingTimestamps()).toEqual(["0s"]);
await vi.advanceTimersByTimeAsync(5_000);
// The measurement's own deadline passed; the foreground cycle starts its first fresh window now.
expect(first.closesFromClient()).toEqual([]);
expect(first.pingTimestamps()).toEqual(["0s", "5s"]);
await vi.advanceTimersByTimeAsync(5_000);
expect(first.pingTimestamps()).toEqual(["0s", "5s", "10s"]);
await vi.advanceTimersByTimeAsync(5_000);
expect(first.closesFromClient()).toEqual([{ code: 1001, reason: "Liveness check timed out" }]);
expect(attempts()).toBe(2);
});
test("a latency measurement that joined a heartbeat probe survives the foreground check", async () => {
useHeartbeatClock();
const daemon = new FakeDaemon();
daemon.daemonAnswersPingsAfter("2s");
const { client, attempts } = connectThroughReplacements("latency-joins-heartbeat", [daemon]);
const connection = client.connect();
daemon.openConnection();
await connection;
await vi.advanceTimersByTimeAsync(10_000); // the first heartbeat ping is outstanding
const measurement = client.measureLatency({ timeoutMs: 5_000 });
await vi.advanceTimersByTimeAsync(1_000);
client.ensureConnected({ verify: true });
expect(daemon.pingTimestamps()).toEqual(["10s", "11s"]);
// The heartbeat's pong lands at 12s and answers the measurement that joined it.
await vi.advanceTimersByTimeAsync(1_000);
await expect(measurement).resolves.toBe(2000);
await vi.advanceTimersByTimeAsync(19_000);
expect(client.getConnectionState()).toEqual({ status: "connected" });
expect(daemon.closesFromClient()).toEqual([]);
expect(attempts()).toBe(1);
expect(daemon.pingTimestamps()).toEqual(["10s", "11s", "22s"]);
});
test("a joined latency measurement outlives a silent foreground window until its own deadline", async () => {
useHeartbeatClock();
const daemon = new FakeDaemon();
daemon.daemonAnswersPingsAfter("7s");
const { client, attempts } = connectThroughReplacements("latency-outlives-window", [daemon]);
const connection = client.connect();
daemon.openConnection();
await connection;
await vi.advanceTimersByTimeAsync(10_000); // the first heartbeat ping is outstanding
const measurement = client.measureLatency({ timeoutMs: 10_000 });
await vi.advanceTimersByTimeAsync(1_000);
client.ensureConnected({ verify: true });
await vi.advanceTimersByTimeAsync(5_000); // the first window closes silent at 16s
expect(daemon.pingTimestamps()).toEqual(["10s", "11s", "16s"]);
// The heartbeat's pong lands at 17s, inside the second window and before the 20s deadline.
await vi.advanceTimersByTimeAsync(1_000);
await expect(measurement).resolves.toBe(7000);
await vi.advanceTimersByTimeAsync(10_000);
expect(client.getConnectionState()).toEqual({ status: "connected" });
expect(daemon.closesFromClient()).toEqual([]);
expect(attempts()).toBe(1);
expect(daemon.pingTimestamps()).toEqual(["10s", "11s", "16s", "27s"]);
});
// Fake timers cannot suspend: a suspension is modelled as the wall clock jumping forward while
// the pending window timer stays queued, then firing late on resume.
async function suspendFor(ms: number): Promise<void> {
vi.setSystemTime(Date.now() + ms);
}
test("a foreground window that spanned a second suspension is discarded when the timer fires first", async () => {
useHeartbeatClock();
const daemon = new FakeDaemon();
daemon.daemonGoesSilent();
const { client, attempts } = connectThroughReplacements("suspended-mid-cycle-timer", [daemon]);
const connection = client.connect();
daemon.openConnection();
await connection;
client.ensureConnected({ verify: true });
await vi.advanceTimersByTimeAsync(5_000); // window 1 silent, window 2 outstanding
expect(daemon.pingTimestamps()).toEqual(["0s", "5s"]);
await suspendFor(60_000);
daemon.daemonAnswersPingsAfter("fast"); // the socket is healthy on resume
await vi.advanceTimersByTimeAsync(5_000); // window 2's timer fires late
client.ensureConnected({ verify: true }); // then AppState lands
await vi.advanceTimersByTimeAsync(1_000);
expect(daemon.closesFromClient()).toEqual([]);
expect(client.getConnectionState()).toEqual({ status: "connected" });
expect(attempts()).toBe(1);
});
test("a foreground window that spanned a second suspension is discarded when AppState fires first", async () => {
useHeartbeatClock();
const daemon = new FakeDaemon();
daemon.daemonGoesSilent();
const { client, attempts } = connectThroughReplacements("suspended-mid-cycle-appstate", [daemon]);
const connection = client.connect();
daemon.openConnection();
await connection;
client.ensureConnected({ verify: true });
await vi.advanceTimersByTimeAsync(5_000);
expect(daemon.pingTimestamps()).toEqual(["0s", "5s"]);
await suspendFor(60_000);
daemon.daemonAnswersPingsAfter("fast");
client.ensureConnected({ verify: true }); // AppState lands before the queued timer
await vi.advanceTimersByTimeAsync(5_000); // window 2's timer fires late
await vi.advanceTimersByTimeAsync(1_000);
expect(daemon.closesFromClient()).toEqual([]);
expect(client.getConnectionState()).toEqual({ status: "connected" });
expect(attempts()).toBe(1);
});
test("a dead socket after a second suspension mid-cycle still gets two fresh windows", async () => {
useHeartbeatClock();
const first = new FakeDaemon();
const second = new FakeDaemon();
first.daemonGoesSilent();
const { client, attempts } = connectThroughReplacements("suspended-mid-cycle-dead", [
first,
second,
]);
const connection = client.connect();
first.openConnection();
await connection;
client.ensureConnected({ verify: true });
await vi.advanceTimersByTimeAsync(5_000);
first.receiveChunk(true); // a frame before the second suspension is not evidence after it
await suspendFor(60_000);
client.ensureConnected({ verify: true });
await vi.advanceTimersByTimeAsync(5_000); // the late timer discards window 2 and starts over
expect(first.pingTimestamps()).toEqual(["0s", "5s", "10s"]);
await vi.advanceTimersByTimeAsync(5_000);
expect(first.closesFromClient()).toEqual([]);
expect(first.pingTimestamps()).toEqual(["0s", "5s", "10s", "15s"]);
await vi.advanceTimersByTimeAsync(5_000);
expect(first.closesFromClient()).toEqual([{ code: 1001, reason: "Liveness check timed out" }]);
expect(attempts()).toBe(2);
});
test("repeated foreground events share one cycle and do not restart its windows", async () => {
useHeartbeatClock();
const first = new FakeDaemon();
const second = new FakeDaemon();
first.daemonGoesSilent();
const { client, attempts } = connectThroughReplacements("repeated-foreground", [first, second]);
const connection = client.connect();
first.openConnection();
await connection;
client.ensureConnected({ verify: true });
for (let elapsedSeconds = 1; elapsedSeconds <= 9; elapsedSeconds++) {
await vi.advanceTimersByTimeAsync(1_000);
client.ensureConnected({ verify: true });
expect(first.closesFromClient()).toEqual([]);
}
expect(first.pingTimestamps()).toEqual(["0s", "5s"]);
await vi.advanceTimersByTimeAsync(1_000);
expect(first.closesFromClient()).toEqual([{ code: 1001, reason: "Liveness check timed out" }]);
expect(attempts()).toBe(2);
second.openConnection();
expect(client.getConnectionState()).toEqual({ status: "connected" });
});
test("a late pong from the replaced socket does not count for the replacement", async () => {
useHeartbeatClock();
const first = new FakeDaemon();
const second = new FakeDaemon();
first.daemonGoesSilent();
second.daemonGoesSilent();
const { client, attempts } = connectThroughReplacements("late-pong", [first, second]);
const connection = client.connect();
first.openConnection();
await connection;
client.ensureConnected({ verify: true });
await vi.advanceTimersByTimeAsync(10_000);
expect(attempts()).toBe(2);
second.openConnection();
client.ensureConnected({ verify: true });
expect(second.pingTimestamps()).toEqual(["10s"]);
await vi.advanceTimersByTimeAsync(2_000);
first.daemonSendsPong();
await vi.advanceTimersByTimeAsync(8_000);
expect(second.pingTimestamps()).toEqual(["10s", "15s"]);
expect(second.closesFromClient()).toEqual([{ code: 1001, reason: "Liveness check timed out" }]);
expect(first.closesFromClient()).toEqual([{ code: 1001, reason: "Liveness check timed out" }]);
});
test("closing the client in the middle of a foreground cycle stops it", async () => {
useHeartbeatClock();
const first = new FakeDaemon();
const second = new FakeDaemon();
first.daemonGoesSilent();
const { client, attempts } = connectThroughReplacements("close-mid-cycle", [first, second]);
const connection = client.connect();
first.openConnection();
await connection;
client.ensureConnected({ verify: true });
await vi.advanceTimersByTimeAsync(5_000);
expect(first.pingTimestamps()).toEqual(["0s", "5s"]);
await client.close();
await vi.advanceTimersByTimeAsync(60_000);
expect(first.pingTimestamps()).toEqual(["0s", "5s"]);
expect(first.closesFromClient()).toEqual([{ code: 1000, reason: "Client closed" }]);
expect(attempts()).toBe(1);
expect(client.getConnectionState()).toEqual({ status: "disposed" });
});
test("a foreground teardown with reconnect disabled reports the timeout and stays down", async () => {
useHeartbeatClock();
const daemon = new FakeDaemon();
daemon.daemonGoesSilent();
const { client, attempts } = connectThroughReplacements("no-reconnect", [daemon], {
reconnect: { enabled: false },
});
const connection = client.connect();
daemon.openConnection();
await connection;
client.ensureConnected({ verify: true });
await vi.advanceTimersByTimeAsync(10_000);
expect(client.getConnectionState()).toEqual({
status: "disconnected",
reason: "Liveness check timed out (5000ms)",
});
await vi.advanceTimersByTimeAsync(10_000);
expect(attempts()).toBe(1);
});
test("the immediate reconnect after a foreground teardown backs off normally if it fails", async () => {
useHeartbeatClock();
const first = new FakeDaemon();
const second = new FakeDaemon();
const third = new FakeDaemon();
first.daemonGoesSilent();
const { client, attempts } = connectThroughReplacements("no-storm", [first, second, third]);
const connection = client.connect();
first.openConnection();
await connection;
client.ensureConnected({ verify: true });
await vi.advanceTimersByTimeAsync(10_000);
expect(attempts()).toBe(2);
second.daemonClosesWith("connection refused");
await vi.advanceTimersByTimeAsync(1_499);
expect(attempts()).toBe(2);
await vi.advanceTimersByTimeAsync(1);
expect(attempts()).toBe(3);
third.openConnection();
expect(client.getConnectionState()).toEqual({ status: "connected" });
});
test("an obsolete heartbeat cannot close a replacement connection", async () => {
useHeartbeatClock();
const first = new FakeDaemon();
const second = new FakeDaemon();
first.daemonGoesSilent();
let attempts = 0;
const client = new DaemonClient({
url: "ws://test",
clientId: "resume-race",
logger: noopLogger,
transportFactory: () => (++attempts === 1 ? first : second).transport,
});
clients.push(client);
const connection = client.connect();
first.openConnection();
await connection;
client.ensureConnected({ verify: true });
first.daemonClosesWith("network changed");
client.ensureConnected();
second.openConnection();
await vi.advanceTimersByTimeAsync(3_000);
expect(attempts).toBe(2);
expect(client.getConnectionState()).toEqual({ status: "connected" });
expect(second.closesFromClient()).toEqual([]);
});
test("stays online through ten minutes of pongs that arrive five seconds late", async () => {
useHeartbeatClock();
const session = new DaemonClientSession();
session.daemonAnswersPingsAfter("5.5s");
await session.connect();
await session.advance(10 * 60 * 1000);
expect(session.state()).toEqual({ status: "connected" });
expect(session.teardownCount()).toBe(0);
expect(session.lastError()).toBeNull();
expect(session.pingTimestamps().length).toBeGreaterThan(0);
expect(session.lastLivenessRttMs()).toBe(5500);
});
test("tears down and reports a liveness timeout after the daemon goes silent for two cycles", async () => {
useHeartbeatClock();
const session = new DaemonClientSession();
session.daemonGoesSilent();
await session.connect();
await session.advance(51_000);
expect(session.state()).toEqual({
status: "disconnected",
reason: "Liveness check timed out (15000ms)",
});
expect(session.teardownCount()).toBe(1);
});
test("survives a single missed pong when answers resume", async () => {
useHeartbeatClock();
const session = new DaemonClientSession();
session.daemonWithholdsNextPongThenAnswersFast();
await session.connect();
await session.advance(40_000);
expect(session.state()).toEqual({ status: "connected" });
expect(session.teardownCount()).toBe(0);
});
test("keeps the connection proven online from heartbeats alone when no other traffic flows", async () => {
useHeartbeatClock();
const session = new DaemonClientSession();
session.daemonAnswersPingsAfter("fast");
await session.connect();
await session.advance(35_000);
expect(session.state()).toEqual({ status: "connected" });
expect(session.pingTimestamps()).toEqual(["10s", "20s", "30s"]);
});
test("starts pinging one interval after connecting and holds the cadence", async () => {
useHeartbeatClock();
const session = new DaemonClientSession();
session.daemonAnswersPingsAfter("fast");
await session.connect();
await session.advance(9_999);
expect(session.pingTimestamps()).toEqual([]);
await session.advance(20_001);
expect(session.pingTimestamps()).toEqual(["10s", "20s", "30s"]);
});
test("stops pinging once the connection is gone", async () => {
useHeartbeatClock();
const session = new DaemonClientSession();
session.daemonAnswersPingsAfter("fast");
await session.connect();
await session.advance(10_000);
session.daemonClosesWith("daemon shutting down");
await session.advance(30_000);
expect(session.pingTimestamps()).toEqual(["10s"]);
});
test("sends only one ping while a slow pong is still outstanding", async () => {
useHeartbeatClock();
const session = new DaemonClientSession();
session.daemonAnswersPingsAfter("15.1s");
await session.connect();
await session.advance(20_000);
expect(session.pingTimestamps()).toEqual(["10s"]);
});
test("a heartbeat sharing a latency measurement still saves the RTT", async () => {
useHeartbeatClock();
const session = new DaemonClientSession();
session.daemonAnswersPingsAfter("2s");
await session.connect();
await session.advance(9_000);
const measurement = session.measureLatency({ timeoutMs: 5_000 });
await session.advance(2_000);
await expect(measurement).resolves.toBe(2000);
expect(session.lastLivenessRttMs()).toBe(2000);
});
test("a measurement that joins an in-flight ping reports that ping's round trip", async () => {
useHeartbeatClock();
const session = new DaemonClientSession();
session.daemonAnswersPingsAfter("2s");
await session.connect();
await session.advance(11_000); // the heartbeat ping sent at 10s is still outstanding
const measurement = session.measureLatency({ timeoutMs: 5_000 });
await session.advance(1_000);
await expect(measurement).resolves.toBe(2000);
expect(session.lastLivenessRttMs()).toBe(2000);
});
test("reports the round-trip time of the last successful heartbeat", async () => {
useHeartbeatClock();
const session = new DaemonClientSession();
session.daemonAnswersPingsAfter("2.3s");
await session.connect();
await session.advance(12_300);
expect(session.lastLivenessRttMs()).toBe(2300);
});
test("treats a pong just under the timeout as alive and just over as a miss", async () => {
useHeartbeatClock();
const alive = new DaemonClientSession();
alive.daemonAnswersPingsAfter("14.9s");
await alive.connect();
await alive.advance(24_900);
expect(alive.state()).toEqual({ status: "connected" });
const missed = new DaemonClientSession();
missed.daemonAnswersPingsAfter("15.1s");
await missed.connect();
await missed.advance(25_000);
expect(missed.state()).toEqual({ status: "connected" });
expect(missed.lastLivenessRttMs()).toBeNull();
});
test("goes red with the daemon's reason when the connection is closed", async () => {
useHeartbeatClock();
const session = new DaemonClientSession();
await session.connect();
session.daemonClosesWith("Control unresponsive");
expect(session.state()).toEqual({
status: "disconnected",
reason: "Control unresponsive",
});
expect(session.lastError()).toBe("Control unresponsive");
expect(session.closesFromClient()).toEqual([]);
});
test("two candidate latency timeouts do not tear down the live connection", async () => {
useHeartbeatClock();
const session = new DaemonClientSession();
session.daemonGoesSilent();
await session.connect();
const firstMeasurement = session.measureLatency({ timeoutMs: 1000 });
const firstMeasurementError = firstMeasurement.then(
() => null,
(error) => error,
);
await session.advance(1000);
await expect(firstMeasurementError).resolves.toEqual(
expect.objectContaining({
message: "Latency measurement timed out (1000ms)",
}),
);
const secondMeasurement = session.measureLatency({ timeoutMs: 1000 });
const secondMeasurementError = secondMeasurement.then(
() => null,
(error) => error,
);
await session.advance(1000);
await expect(secondMeasurementError).resolves.toEqual(
expect.objectContaining({
message: "Latency measurement timed out (1000ms)",
}),
);
expect(session.state()).toEqual({ status: "connected" });
expect(session.teardownCount()).toBe(0);
expect(session.pingTimestamps()).toEqual(["0s", "1s"]);
});
test("a candidate measurement that times out under a heartbeat tick does not count toward teardown", async () => {
useHeartbeatClock();
const session = new DaemonClientSession();
session.daemonGoesSilent();
await session.connect();
// A candidate measurement is still in flight when the +10s heartbeat tick lands,
// so the heartbeat shares the in-flight ping. Let the measurement time out.
await session.advance(9_000);
const measurement = session.measureLatency({ timeoutMs: 5_000 });
const measurementError = measurement.then(
() => null,
(error) => error,
);
await session.advance(5_500);
await expect(measurementError).resolves.toEqual(
expect.objectContaining({ message: "Latency measurement timed out (5000ms)" }),
);
// The measurement timeout must not have been recorded as a liveness failure: a
// single genuine heartbeat miss after it must still leave the connection up.
await session.advance(25_000);
expect(session.state()).toEqual({ status: "connected" });
expect(session.teardownCount()).toBe(0);
});
test("file context action RPCs correlate success and error responses", async () => {
const mock = createMockTransport();
const client = new DaemonClient({
url: "ws://test",
clientId: "clsk_context_actions",
logger: createMockLogger(),
reconnect: { enabled: false },
transportFactory: () => mock.transport,
});
clients.push(client);
const connectPromise = client.connect();
mock.triggerOpen();
await connectPromise;
const createPromise = client.createFileEntry({
cwd: "/tmp/project",
parentPath: "src",
name: "new.ts",
kind: "file",
});
const createRequest = parseSentFrame(mock.sent.at(-1));
expect(createRequest).toEqual({
type: "fs.entry.create.request",
cwd: "/tmp/project",
parentPath: "src",
name: "new.ts",
kind: "file",
requestId: expect.any(String),
});
mock.triggerMessage(
wrapSessionMessage({
type: "fs.entry.create.response",
payload: {
cwd: "/tmp/project",
parentPath: "src",
path: "src/new.ts",
success: true,
error: null,
requestId: createRequest.requestId,
},
}),
);
await expect(createPromise).resolves.toMatchObject({
path: "src/new.ts",
success: true,
error: null,
});
const renamePromise = client.renameFileEntry({
cwd: "/tmp/project",
path: "src/new.ts",
name: "existing.ts",
});
const renameRequest = parseSentFrame(mock.sent.at(-1));
expect(renameRequest).toMatchObject({
type: "fs.entry.rename.request",
cwd: "/tmp/project",
path: "src/new.ts",
name: "existing.ts",
});
mock.triggerMessage(
wrapSessionMessage({
type: "fs.entry.rename.response",
payload: {
cwd: "/tmp/project",
path: "src/new.ts",
renamedPath: null,
success: false,
error: '"existing.ts" already exists',
requestId: renameRequest.requestId,
},
}),
);
await expect(renamePromise).resolves.toMatchObject({
renamedPath: null,
success: false,
error: '"existing.ts" already exists',
});
const duplicatePromise = client.duplicateFileEntry({
cwd: "/tmp/project",
path: "src/new.ts",
});
const duplicateRequest = parseSentFrame(mock.sent.at(-1));
expect(duplicateRequest).toMatchObject({
type: "fs.entry.duplicate.request",
cwd: "/tmp/project",
path: "src/new.ts",
});
mock.triggerMessage(
wrapSessionMessage({
type: "fs.entry.duplicate.response",
payload: {
cwd: "/tmp/project",
path: "src/new.ts",
duplicatedPath: "src/new copy.ts",
success: true,
error: null,
requestId: duplicateRequest.requestId,
},
}),
);
await expect(duplicatePromise).resolves.toMatchObject({
duplicatedPath: "src/new copy.ts",
success: true,
error: null,
});
const deletePromise = client.deleteFileEntry({ cwd: "/tmp/project", path: "src/new.ts" });
const deleteRequest = parseSentFrame(mock.sent.at(-1));
expect(deleteRequest).toMatchObject({
type: "fs.entry.delete.request",
cwd: "/tmp/project",
path: "src/new.ts",
});
mock.triggerMessage(
wrapSessionMessage({
type: "fs.entry.delete.response",
payload: {
cwd: "/tmp/project",
path: "src/new.ts",
success: true,
error: null,
requestId: deleteRequest.requestId,
},
}),
);
await expect(deletePromise).resolves.toMatchObject({ success: true, error: null });
const discardPromise = client.checkoutDiscardChanges("/tmp/project", { paths: ["src"] });
const discardRequest = parseSentFrame(mock.sent.at(-1));
expect(discardRequest).toMatchObject({
type: "checkout.discard_changes.request",
cwd: "/tmp/project",
paths: ["src"],
});
mock.triggerMessage(
wrapSessionMessage({
type: "checkout.discard_changes.response",
payload: {
cwd: "/tmp/project",
success: false,
error: { code: "NOT_GIT_REPO", message: "Not a git repository" },
requestId: discardRequest.requestId,
},
}),
);
await expect(discardPromise).resolves.toMatchObject({
success: false,
error: { code: "NOT_GIT_REPO", message: "Not a git repository" },
});
});
test("sends plugin source identifiers unchanged for daemon-host resolution", async () => {
const mock = createMockTransport();
const client = new DaemonClient({
url: "ws://test",
clientId: "clsk_plugin_source",
logger: createMockLogger(),
reconnect: { enabled: false },
transportFactory: () => mock.transport,
});
clients.push(client);
const connectPromise = client.connect();
mock.triggerOpen({ features: { pluginSourceInstallation: true } });
await connectPromise;
const installPromise = client.installPluginSource({
source: "owner/repository:plugins/review",
});
const request = parseSentFrame(mock.sent.at(-1));
expect(request).toEqual({
type: "plugin.source.install.request",
requestId: expect.any(String),
source: "owner/repository:plugins/review",
});
mock.triggerMessage(
wrapSessionMessage({
type: "plugin.source.install.response",
payload: {
requestId: request.requestId,
plugin: {
id: "review",
path: "/plugins/review",
enabled: true,
status: "running",
},
},
}),
);
await expect(installPromise).resolves.toMatchObject({ id: "review", status: "running" });
});
test("a connection loss rejects an in-flight file context action", async () => {
const mock = createMockTransport();
const client = new DaemonClient({
url: "ws://test",
clientId: "clsk_context_disconnect",
logger: createMockLogger(),
reconnect: { enabled: false },
transportFactory: () => mock.transport,
});
clients.push(client);
const connectPromise = client.connect();
mock.triggerOpen();
await connectPromise;
const pending = client.deleteFileEntry({ cwd: "/tmp/project", path: "src/file.ts" });
mock.triggerClose({ code: 1006, reason: "network lost" });
await expect(pending).rejects.toThrow(/network lost|disconnected|closed/i);
});
test("listDirectory sends a list file explorer request and returns directory entries", async () => {
const logger = createMockLogger();
const mock = createMockTransport();
const client = new DaemonClient({
url: "ws://test",
clientId: "clsk_unit_test",
logger,
reconnect: { enabled: false },
transportFactory: () => mock.transport,
});
clients.push(client);
const connectPromise = client.connect();
mock.triggerOpen();
await connectPromise;
const responsePromise = client.listDirectory("/tmp/project", "src", "req-list");
expect(JSON.parse(assertStr(mock.sent[0]))).toEqual({
type: "session",
message: {
type: "file_explorer_request",
cwd: "/tmp/project",
path: "src",
mode: "list",
requestId: "req-list",
},
});
mock.triggerMessage(
wrapSessionMessage({
type: "file_explorer_response",
payload: {
cwd: "/tmp/project",
path: "src",
mode: "list",
directory: {
path: "src",
entries: [
{
name: "index.ts",
path: "src/index.ts",
kind: "file",
size: 12,
modifiedAt: "2026-05-02T00:00:00.000Z",
},
],
},
file: null,
error: null,
requestId: "req-list",
},
}),
);
await expect(responsePromise).resolves.toEqual({
path: "src",
entries: [
{
name: "index.ts",
path: "src/index.ts",
kind: "file",
size: 12,
modifiedAt: "2026-05-02T00:00:00.000Z",
},
],
});
});
test("readFile hides legacy base64 behind bytes", async () => {
const logger = createMockLogger();
const mock = createMockTransport();
const client = new DaemonClient({
url: "ws://test",
clientId: "clsk_unit_test",
logger,
reconnect: { enabled: false },
transportFactory: () => mock.transport,
});
clients.push(client);
const connectPromise = client.connect();
mock.triggerOpen();
await connectPromise;
const responsePromise = client.readFile("/tmp/project", "logo.png", "req-file");
expect(JSON.parse(assertStr(mock.sent[0]))).toEqual({
type: "session",
message: {
type: "file_explorer_request",
cwd: "/tmp/project",
path: "logo.png",
mode: "file",
acceptBinary: true,
requestId: "req-file",
},
});
mock.triggerMessage(
wrapSessionMessage({
type: "file_explorer_response",
payload: {
cwd: "/tmp/project",
path: "logo.png",
mode: "file",
directory: null,
file: {
path: "logo.png",
kind: "image",
encoding: "base64",
content: "aGVsbG8=",
mimeType: "image/png",
size: 5,
modifiedAt: "2026-05-02T00:00:00.000Z",
},
error: null,
requestId: "req-file",
},
}),
);
const result = await responsePromise;
expect(result).toMatchObject({
mime: "image/png",
size: 5,
path: "logo.png",
kind: "image",
modifiedAt: "2026-05-02T00:00:00.000Z",
});
expect(new TextDecoder().decode(result.bytes)).toBe("hello");
});
test("readFile resolves from binary file frames when the daemon supports them", async () => {
const logger = createMockLogger();
const mock = createMockTransport();
const client = new DaemonClient({
url: "ws://test",
clientId: "clsk_unit_test",
logger,
reconnect: { enabled: false },
transportFactory: () => mock.transport,
});
clients.push(client);
const connectPromise = client.connect();
mock.triggerOpen();
await connectPromise;
const responsePromise = client.readFile("/tmp/project", "logo.png", "req-binary");
expect(JSON.parse(assertStr(mock.sent[0]))).toEqual({
type: "session",
message: {
type: "file_explorer_request",
cwd: "/tmp/project",
path: "logo.png",
mode: "file",
acceptBinary: true,
requestId: "req-binary",
},
});
mock.triggerMessage(
encodeFileTransferFrame({
opcode: FileTransferOpcode.FileBegin,
requestId: "req-binary",
metadata: {
mime: "image/png",
size: 5,
encoding: "binary",
modifiedAt: "2026-05-02T00:00:00.000Z",
},
}),
);
mock.triggerMessage(
encodeFileTransferFrame({
opcode: FileTransferOpcode.FileChunk,
requestId: "req-binary",
payload: new TextEncoder().encode("hello"),
}),
);
mock.triggerMessage(
encodeFileTransferFrame({
opcode: FileTransferOpcode.FileEnd,
requestId: "req-binary",
}),
);
const result = await responsePromise;
expect(result).toMatchObject({
mime: "image/png",
size: 5,
path: "logo.png",
kind: "image",
modifiedAt: "2026-05-02T00:00:00.000Z",
});
expect(new TextDecoder().decode(result.bytes)).toBe("hello");
});
test("readFile drops an old daemon's over-budget binary chunks and reports the refusal", async () => {
const mock = createMockTransport();
const client = new DaemonClient({
url: "ws://test",
clientId: "clsk_file_budget_compat",
logger: createMockLogger(),
reconnect: { enabled: false },
transportFactory: () => mock.transport,
});
clients.push(client);
const connectPromise = client.connect();
mock.triggerOpen();
await connectPromise;
const responsePromise = client.readFile("/tmp/project", "large.txt", "req-budget", 10);
expect(JSON.parse(assertStr(mock.sent[0]))).toEqual({
type: "session",
message: {
type: "file_explorer_request",
cwd: "/tmp/project",
path: "large.txt",
mode: "file",
acceptBinary: true,
maxBytes: 10,
requestId: "req-budget",
},
});
mock.triggerMessage(
encodeFileTransferFrame({
opcode: FileTransferOpcode.FileBegin,
requestId: "req-budget",
metadata: {
mime: "text/plain",
size: 100,
encoding: "utf-8",
modifiedAt: "2026-05-02T00:00:00.000Z",
},
}),
);
mock.triggerMessage(
encodeFileTransferFrame({
opcode: FileTransferOpcode.FileChunk,
requestId: "req-budget",
payload: new Uint8Array(100),
}),
);
mock.triggerMessage(
encodeFileTransferFrame({ opcode: FileTransferOpcode.FileEnd, requestId: "req-budget" }),
);
await expect(responsePromise).rejects.toThrow("File is too large to display");
});
async function connectFileClient(clientId: string) {
const mock = createMockTransport();
const client = new DaemonClient({
url: "ws://test",
clientId,
logger: createMockLogger(),
reconnect: { enabled: false },
transportFactory: () => mock.transport,
});
clients.push(client);
const connectPromise = client.connect();
mock.triggerOpen();
await connectPromise;
return { mock, client };
}
function sendBinaryFile(
mock: ReturnType<typeof createMockTransport>,
requestId: string,
announcedSize: number,
chunks: string[],
) {
mock.triggerMessage(
encodeFileTransferFrame({
opcode: FileTransferOpcode.FileBegin,
requestId,
metadata: {
mime: "application/octet-stream",
size: announcedSize,
encoding: "binary",
modifiedAt: "2026-05-02T00:00:00.000Z",
},
}),
);
for (const chunk of chunks) {
mock.triggerMessage(
encodeFileTransferFrame({
opcode: FileTransferOpcode.FileChunk,
requestId,
payload: new TextEncoder().encode(chunk),
}),
);
}
}
test("readFile with timeout 0 outlives the default request timeout", async () => {
useHeartbeatClock();
const { mock, client } = await connectFileClient("clsk_file_no_timeout");
const responsePromise = client.readFile("/tmp/project", "slow.bin", "req-slow", undefined, 0);
await vi.advanceTimersByTimeAsync(60_001);
sendBinaryFile(mock, "req-slow", 5, ["hello"]);
mock.triggerMessage(
encodeFileTransferFrame({ opcode: FileTransferOpcode.FileEnd, requestId: "req-slow" }),
);
const result = await responsePromise;
expect(result.size).toBe(5);
expect(new TextDecoder().decode(result.bytes)).toBe("hello");
});
test("readFile rejects a binary transfer that ends before the announced size", async () => {
const { mock, client } = await connectFileClient("clsk_file_short");
const responsePromise = client.readFile("/tmp/project", "short.bin", "req-short");
sendBinaryFile(mock, "req-short", 10, ["hello"]);
mock.triggerMessage(
encodeFileTransferFrame({ opcode: FileTransferOpcode.FileEnd, requestId: "req-short" }),
);
await expect(responsePromise).rejects.toThrow(
"File transfer incomplete: expected 10 bytes, received 5.",
);
});
test("readFile rejects a binary transfer that exceeds the announced size", async () => {
const { mock, client } = await connectFileClient("clsk_file_long");
const responsePromise = client.readFile("/tmp/project", "long.bin", "req-long");
const settled = responsePromise.then(
() => null,
(error: unknown) => error,
);
sendBinaryFile(mock, "req-long", 4, ["hello!"]);
expect(() =>
mock.triggerMessage(
encodeFileTransferFrame({ opcode: FileTransferOpcode.FileEnd, requestId: "req-long" }),
),
).not.toThrow();
expect(await settled).toMatchObject({
message: "File transfer incomplete: expected 4 bytes, received 6.",
});
});
test("uploadFile sends metadata request and file bytes as binary chunks", async () => {
const logger = createMockLogger();
const mock = createMockTransport();
const client = new DaemonClient({
url: "ws://test",
clientId: "clsk_unit_test",
logger,
reconnect: { enabled: false },
transportFactory: () => mock.transport,
});
clients.push(client);
const connectPromise = client.connect();
mock.triggerOpen();
await connectPromise;
const responsePromise = client.uploadFile({
fileName: "notes.txt",
mimeType: "text/plain",
bytes: new TextEncoder().encode("hello world"),
modifiedAt: "2026-05-02T00:00:00.000Z",
requestId: "req-upload",
chunkSize: 5,
});
// Other tasks must run before a multi-chunk upload has queued all its bytes.
await new Promise((resolve) => setTimeout(resolve, 0));
const framesDuringUpload = mock.sent.slice(1).map(assertUint8Array).map(decodeFileTransferFrame);
expect(framesDuringUpload.some((frame) => frame.opcode === FileTransferOpcode.FileEnd)).toBe(
false,
);
await vi.waitFor(() => {
const frames = mock.sent.slice(1).map(assertUint8Array).map(decodeFileTransferFrame);
expect(frames.at(-1)?.opcode).toBe(FileTransferOpcode.FileEnd);
});
expect(JSON.parse(assertStr(mock.sent[0]))).toEqual({
type: "session",
message: {
type: "file.upload.request",
fileName: "notes.txt",
mimeType: "text/plain",
size: 11,
modifiedAt: "2026-05-02T00:00:00.000Z",
requestId: "req-upload",
},
});
expect(mock.sent.slice(1).map(assertUint8Array).map(decodeFileTransferFrame)).toEqual([
{
opcode: FileTransferOpcode.FileBegin,
requestId: "req-upload",
metadata: {
mime: "text/plain",
size: 11,
encoding: "binary",
modifiedAt: "2026-05-02T00:00:00.000Z",
fileName: "notes.txt",
},
payload: new Uint8Array(),
},
{
opcode: FileTransferOpcode.FileChunk,
requestId: "req-upload",
payload: new TextEncoder().encode("hello"),
},
{
opcode: FileTransferOpcode.FileChunk,
requestId: "req-upload",
payload: new TextEncoder().encode(" worl"),
},
{
opcode: FileTransferOpcode.FileChunk,
requestId: "req-upload",
payload: new TextEncoder().encode("d"),
},
{
opcode: FileTransferOpcode.FileEnd,
requestId: "req-upload",
payload: new Uint8Array(),
},
]);
mock.triggerMessage(
wrapSessionMessage({
type: "file.upload.response",
payload: {
requestId: "req-upload",
file: {
type: "uploaded_file",
id: "upload_req-upload",
fileName: "notes.txt",
mimeType: "text/plain",
size: 11,
path: "/tmp/paseo-uploads/upload_req-upload/notes.txt",
},
error: null,
},
}),
);
await expect(responsePromise).resolves.toEqual({
requestId: "req-upload",
file: {
type: "uploaded_file",
id: "upload_req-upload",
fileName: "notes.txt",
mimeType: "text/plain",
size: 11,
path: "/tmp/paseo-uploads/upload_req-upload/notes.txt",
},
error: null,
});
});
test("normalizes workspace_setup_progress into a workspace-scoped daemon event", async () => {
const logger = createMockLogger();
const mock = createMockTransport();
const client = new DaemonClient({
url: "ws://test",
clientId: "clsk_unit_test",
logger,
reconnect: { enabled: false },
transportFactory: () => mock.transport,
});
clients.push(client);
const events: Array<Parameters<Parameters<typeof client.subscribe>[0]>[0]> = [];
client.subscribe((event) => {
events.push(event);
});
const connectPromise = client.connect();
mock.triggerOpen();
await connectPromise;
mock.triggerMessage(
wrapSessionMessage({
type: "workspace_setup_progress",
payload: {
workspaceId: "ws-feature-a",
status: "running",
detail: {
type: "worktree_setup",
worktreePath: "/tmp/project/.paseo/worktrees/feature-a",
branchName: "feature-a",
log: "phase-one\n",
commands: [
{
index: 1,
command: "npm install",
cwd: "/tmp/project/.paseo/worktrees/feature-a",
log: "phase-one\n",
status: "running",
exitCode: null,
},
],
},
error: null,
},
}),
);
expect(events).toContainEqual({
type: "workspace_setup_progress",
workspaceId: "ws-feature-a",
payload: {
workspaceId: "ws-feature-a",
status: "running",
detail: {
type: "worktree_setup",
worktreePath: "/tmp/project/.paseo/worktrees/feature-a",
branchName: "feature-a",
log: "phase-one\n",
commands: [
{
index: 1,
command: "npm install",
cwd: "/tmp/project/.paseo/worktrees/feature-a",
log: "phase-one\n",
status: "running",
exitCode: null,
},
],
},
error: null,
},
});
});
test("sends create_agent_request with workspace and caller identity", async () => {
const logger = createMockLogger();
const mock = createMockTransport();
const client = new DaemonClient({
url: "ws://test",
clientId: "clsk_unit_test",
logger,
reconnect: { enabled: false },
transportFactory: () => mock.transport,
});
clients.push(client);
const connectPromise = client.connect();
mock.triggerOpen({ features: { agentRequestReceipts: true } });
await connectPromise;
const createPromise = client.createAgent({
idempotencyKey: "one-creation",
provider: "codex",
cwd: "/tmp/project/.paseo/worktrees/feature-a",
workspaceId: "ws-feature-a",
callerAgentId: "parent-agent",
title: "Compat agent",
modeId: "default",
});
expect(mock.sent).toHaveLength(1);
const request = parseSentFrame(mock.sent[0]);
expect(request).toEqual(
expect.objectContaining({
type: "create_agent_request",
idempotencyKey: "one-creation",
workspaceId: "ws-feature-a",
callerAgentId: "parent-agent",
}),
);
mock.triggerMessage(
wrapSessionMessage({
type: "status",
payload: {
status: "agent_create_failed",
requestId: request.requestId,
error: "compat test sentinel",
},
}),
);
await expect(createPromise).rejects.toThrow("compat test sentinel");
});
test("sends internal in create_agent_request when the host advertises internal agents", async () => {
const logger = createMockLogger();
const mock = createMockTransport();
const client = new DaemonClient({
url: "ws://test",
clientId: "clsk_unit_test",
logger,
reconnect: { enabled: false },
transportFactory: () => mock.transport,
});
clients.push(client);
const connectPromise = client.connect();
mock.triggerOpen({ features: { backgroundWorkspaces: true } });
await connectPromise;
const createPromise = client.createAgent({
provider: "codex",
cwd: "/tmp/project",
background: true,
});
expect(mock.sent).toHaveLength(1);
const request = parseSentFrame(mock.sent[0]);
expect(request).toEqual(
expect.objectContaining({
type: "create_agent_request",
config: { provider: "codex", cwd: "/tmp/project" },
background: true,
}),
);
mock.triggerMessage(
wrapSessionMessage({
type: "status",
payload: {
status: "agent_create_failed",
requestId: request.requestId,
error: "internal agent sentinel",
},
}),
);
await expect(createPromise).rejects.toThrow("internal agent sentinel");
});
test("rejects an internal create before sending when the host predates internal agents", async () => {
const logger = createMockLogger();
const mock = createMockTransport();
const client = new DaemonClient({
url: "ws://test",
clientId: "clsk_unit_test",
logger,
reconnect: { enabled: false },
transportFactory: () => mock.transport,
});
clients.push(client);
const connectPromise = client.connect();
mock.triggerOpen({ features: {} });
await connectPromise;
await expect(
client.createAgent({ provider: "codex", cwd: "/tmp/project", background: true }),
).rejects.toThrow("Update the host to use background workspaces.");
expect(mock.sent).toHaveLength(0);
});
test("metadata needs a host that advertises entityMetadata and sends nothing to one that does not", async () => {
const mock = createMockTransport();
const client = new DaemonClient({
url: "ws://test",
clientId: "clsk_unit_test",
logger: createMockLogger(),
reconnect: { enabled: false },
transportFactory: () => mock.transport,
});
clients.push(client);
const connectPromise = client.connect();
mock.triggerOpen({ features: {} });
await connectPromise;
const update = "Update the host to use metadata.";
await expect(
client.createAgent({ provider: "codex", cwd: "/tmp/project", metadata: { key: "value" } }),
).rejects.toThrow(update);
await expect(
client.createWorkspace({
source: { kind: "directory", path: "/tmp/project" },
metadata: { key: "value" },
}),
).rejects.toThrow(update);
await expect(client.setAgentMetadata("agent-1", { key: null })).rejects.toThrow(update);
await expect(client.setWorkspaceMetadata("workspace-1", { key: null })).rejects.toThrow(update);
expect(mock.sent).toHaveLength(0);
});
test("sends internal workspace creation and internal listings only to hosts that support them", async () => {
const logger = createMockLogger();
const mock = createMockTransport();
const client = new DaemonClient({
url: "ws://test",
clientId: "clsk_unit_test",
logger,
reconnect: { enabled: false },
transportFactory: () => mock.transport,
});
clients.push(client);
const connectPromise = client.connect();
// workspaceMultiplicity keeps the fetch on the real workspace directory RPC.
mock.triggerOpen({
features: { workspaceMultiplicity: true, backgroundWorkspaces: true },
});
await connectPromise;
void client
.createWorkspace({ source: { kind: "directory", path: "/tmp/project" }, background: true })
.catch(() => undefined);
void client.fetchWorkspaces({ filter: { includeBackground: true } }).catch(() => undefined);
void client.fetchAgents({ filter: { includeBackground: true } }).catch(() => undefined);
expect(mock.sent.map((frame) => parseSentFrame(frame))).toEqual(
expect.arrayContaining([
expect.objectContaining({ type: "workspace.create.request", background: true }),
expect.objectContaining({
type: "fetch_workspaces_request",
filter: { includeBackground: true },
}),
expect.objectContaining({
type: "fetch_agents_request",
filter: { includeBackground: true },
}),
]),
);
});
test("rejects internal workspace creation and internal listings before sending on older hosts", async () => {
const logger = createMockLogger();
const mock = createMockTransport();
const client = new DaemonClient({
url: "ws://test",
clientId: "clsk_unit_test",
logger,
reconnect: { enabled: false },
transportFactory: () => mock.transport,
});
clients.push(client);
const connectPromise = client.connect();
mock.triggerOpen({ features: {} });
await connectPromise;
await expect(
client.createWorkspace({
source: { kind: "directory", path: "/tmp/project" },
background: true,
}),
).rejects.toThrow("Update the host to use background workspaces.");
await expect(client.fetchWorkspaces({ filter: { includeBackground: true } })).rejects.toThrow(
"Update the host to use background workspaces.",
);
expect(mock.sent).toHaveLength(0);
// Agent listings gate on the older internalAgents feature.
const olderMock = createMockTransport();
const olderClient = new DaemonClient({
url: "ws://test",
clientId: "clsk_unit_test",
logger,
reconnect: { enabled: false },
transportFactory: () => olderMock.transport,
});
clients.push(olderClient);
const olderConnect = olderClient.connect();
olderMock.triggerOpen({ features: {} });
await olderConnect;
await expect(olderClient.fetchAgents({ filter: { includeBackground: true } })).rejects.toThrow(
"Update the host to use background workspaces.",
);
expect(olderMock.sent).toHaveLength(0);
});
test("sends structured attachments with create_agent_request", async () => {
const logger = createMockLogger();
const mock = createMockTransport();
const client = new DaemonClient({
url: "ws://test",
clientId: "clsk_unit_test",
logger,
reconnect: { enabled: false },
transportFactory: () => mock.transport,
});
clients.push(client);
const connectPromise = client.connect();
mock.triggerOpen();
await connectPromise;
const createPromise = client.createAgent({
provider: "codex",
cwd: "/tmp/project",
initialPrompt: "Review this PR",
attachments: [
{
type: "github_pr",
mimeType: "application/github-pr",
number: 123,
title: "Fix race in worktree setup",
url: "https://github.com/getpaseo/paseo/pull/123",
baseRefName: "main",
headRefName: "fix/worktree-race",
},
],
});
expect(mock.sent).toHaveLength(1);
const request = parseSentFrame(mock.sent[0]);
expect(request.attachments).toEqual([
{
type: "github_pr",
mimeType: "application/github-pr",
number: 123,
title: "Fix race in worktree setup",
url: "https://github.com/getpaseo/paseo/pull/123",
baseRefName: "main",
headRefName: "fix/worktree-race",
},
]);
mock.triggerMessage(
wrapSessionMessage({
type: "status",
payload: {
status: "agent_create_failed",
requestId: request.requestId,
error: "attachment test sentinel",
},
}),
);
await expect(createPromise).rejects.toThrow("attachment test sentinel");
});
test("sends structured first-agent context attachments with create_paseo_worktree_request", async () => {
const logger = createMockLogger();
const mock = createMockTransport();
const client = new DaemonClient({
url: "ws://test",
clientId: "clsk_unit_test",
logger,
reconnect: { enabled: false },
transportFactory: () => mock.transport,
});
clients.push(client);
const connectPromise = client.connect();
mock.triggerOpen();
await connectPromise;
const createPromise = client.createPaseoWorktree({
cwd: "/tmp/project",
worktreeSlug: "review-pr-123",
firstAgentContext: {
attachments: [
{
type: "github_pr",
mimeType: "application/github-pr",
number: 123,
title: "Fix race in worktree setup",
url: "https://github.com/getpaseo/paseo/pull/123",
},
],
},
});
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([
{
type: "github_pr",
mimeType: "application/github-pr",
number: 123,
title: "Fix race in worktree setup",
url: "https://github.com/getpaseo/paseo/pull/123",
},
]);
mock.triggerMessage(
wrapSessionMessage({
type: "create_paseo_worktree_response",
payload: {
requestId: request.requestId,
workspace: null,
error: "worktree attachment sentinel",
setupTerminalId: null,
},
}),
);
await expect(createPromise).resolves.toEqual({
requestId: request.requestId,
workspace: null,
error: "worktree attachment sentinel",
setupTerminalId: null,
});
});
test("sends project.add.request without creating a workspace", async () => {
const logger = createMockLogger();
const mock = createMockTransport();
const client = new DaemonClient({
url: "ws://test",
clientId: "clsk_unit_test",
logger,
reconnect: { enabled: false },
transportFactory: () => mock.transport,
});
clients.push(client);
const connectPromise = client.connect();
mock.triggerOpen();
await connectPromise;
const addPromise = client.addProject("/tmp/project", "req-add-project");
expect(mock.sent).toHaveLength(1);
expect(parseSentFrame(mock.sent[0])).toEqual({
type: "project.add.request",
requestId: "req-add-project",
cwd: "/tmp/project",
});
mock.triggerMessage(
wrapSessionMessage({
type: "project.add.response",
payload: {
requestId: "req-add-project",
project: {
projectId: "/tmp/project",
projectDisplayName: "project",
projectCustomName: null,
projectRootPath: "/tmp/project",
projectKind: "git",
},
error: null,
},
}),
);
await expect(addPromise).resolves.toEqual({
requestId: "req-add-project",
project: {
projectId: "/tmp/project",
projectDisplayName: "project",
projectCustomName: null,
projectRootPath: "/tmp/project",
projectKind: "git",
},
error: null,
});
});
test("marks a workspace unread through the dotted RPC", async () => {
const logger = createMockLogger();
const mock = createMockTransport();
const client = new DaemonClient({
url: "ws://test",
clientId: "clsk_unit_test",
logger,
reconnect: { enabled: false },
transportFactory: () => mock.transport,
});
clients.push(client);
const connectPromise = client.connect();
mock.triggerOpen();
await connectPromise;
const markPromise = client.markWorkspaceUnread("workspace-1", "req-mark-unread");
expect(parseSentFrame(mock.sent[0])).toEqual({
type: "workspace.mark_unread.request",
workspaceId: "workspace-1",
requestId: "req-mark-unread",
});
mock.triggerMessage(
wrapSessionMessage({
type: "workspace.mark_unread.response",
payload: {
requestId: "req-mark-unread",
workspaceId: "workspace-1",
markedAgentId: "agent-1",
success: true,
error: null,
},
}),
);
await expect(markPromise).resolves.toBeUndefined();
});
test("searches GitHub repositories through the dotted RPC", async () => {
const logger = createMockLogger();
const mock = createMockTransport();
const client = new DaemonClient({
url: "ws://test",
clientId: "clsk_unit_test",
logger,
reconnect: { enabled: false },
transportFactory: () => mock.transport,
});
clients.push(client);
const connectPromise = client.connect();
mock.triggerOpen();
await connectPromise;
const searchPromise = client.searchGithubRepositories(
{ query: "paseo", limit: 10 },
"req-repositories",
);
expect(parseSentFrame(mock.sent[0])).toEqual({
type: "workspace.github.search_repositories.request",
query: "paseo",
limit: 10,
requestId: "req-repositories",
});
mock.triggerMessage(
wrapSessionMessage({
type: "workspace.github.search_repositories.response",
payload: {
status: "success",
requestId: "req-repositories",
repositories: [
{
id: "R_paseo",
name: "paseo",
nameWithOwner: "getpaseo/paseo",
description: "Development environment in your pocket",
visibility: "public",
updatedAt: "2026-07-15T10:00:00Z",
cloneUrl: "git@github.com:getpaseo/paseo.git",
},
],
available: true,
error: null,
},
}),
);
await expect(searchPromise).resolves.toEqual({
status: "success",
requestId: "req-repositories",
repositories: [
{
id: "R_paseo",
name: "paseo",
nameWithOwner: "getpaseo/paseo",
description: "Development environment in your pocket",
visibility: "public",
updatedAt: "2026-07-15T10:00:00Z",
cloneUrl: "git@github.com:getpaseo/paseo.git",
},
],
available: true,
error: null,
});
});
test("creates and registers a project directory through the dotted RPC", async () => {
const logger = createMockLogger();
const mock = createMockTransport();
const client = new DaemonClient({
url: "ws://test",
clientId: "clsk_unit_test",
logger,
reconnect: { enabled: false },
transportFactory: () => mock.transport,
});
clients.push(client);
const connectPromise = client.connect();
mock.triggerOpen();
await connectPromise;
const createPromise = client.createProjectDirectory(
{ parentPath: "/tmp/projects", name: "new-project" },
"req-create-project-directory",
);
expect(parseSentFrame(mock.sent[0])).toEqual({
type: "project.create_directory.request",
parentPath: "/tmp/projects",
name: "new-project",
requestId: "req-create-project-directory",
});
mock.triggerMessage(
wrapSessionMessage({
type: "project.create_directory.response",
payload: {
requestId: "req-create-project-directory",
directoryPath: "/tmp/projects/new-project",
project: {
projectId: "directory:/tmp/projects/new-project",
projectDisplayName: "new-project",
projectCustomName: null,
projectRootPath: "/tmp/projects/new-project",
projectKind: "non_git",
},
error: null,
errorCode: null,
},
}),
);
await expect(createPromise).resolves.toEqual({
requestId: "req-create-project-directory",
directoryPath: "/tmp/projects/new-project",
project: {
projectId: "directory:/tmp/projects/new-project",
projectDisplayName: "new-project",
projectCustomName: null,
projectRootPath: "/tmp/projects/new-project",
projectKind: "non_git",
},
error: null,
errorCode: null,
});
});
test("sends first-agent prompt context with workspace.create.request", async () => {
const logger = createMockLogger();
const mock = createMockTransport();
const client = new DaemonClient({
url: "ws://test",
clientId: "clsk_unit_test",
logger,
reconnect: { enabled: false },
transportFactory: () => mock.transport,
});
clients.push(client);
const connectPromise = client.connect();
mock.triggerOpen();
await connectPromise;
const createPromise = client.createWorkspace(
{
source: {
kind: "directory",
path: "/tmp/project",
projectId: "local:/tmp/project",
},
firstAgentContext: {
prompt: "Fix login bug",
attachments: [],
},
},
"req-local-title",
);
expect(mock.sent).toHaveLength(1);
expect(parseSentFrame(mock.sent[0])).toEqual({
type: "workspace.create.request",
requestId: "req-local-title",
source: {
kind: "directory",
path: "/tmp/project",
projectId: "local:/tmp/project",
},
firstAgentContext: {
prompt: "Fix login bug",
attachments: [],
},
});
mock.triggerMessage(
wrapSessionMessage({
type: "workspace.create.response",
payload: {
requestId: "req-local-title",
workspace: null,
error: "local title sentinel",
setupTerminalId: null,
},
}),
);
await expect(createPromise).resolves.toEqual({
requestId: "req-local-title",
workspace: null,
error: "local title sentinel",
setupTerminalId: null,
});
});
test("sends project.remove.request", async () => {
const logger = createMockLogger();
const mock = createMockTransport();
const client = new DaemonClient({
url: "ws://test",
clientId: "clsk_unit_test",
logger,
reconnect: { enabled: false },
transportFactory: () => mock.transport,
});
clients.push(client);
const connectPromise = client.connect();
mock.triggerOpen();
await connectPromise;
const removePromise = client.removeProject("remote:github.com/acme/app", "req-remove-project");
expect(parseSentFrame(mock.sent[0])).toEqual({
type: "project.remove.request",
requestId: "req-remove-project",
projectId: "remote:github.com/acme/app",
});
mock.triggerMessage(
wrapSessionMessage({
type: "project.remove.response",
payload: {
requestId: "req-remove-project",
projectId: "remote:github.com/acme/app",
accepted: true,
removedWorkspaceIds: ["ws-main"],
error: null,
},
}),
);
await expect(removePromise).resolves.toEqual({ removedWorkspaceIds: ["ws-main"] });
});
test("sends worktree base-ref fields in create_paseo_worktree_request", async () => {
const logger = createMockLogger();
const mock = createMockTransport();
const client = new DaemonClient({
url: "ws://test",
clientId: "clsk_unit_test",
logger,
reconnect: { enabled: false },
transportFactory: () => mock.transport,
});
clients.push(client);
const connectPromise = client.connect();
mock.triggerOpen();
await connectPromise;
const createPromise = client.createPaseoWorktree(
{
cwd: "/tmp/project",
projectId: "remote:github.com/acme/project",
worktreeSlug: "review-pr-123",
refName: "feature/worktree-base-ref",
action: "checkout",
githubPrNumber: 123,
},
"req-worktree-ref",
);
expect(mock.sent).toHaveLength(1);
const request = parseSentFrame(mock.sent[0]);
expect(request).toEqual({
type: "create_paseo_worktree_request",
cwd: "/tmp/project",
projectId: "remote:github.com/acme/project",
worktreeSlug: "review-pr-123",
refName: "feature/worktree-base-ref",
action: "checkout",
githubPrNumber: 123,
requestId: "req-worktree-ref",
});
mock.triggerMessage(
wrapSessionMessage({
type: "create_paseo_worktree_response",
payload: {
requestId: request.requestId,
workspace: null,
error: "worktree ref fields sentinel",
setupTerminalId: null,
},
}),
);
await expect(createPromise).resolves.toEqual({
requestId: request.requestId,
workspace: null,
error: "worktree ref fields sentinel",
setupTerminalId: null,
});
});
test("omitting create_paseo_worktree_request worktree base-ref fields preserves legacy wire shape", async () => {
const logger = createMockLogger();
const mock = createMockTransport();
const client = new DaemonClient({
url: "ws://test",
clientId: "clsk_unit_test",
logger,
reconnect: { enabled: false },
transportFactory: () => mock.transport,
});
clients.push(client);
const connectPromise = client.connect();
mock.triggerOpen();
await connectPromise;
const createPromise = client.createPaseoWorktree(
{
cwd: "/tmp/project",
worktreeSlug: "feature-a",
},
"req-worktree-legacy",
);
expect(assertStr(mock.sent[0])).toBe(
JSON.stringify({
type: "session",
message: {
type: "create_paseo_worktree_request",
cwd: "/tmp/project",
worktreeSlug: "feature-a",
requestId: "req-worktree-legacy",
},
}),
);
mock.triggerMessage(
wrapSessionMessage({
type: "create_paseo_worktree_response",
payload: {
requestId: "req-worktree-legacy",
workspace: null,
error: "legacy worktree shape sentinel",
setupTerminalId: null,
},
}),
);
await expect(createPromise).resolves.toEqual({
requestId: "req-worktree-legacy",
workspace: null,
error: "legacy worktree shape sentinel",
setupTerminalId: null,
});
});
test("sends explicit shutdown_server_request via shutdownServer", async () => {
const logger = createMockLogger();
const mock = createMockTransport();
const client = new DaemonClient({
url: "ws://test",
clientId: "clsk_unit_test",
logger,
reconnect: { enabled: false },
transportFactory: () => mock.transport,
});
clients.push(client);
const connectPromise = client.connect();
mock.triggerOpen();
await connectPromise;
expect(typeof client.shutdownServer).toBe("function");
const promise = client.shutdownServer({ requestId: "req-shutdown-1" });
expect(mock.sent).toHaveLength(1);
const request = parseSentFrame(mock.sent[0]);
expect(request).toEqual({
type: "shutdown_server_request",
requestId: "req-shutdown-1",
});
mock.triggerMessage(
wrapSessionMessage({
type: "status",
payload: {
status: "shutdown_requested",
clientId: "clsk_unit_test",
requestId: "req-shutdown-1",
},
}),
);
await expect(promise).resolves.toEqual({
status: "shutdown_requested",
clientId: "clsk_unit_test",
requestId: "req-shutdown-1",
});
});
test("restartServer remains restart-only and sends restart_server_request", async () => {
const logger = createMockLogger();
const mock = createMockTransport();
const client = new DaemonClient({
url: "ws://test",
clientId: "clsk_unit_test",
logger,
reconnect: { enabled: false },
transportFactory: () => mock.transport,
});
clients.push(client);
const connectPromise = client.connect();
mock.triggerOpen();
await connectPromise;
const promise = client.restartServer("settings_update", "req-restart-1");
expect(mock.sent).toHaveLength(1);
const request = parseSentFrame(mock.sent[0]);
expect(request).toEqual({
type: "restart_server_request",
reason: "settings_update",
requestId: "req-restart-1",
});
mock.triggerMessage(
wrapSessionMessage({
type: "status",
payload: {
status: "restart_requested",
clientId: "clsk_unit_test",
reason: "settings_update",
requestId: "req-restart-1",
},
}),
);
await expect(promise).resolves.toEqual({
status: "restart_requested",
clientId: "clsk_unit_test",
reason: "settings_update",
requestId: "req-restart-1",
});
});
test("transitions out of connecting when connect timeout elapses", async () => {
useHeartbeatClock();
try {
const logger = createMockLogger();
const mock = createMockTransport();
const client = new DaemonClient({
url: "ws://test",
clientId: "clsk_unit_test",
logger,
reconnect: { enabled: false },
connectTimeoutMs: 100,
transportFactory: () => mock.transport,
});
clients.push(client);
const pendingConnect = client.connect().then(
() => ({ ok: true as const }),
(error) => ({ ok: false as const, error }),
);
expect(client.getConnectionState().status).toBe("connecting");
await vi.advanceTimersByTimeAsync(120);
const result = await pendingConnect;
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(client.getConnectionState().status).toBe("disconnected");
} finally {
vi.useRealTimers();
}
});
test("reconnects after relay close with replaced-by-new-connection reason", async () => {
useHeartbeatClock();
try {
const logger = createMockLogger();
const first = createMockTransport();
const second = createMockTransport();
const transports = [first, second];
let transportIndex = 0;
const client = new DaemonClient({
url: "ws://relay.test/ws?role=client&serverId=srv_test&v=2",
clientId: "clsk_test",
logger,
reconnect: {
enabled: true,
baseDelayMs: 5,
maxDelayMs: 5,
},
transportFactory: () => {
const next = transports[Math.min(transportIndex, transports.length - 1)];
transportIndex += 1;
return next.transport;
},
});
clients.push(client);
const connectPromise = client.connect();
first.triggerOpen();
await connectPromise;
expect(client.getConnectionState().status).toBe("connected");
first.triggerClose({ code: 1008, reason: "Replaced by new connection" });
expect(client.getConnectionState().status).toBe("disconnected");
await vi.advanceTimersByTimeAsync(10);
expect(client.getConnectionState().status).toBe("connecting");
second.triggerOpen();
expect(client.getConnectionState().status).toBe("connected");
} finally {
vi.useRealTimers();
}
});
test("stops reconnecting after the transport closes for an endpoint security failure", async () => {
const close = { code: 4002, reason: "legacy security below required minimum" };
const error = "Endpoint security handshake failed: legacy security below required minimum";
useHeartbeatClock();
try {
let attempts = 0;
const mock = createMockTransport();
const client = new DaemonClient({
url: "ws://relay.test/ws?role=client&serverId=srv_test&v=2",
clientId: "clsk_security_close_test",
e2ee: { enabled: true, daemonPublicKeyB64: "daemon-public-key" },
reconnect: { enabled: true, baseDelayMs: 1, maxDelayMs: 1 },
transportFactory: () => {
attempts += 1;
return mock.transport;
},
});
clients.push(client);
const connecting = client.connect();
void connecting.catch(() => {});
await vi.waitFor(() => expect(attempts).toBe(1));
mock.triggerClose(close);
await expect(connecting).rejects.toThrow(error);
await vi.advanceTimersByTimeAsync(50);
expect(attempts).toBe(1);
expect(client.getConnectionState().status).toBe("disconnected");
expect(client.lastError).toContain(error);
} finally {
vi.useRealTimers();
}
});
test("requires non-empty clientId", () => {
expect(() => {
const _client = new DaemonClient({
url: "ws://relay.test/ws?role=client&serverId=srv_test&v=2",
clientId: "",
reconnect: { enabled: false },
});
void _client;
}).toThrow("Daemon client requires a non-empty clientId");
});
test("requires non-empty clientId for direct connections", () => {
expect(() => {
const _client = new DaemonClient({
url: "ws://127.0.0.1:6767/ws",
clientId: " ",
reconnect: { enabled: false },
});
void _client;
}).toThrow("Daemon client requires a non-empty clientId");
});
test("logs configured runtime generation in connection transition events", async () => {
const logger = createMockLogger();
const mock = createMockTransport();
const client = new DaemonClient({
url: "ws://test",
clientId: "clsk_unit_test",
logger,
reconnect: { enabled: false },
runtimeGeneration: 7,
transportFactory: () => mock.transport,
});
clients.push(client);
const connectPromise = client.connect();
mock.triggerOpen();
await connectPromise;
const transitionPayloads = logger.debug.mock.calls.filter(
([, message]) => message === "DaemonClientTransition",
);
expect(transitionPayloads.length).toBeGreaterThan(0);
for (const [payload] of transitionPayloads) {
expect(
z.object({ generation: z.number().nullable().optional() }).parse(payload).generation,
).toBe(7);
}
});
test("closing a client while subscription releases are in flight reports only the disposal", async () => {
const mock = createMockTransport();
let transportsCreated = 0;
const client = new DaemonClient({
url: "ws://test",
clientId: "clsk_unit_test",
logger: createMockLogger(),
reconnect: { enabled: true, baseDelayMs: 1, maxDelayMs: 1 },
transportFactory: () => {
transportsCreated += 1;
return mock.transport;
},
});
clients.push(client);
const connectPromise = client.connect();
mock.triggerOpen();
await connectPromise;
const subscriptions = ["/tmp/one", "/tmp/two"].map((cwd) =>
client.observeCheckoutDiff(cwd, { mode: "uncommitted" }),
);
await vi.waitFor(() => expect(mock.sent).toHaveLength(2));
for (const [index, frame] of [...mock.sent].entries()) {
const request = parseSentFrame(frame);
mock.triggerMessage(
wrapSessionMessage({
type: "subscribe_checkout_diff_response",
payload: {
subscriptionId: `checkout-sub-${index}`,
cwd: request.cwd,
files: [],
error: null,
requestId: request.requestId,
},
}),
);
}
await Promise.all(subscriptions.map((subscription) => subscription.ready));
// The host runtime releases its handlers and closes the previous client without waiting.
const releases = subscriptions.map((subscription) =>
subscription.release().catch(() => undefined),
);
await vi.waitFor(() => expect(mock.sent).toHaveLength(4));
const statuses: string[] = [];
client.subscribeConnectionStatus((state) => statuses.push(state.status));
statuses.length = 0;
await client.close();
await Promise.all(releases);
await new Promise((resolve) => setTimeout(resolve, 20));
expect(statuses).toEqual(["disposed"]);
expect(client.lastError).toBeNull();
expect(transportsCreated).toBe(1);
});
test("subscribes to checkout diff updates via RPC handshake", async () => {
const logger = createMockLogger();
const mock = createMockTransport();
const client = new DaemonClient({
url: "ws://test",
clientId: "clsk_unit_test",
logger,
reconnect: { enabled: false },
transportFactory: () => mock.transport,
});
clients.push(client);
const connectPromise = client.connect();
mock.triggerOpen();
await connectPromise;
const subscription = client.observeCheckoutDiff("/tmp/project", { mode: "uncommitted" });
const promise = subscription.ready;
await vi.waitFor(() => expect(mock.sent).toHaveLength(1));
expect(mock.sent).toHaveLength(1);
const request = parseSentFrame(mock.sent[0]);
expect(request.type).toBe("subscribe_checkout_diff_request");
expect(request.subscriptionId).toBeUndefined();
expect(request.cwd).toBe("/tmp/project");
expect(request.compare).toEqual({ mode: "uncommitted" });
mock.triggerMessage(
JSON.stringify({
type: "session",
message: {
type: "subscribe_checkout_diff_response",
payload: {
subscriptionId: "checkout-sub-1",
cwd: "/tmp/project",
files: [],
error: null,
requestId: request.requestId,
},
},
}),
);
await expect(promise).resolves.toEqual({
subscriptionId: "checkout-sub-1",
cwd: "/tmp/project",
files: [],
error: null,
requestId: request.requestId,
});
});
test("getCheckoutDiff reads a snapshot without creating a subscription", async () => {
const logger = createMockLogger();
const mock = createMockTransport();
const client = new DaemonClient({
url: "ws://test",
clientId: "clsk_unit_test",
logger,
reconnect: { enabled: false },
transportFactory: () => mock.transport,
});
clients.push(client);
const connectPromise = client.connect();
mock.triggerOpen();
await connectPromise;
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("checkout.diff.get.request");
expect(subscribeRequest.cwd).toBe("/tmp/project");
expect(subscribeRequest.compare).toEqual({
mode: "base",
baseRef: "main",
});
mock.triggerMessage(
JSON.stringify({
type: "session",
message: {
type: "checkout.diff.get.response",
payload: {
cwd: "/tmp/project",
files: [],
error: null,
requestId: subscribeRequest.requestId,
},
},
}),
);
await expect(promise).resolves.toEqual({
cwd: "/tmp/project",
files: [],
error: null,
requestId: subscribeRequest.requestId,
});
expect(mock.sent).toHaveLength(1);
});
test("requests branch suggestions via RPC", async () => {
const logger = createMockLogger();
const mock = createMockTransport();
const client = new DaemonClient({
url: "ws://test",
clientId: "clsk_unit_test",
logger,
reconnect: { enabled: false },
transportFactory: () => mock.transport,
});
clients.push(client);
const connectPromise = client.connect();
mock.triggerOpen();
await connectPromise;
const promise = client.getBranchSuggestions(
{ cwd: "/tmp/project", query: "mai", limit: 5 },
"req-branches",
);
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");
mock.triggerMessage(
JSON.stringify({
type: "session",
message: {
type: "branch_suggestions_response",
payload: {
branches: ["main"],
error: null,
requestId: "req-branches",
},
},
}),
);
await expect(promise).resolves.toEqual({
branches: ["main"],
error: null,
requestId: "req-branches",
});
});
test("reads project config via correlated RPC", async () => {
const logger = createMockLogger();
const mock = createMockTransport();
const client = new DaemonClient({
url: "ws://test",
clientId: "clsk_unit_test",
logger,
reconnect: { enabled: false },
transportFactory: () => mock.transport,
});
clients.push(client);
const connectPromise = client.connect();
mock.triggerOpen();
await connectPromise;
const promise = client.readProjectConfig("/repo/app", "read-project-config-1");
expect(mock.sent).toHaveLength(1);
const request = parseSentFrame(mock.sent[0]);
expect(request).toEqual({
type: "read_project_config_request",
requestId: "read-project-config-1",
repoRoot: "/repo/app",
});
mock.triggerMessage(
wrapSessionMessage({
type: "read_project_config_response",
payload: {
requestId: "read-project-config-1",
repoRoot: "/repo/app",
ok: true,
config: { worktree: { setup: "npm install" } },
revision: { mtimeMs: 10, size: 20 },
},
}),
);
await expect(promise).resolves.toEqual({
requestId: "read-project-config-1",
repoRoot: "/repo/app",
ok: true,
config: { worktree: { setup: "npm install" } },
revision: { mtimeMs: 10, size: 20 },
});
});
test("writes project config via correlated RPC and returns inline failures", async () => {
const logger = createMockLogger();
const mock = createMockTransport();
const client = new DaemonClient({
url: "ws://test",
clientId: "clsk_unit_test",
logger,
reconnect: { enabled: false },
transportFactory: () => mock.transport,
});
clients.push(client);
const connectPromise = client.connect();
mock.triggerOpen();
await connectPromise;
const promise = client.writeProjectConfig({
requestId: "write-project-config-1",
repoRoot: "/repo/app",
config: { worktree: { setup: ["npm install"] } },
expectedRevision: { mtimeMs: 10, size: 20 },
});
expect(mock.sent).toHaveLength(1);
const request = parseSentFrame(mock.sent[0]);
expect(request).toEqual({
type: "write_project_config_request",
requestId: "write-project-config-1",
repoRoot: "/repo/app",
config: { worktree: { setup: ["npm install"] } },
expectedRevision: { mtimeMs: 10, size: 20 },
});
mock.triggerMessage(
wrapSessionMessage({
type: "write_project_config_response",
payload: {
requestId: "write-project-config-1",
repoRoot: "/repo/app",
ok: false,
error: {
code: "stale_project_config",
currentRevision: { mtimeMs: 11, size: 21 },
},
},
}),
);
await expect(promise).resolves.toEqual({
requestId: "write-project-config-1",
repoRoot: "/repo/app",
ok: false,
error: {
code: "stale_project_config",
currentRevision: { mtimeMs: 11, size: 21 },
},
});
});
test("requests directory suggestions via RPC", async () => {
const logger = createMockLogger();
const mock = createMockTransport();
const client = new DaemonClient({
url: "ws://test",
clientId: "clsk_unit_test",
logger,
reconnect: { enabled: false },
transportFactory: () => mock.transport,
});
clients.push(client);
const connectPromise = client.connect();
mock.triggerOpen();
await connectPromise;
const promise = client.getDirectorySuggestions(
{
query: "proj",
limit: 10,
cwd: "/tmp/project",
includeFiles: true,
includeDirectories: true,
matchMode: "suffix",
},
"req-directories",
);
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.matchMode).toBe("suffix");
expect(request.limit).toBe(10);
expect(request.requestId).toBe("req-directories");
mock.triggerMessage(
JSON.stringify({
type: "session",
message: {
type: "directory_suggestions_response",
payload: {
directories: ["/Users/test/projects/paseo"],
entries: [{ path: "README.md", kind: "file" }],
error: null,
requestId: "req-directories",
},
},
}),
);
await expect(promise).resolves.toEqual({
directories: ["/Users/test/projects/paseo"],
entries: [{ path: "README.md", kind: "file" }],
error: null,
requestId: "req-directories",
});
});
test("requests checkout merge from base via RPC", async () => {
const logger = createMockLogger();
const mock = createMockTransport();
const client = new DaemonClient({
url: "ws://test",
clientId: "clsk_unit_test",
logger,
reconnect: { enabled: false },
transportFactory: () => mock.transport,
});
clients.push(client);
const connectPromise = client.connect();
mock.triggerOpen();
await connectPromise;
const promise = client.checkoutMergeFromBase(
"/tmp/project",
{ baseRef: "main", requireCleanTarget: true },
"req-merge-from-base",
);
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");
mock.triggerMessage(
JSON.stringify({
type: "session",
message: {
type: "checkout_merge_from_base_response",
payload: {
cwd: "/tmp/project",
requestId: "req-merge-from-base",
success: true,
error: null,
},
},
}),
);
await expect(promise).resolves.toEqual({
cwd: "/tmp/project",
requestId: "req-merge-from-base",
success: true,
error: null,
});
});
test("requests GitHub auto-merge enable via namespaced RPC", async () => {
const logger = createMockLogger();
const mock = createMockTransport();
const client = new DaemonClient({
url: "ws://test",
clientId: "clsk_unit_test",
logger,
reconnect: { enabled: false },
transportFactory: () => mock.transport,
});
clients.push(client);
const connectPromise = client.connect();
mock.triggerOpen();
await connectPromise;
const promise = client.checkoutGithubSetAutoMerge(
"/tmp/project",
{ enabled: true, method: "squash" },
"req-enable-auto-merge",
);
expect(mock.sent).toHaveLength(1);
const request = parseSentFrame(mock.sent[0]);
expect(request.type).toBe("checkout.github.set_auto_merge.request");
expect(request.cwd).toBe("/tmp/project");
expect(request.enabled).toBe(true);
expect(request.mergeMethod).toBe("squash");
expect(request.requestId).toBe("req-enable-auto-merge");
mock.triggerMessage(
JSON.stringify({
type: "session",
message: {
type: "checkout.github.set_auto_merge.response",
payload: {
cwd: "/tmp/project",
enabled: true,
requestId: "req-enable-auto-merge",
success: true,
error: null,
},
},
}),
);
await expect(promise).resolves.toEqual({
cwd: "/tmp/project",
enabled: true,
requestId: "req-enable-auto-merge",
success: true,
error: null,
});
});
test("requests GitHub auto-merge disable via namespaced RPC", async () => {
const logger = createMockLogger();
const mock = createMockTransport();
const client = new DaemonClient({
url: "ws://test",
clientId: "clsk_unit_test",
logger,
reconnect: { enabled: false },
transportFactory: () => mock.transport,
});
clients.push(client);
const connectPromise = client.connect();
mock.triggerOpen();
await connectPromise;
const promise = client.checkoutGithubSetAutoMerge(
"/tmp/project",
{ enabled: false },
"req-disable-auto-merge",
);
expect(mock.sent).toHaveLength(1);
const request = parseSentFrame(mock.sent[0]);
expect(request.type).toBe("checkout.github.set_auto_merge.request");
expect(request.cwd).toBe("/tmp/project");
expect(request.enabled).toBe(false);
expect(request.mergeMethod).toBeUndefined();
expect(request.requestId).toBe("req-disable-auto-merge");
mock.triggerMessage(
JSON.stringify({
type: "session",
message: {
type: "checkout.github.set_auto_merge.response",
payload: {
cwd: "/tmp/project",
enabled: false,
requestId: "req-disable-auto-merge",
success: true,
error: null,
},
},
}),
);
await expect(promise).resolves.toEqual({
cwd: "/tmp/project",
enabled: false,
requestId: "req-disable-auto-merge",
success: true,
error: null,
});
});
test("requests GitHub check details via namespaced RPC", async () => {
const logger = createMockLogger();
const mock = createMockTransport();
const client = new DaemonClient({
url: "ws://test",
clientId: "clsk_unit_test",
logger,
reconnect: { enabled: false },
transportFactory: () => mock.transport,
});
clients.push(client);
const connectPromise = client.connect();
mock.triggerOpen();
await connectPromise;
const promise = client.checkoutGithubGetCheckDetails(
{
cwd: "/tmp/project",
repoOwner: "getpaseo",
repoName: "paseo",
checkRunId: 12345,
workflowRunId: 456,
},
"req-check-details",
);
expect(mock.sent).toHaveLength(1);
const request = parseSentFrame(mock.sent[0]);
expect(request).toMatchObject({
type: "checkout.github.get_check_details.request",
cwd: "/tmp/project",
repoOwner: "getpaseo",
repoName: "paseo",
checkRunId: 12345,
workflowRunId: 456,
requestId: "req-check-details",
});
mock.triggerMessage(
JSON.stringify({
type: "session",
message: {
type: "checkout.github.get_check_details.response",
payload: {
cwd: "/tmp/project",
requestId: "req-check-details",
success: true,
details: {
checkRunId: 12345,
workflowRunId: 456,
name: "server-tests",
status: "completed",
conclusion: "failure",
annotations: [],
failedJobs: [],
truncated: false,
},
error: null,
},
},
}),
);
await expect(promise).resolves.toEqual({
cwd: "/tmp/project",
requestId: "req-check-details",
success: true,
details: {
checkRunId: 12345,
workflowRunId: 456,
name: "server-tests",
status: "completed",
conclusion: "failure",
annotations: [],
failedJobs: [],
truncated: false,
},
error: null,
});
});
test("requests checkout pull via RPC", async () => {
const logger = createMockLogger();
const mock = createMockTransport();
const client = new DaemonClient({
url: "ws://test",
clientId: "clsk_unit_test",
logger,
reconnect: { enabled: false },
transportFactory: () => mock.transport,
});
clients.push(client);
const connectPromise = client.connect();
mock.triggerOpen();
await connectPromise;
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");
mock.triggerMessage(
JSON.stringify({
type: "session",
message: {
type: "checkout_pull_response",
payload: {
cwd: "/tmp/project",
requestId: "req-pull",
success: true,
error: null,
},
},
}),
);
await expect(promise).resolves.toEqual({
cwd: "/tmp/project",
requestId: "req-pull",
success: true,
error: null,
});
});
test("renames a branch via RPC", async () => {
const logger = createMockLogger();
const mock = createMockTransport();
const client = new DaemonClient({
url: "ws://test",
clientId: "clsk_unit_test",
logger,
reconnect: { enabled: false },
transportFactory: () => mock.transport,
});
clients.push(client);
const connectPromise = client.connect();
mock.triggerOpen();
await connectPromise;
const promise = client.renameBranch({
cwd: "/tmp/project",
branch: "feature/new-name",
requestId: "req-rename-branch",
});
expect(mock.sent).toHaveLength(1);
const request = JSON.parse(assertStr(mock.sent[0])) as {
type: "session";
message: {
type: "checkout.rename_branch.request";
cwd: string;
branch: string;
requestId: string;
};
};
expect(request.message.type).toBe("checkout.rename_branch.request");
expect(request.message.cwd).toBe("/tmp/project");
expect(request.message.branch).toBe("feature/new-name");
expect(request.message.requestId).toBe("req-rename-branch");
mock.triggerMessage(
JSON.stringify({
type: "session",
message: {
type: "checkout.rename_branch.response",
payload: {
requestId: "req-rename-branch",
success: true,
cwd: "/tmp/project",
currentBranch: "feature/new-name",
error: null,
},
},
}),
);
await expect(promise).resolves.toEqual({
requestId: "req-rename-branch",
success: true,
cwd: "/tmp/project",
currentBranch: "feature/new-name",
error: null,
});
});
test("returns renameBranch business failures", async () => {
const logger = createMockLogger();
const mock = createMockTransport();
const client = new DaemonClient({
url: "ws://test",
clientId: "clsk_unit_test",
logger,
reconnect: { enabled: false },
transportFactory: () => mock.transport,
});
clients.push(client);
const connectPromise = client.connect();
mock.triggerOpen();
await connectPromise;
const promise = client.renameBranch({
cwd: "/tmp/project",
branch: "already-exists",
requestId: "req-rename-branch-fail",
});
expect(mock.sent).toHaveLength(1);
mock.triggerMessage(
JSON.stringify({
type: "session",
message: {
type: "checkout.rename_branch.response",
payload: {
requestId: "req-rename-branch-fail",
success: false,
cwd: "/tmp/project",
currentBranch: null,
error: { code: "NOT_ALLOWED", message: "Branch already exists" },
},
},
}),
);
await expect(promise).resolves.toEqual({
requestId: "req-rename-branch-fail",
success: false,
cwd: "/tmp/project",
currentBranch: null,
error: { code: "NOT_ALLOWED", message: "Branch already exists" },
});
});
test("resubscribes checkout diff streams after reconnect", async () => {
const logger = createMockLogger();
const mock = createMockTransport();
const client = new DaemonClient({
url: "ws://test",
clientId: "clsk_unit_test",
logger,
reconnect: { enabled: false },
transportFactory: () => mock.transport,
});
clients.push(client);
const connectPromise = client.connect();
mock.triggerOpen();
await connectPromise;
const observation = client.observeCheckoutDiff("/tmp/project", { mode: "base", baseRef: "main" });
await vi.waitFor(() => expect(mock.sent).toHaveLength(1));
const first = parseSentFrame(mock.sent[0]);
mock.triggerMessage(
wrapSessionMessage({
type: "subscribe_checkout_diff_response",
payload: {
requestId: first.requestId,
subscriptionId: "server-first",
cwd: "/tmp/project",
files: [],
error: null,
},
}),
);
await observation.ready;
mock.triggerClose();
const reconnect = client.connect();
mock.triggerOpen();
await reconnect;
await vi.waitFor(() => expect(mock.sent).toHaveLength(1));
const request = parseSentFrame(mock.sent[0]);
expect(request).toMatchObject({
type: "subscribe_checkout_diff_request",
cwd: "/tmp/project",
compare: { mode: "base", baseRef: "main" },
});
expect(request.subscriptionId).toBeUndefined();
expect(request.requestId).not.toBe(first.requestId);
mock.triggerMessage(
wrapSessionMessage({
type: "subscribe_checkout_diff_response",
payload: {
requestId: request.requestId,
subscriptionId: "server-second",
cwd: "/tmp/project",
files: [],
error: null,
},
}),
);
await vi.waitFor(() => expect(observation.subscriptionId).toBe("server-second"));
});
test("fetches agents via RPC with filters, sort, and pagination", async () => {
const logger = createMockLogger();
const mock = createMockTransport();
const client = new DaemonClient({
url: "ws://test",
clientId: "clsk_unit_test",
logger,
reconnect: { enabled: false },
transportFactory: () => mock.transport,
});
clients.push(client);
const connectPromise = client.connect();
mock.triggerOpen();
await connectPromise;
const promise = client.fetchAgents({
filter: { labels: { surface: "workspace" } },
sort: [
{ key: "status_priority", direction: "asc" },
{ key: "created_at", direction: "desc" },
],
page: { limit: 25, cursor: "cursor-1" },
subscribe: {},
});
await vi.waitFor(() => expect(mock.sent).toHaveLength(1));
const request = parseSentFrame(mock.sent[0]);
expect(request.type).toBe("fetch_agents_request");
expect(request.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({});
mock.triggerMessage(
JSON.stringify({
type: "session",
message: {
type: "fetch_agents_response",
payload: {
requestId: request.requestId,
subscriptionId: "sub-1",
entries: [],
pageInfo: {
nextCursor: null,
prevCursor: "cursor-1",
hasMore: false,
},
},
},
}),
);
await expect(promise).resolves.toEqual({
requestId: request.requestId,
subscriptionId: "sub-1",
subscription: expect.objectContaining({
subscriptionId: "sub-1",
release: expect.any(Function),
}),
entries: [],
pageInfo: {
nextCursor: null,
prevCursor: "cursor-1",
hasMore: false,
},
});
});
test("detaches an agent through the namespaced detach RPC", async () => {
const logger = createMockLogger();
const mock = createMockTransport();
const client = new DaemonClient({
url: "ws://test",
clientId: "clsk_unit_test",
logger,
reconnect: { enabled: false },
transportFactory: () => mock.transport,
});
clients.push(client);
const connectPromise = client.connect();
mock.triggerOpen();
await connectPromise;
const promise = client.detachAgent("child-agent");
expect(mock.sent).toHaveLength(1);
const request = parseSentFrame(mock.sent[0]);
expect(request).toMatchObject({
type: "agent.detach.request",
agentId: "child-agent",
});
expect(typeof request.requestId).toBe("string");
mock.triggerMessage(
wrapSessionMessage({
type: "agent.detach.response",
payload: {
requestId: request.requestId,
agentId: "child-agent",
accepted: true,
error: null,
},
}),
);
await expect(promise).resolves.toBeUndefined();
});
test("sends active-scoped fetch_agents_request", async () => {
const logger = createMockLogger();
const mock = createMockTransport();
const client = new DaemonClient({
url: "ws://test",
clientId: "clsk_unit_test",
logger,
reconnect: { enabled: false },
transportFactory: () => mock.transport,
});
clients.push(client);
const connectPromise = client.connect();
mock.triggerOpen();
await connectPromise;
const promise = client.fetchAgents({
scope: "active",
page: { limit: 50 },
});
expect(mock.sent).toHaveLength(1);
const request = parseSentFrame(mock.sent[0]);
expect(request).toMatchObject({
type: "fetch_agents_request",
scope: "active",
});
mock.triggerMessage(
JSON.stringify({
type: "session",
message: {
type: "fetch_agents_response",
payload: {
requestId: request.requestId,
entries: [],
pageInfo: {
nextCursor: null,
prevCursor: null,
hasMore: false,
},
},
},
}),
);
await expect(promise).resolves.toMatchObject({
requestId: request.requestId,
entries: [],
});
});
test("fetches paginated agent history separately from active agents", async () => {
const logger = createMockLogger();
const mock = createMockTransport();
const client = new DaemonClient({
url: "ws://test",
clientId: "clsk_unit_test",
logger,
reconnect: { enabled: false },
transportFactory: () => mock.transport,
});
clients.push(client);
const connectPromise = client.connect();
mock.triggerOpen();
await connectPromise;
const promise = client.fetchAgentHistory({
page: { limit: 25, cursor: "cursor-1" },
sort: [{ key: "updated_at", direction: "desc" }],
});
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" });
mock.triggerMessage(
JSON.stringify({
type: "session",
message: {
type: "fetch_agent_history_response",
payload: {
requestId: request.requestId,
entries: [],
pageInfo: {
nextCursor: null,
prevCursor: "cursor-1",
hasMore: false,
},
},
},
}),
);
await expect(promise).resolves.toEqual({
requestId: request.requestId,
entries: [],
pageInfo: {
nextCursor: null,
prevCursor: "cursor-1",
hasMore: false,
},
});
});
test("fetches scoped recent provider sessions", async () => {
const logger = createMockLogger();
const mock = createMockTransport();
const client = new DaemonClient({
url: "ws://test",
clientId: "clsk_unit_test",
logger,
reconnect: { enabled: false },
transportFactory: () => mock.transport,
});
clients.push(client);
const connectPromise = client.connect();
mock.triggerOpen();
await connectPromise;
const promise = client.fetchRecentProviderSessions({
cwd: "/tmp/repo",
providers: ["my-claude"],
since: "2026-04-30T00:00:00.000Z",
limit: 25,
query: "invoice",
});
expect(mock.sent).toHaveLength(1);
const request = JSON.parse(String(mock.sent[0])) as {
type: "session";
message: {
type: "fetch_recent_provider_sessions_request";
requestId: string;
cwd?: string;
providers?: string[];
since?: string;
limit?: number;
query?: string;
};
};
expect(request.message).toMatchObject({
type: "fetch_recent_provider_sessions_request",
cwd: "/tmp/repo",
providers: ["my-claude"],
since: "2026-04-30T00:00:00.000Z",
limit: 25,
query: "invoice",
});
mock.triggerMessage(
JSON.stringify({
type: "session",
message: {
type: "fetch_recent_provider_sessions_response",
payload: {
requestId: request.message.requestId,
entries: [
{
providerId: "codex",
providerLabel: "Codex",
providerHandleId: "thread-1",
cwd: "/tmp/repo",
title: "Import me",
firstPromptPreview: "first prompt",
lastPromptPreview: "last prompt",
lastActivityAt: "2026-04-30T12:34:56.000Z",
},
],
},
},
}),
);
await expect(promise).resolves.toEqual({
requestId: request.message.requestId,
entries: [
{
providerId: "codex",
providerLabel: "Codex",
providerHandleId: "thread-1",
cwd: "/tmp/repo",
title: "Import me",
firstPromptPreview: "first prompt",
lastPromptPreview: "last prompt",
lastActivityAt: "2026-04-30T12:34:56.000Z",
},
],
});
});
test("imports an agent by provider handle id", async () => {
const logger = createMockLogger();
const mock = createMockTransport();
const client = new DaemonClient({
url: "ws://test",
clientId: "clsk_unit_test",
logger,
reconnect: { enabled: false },
transportFactory: () => mock.transport,
});
clients.push(client);
const connectPromise = client.connect();
mock.triggerOpen();
await connectPromise;
const promise = client.importAgent({
providerId: "custom-codex",
providerHandleId: "thread-1",
cwd: "/tmp/repo",
});
expect(mock.sent).toHaveLength(1);
const request = JSON.parse(String(mock.sent[0])) as {
type: "session";
message: {
type: "import_agent_request";
requestId: string;
providerId?: string;
providerHandleId?: string;
sessionId?: string;
cwd?: string;
};
};
expect(request.message).toMatchObject({
type: "import_agent_request",
providerId: "custom-codex",
providerHandleId: "thread-1",
cwd: "/tmp/repo",
});
expect(request.message).not.toHaveProperty("sessionId");
mock.triggerMessage(
wrapSessionMessage({
type: "status",
payload: {
status: "agent_resumed",
requestId: request.message.requestId,
agentId: "agent-1",
timelineSize: 0,
agent: {
id: "agent-1",
provider: "custom-codex",
cwd: "/tmp/repo",
model: null,
features: [],
thinkingOptionId: null,
effectiveThinkingOptionId: null,
createdAt: "2026-04-30T00:00:00.000Z",
updatedAt: "2026-04-30T00:00:00.000Z",
lastUserMessageAt: null,
status: "idle",
capabilities: {
supportsStreaming: false,
supportsSessionPersistence: false,
supportsDynamicModes: false,
supportsMcpServers: false,
supportsReasoningStream: false,
supportsToolInvocations: false,
},
currentModeId: null,
availableModes: [],
pendingPermissions: [],
persistence: {
provider: "custom-codex",
sessionId: "thread-1",
nativeHandle: "thread-1",
},
title: null,
labels: {},
requiresAttention: false,
attentionReason: null,
},
},
}),
);
await expect(promise).resolves.toMatchObject({
id: "agent-1",
provider: "custom-codex",
});
});
test("uses server-provided dictation finish timeout budget", async () => {
useHeartbeatClock();
const logger = createMockLogger();
const mock = createMockTransport();
const client = new DaemonClient({
url: "ws://test",
clientId: "clsk_unit_test",
logger,
reconnect: { enabled: false },
transportFactory: () => mock.transport,
});
clients.push(client);
const connectPromise = client.connect();
mock.triggerOpen();
await connectPromise;
const finishPromise = client.finishDictationStream("dict-1", 0);
const finishError = finishPromise.then(
() => null,
(error) => error,
);
expect(mock.sent).toHaveLength(1);
mock.triggerMessage(
wrapSessionMessage({
type: "dictation_stream_finish_accepted",
payload: {
dictationId: "dict-1",
timeoutMs: 100,
},
}),
);
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)");
}
vi.useRealTimers();
});
test("resolves dictation finish when final arrives after finish accepted", async () => {
const logger = createMockLogger();
const mock = createMockTransport();
const client = new DaemonClient({
url: "ws://test",
clientId: "clsk_unit_test",
logger,
reconnect: { enabled: false },
transportFactory: () => mock.transport,
});
clients.push(client);
const connectPromise = client.connect();
mock.triggerOpen();
await connectPromise;
const finishPromise = client.finishDictationStream("dict-2", 1);
expect(mock.sent).toHaveLength(1);
mock.triggerMessage(
wrapSessionMessage({
type: "dictation_stream_finish_accepted",
payload: {
dictationId: "dict-2",
timeoutMs: 1000,
},
}),
);
mock.triggerMessage(
wrapSessionMessage({
type: "dictation_stream_final",
payload: {
dictationId: "dict-2",
text: "hello",
},
}),
);
await expect(finishPromise).resolves.toEqual({
dictationId: "dict-2",
text: "hello",
});
});
test("cancels waiters when send fails (no leaked timeouts)", async () => {
useHeartbeatClock();
const logger = createMockLogger();
const mock = createMockTransport();
let sendCount = 0;
const transportFactory = () => ({
...mock.transport,
send: () => {
sendCount += 1;
if (sendCount > 1) {
throw new Error("boom");
}
},
});
const client = new DaemonClient({
url: "ws://test",
clientId: "clsk_unit_test",
logger,
reconnect: { enabled: false },
transportFactory,
});
clients.push(client);
const connectPromise = client.connect();
mock.triggerOpen();
await connectPromise;
const promise = client.getCheckoutStatus("/tmp/project");
await expect(promise).rejects.toThrow("boom");
// Ensure we didn't leave a waiter behind that will reject later.
const internal = client as unknown as { waiters: Set<unknown> };
expect(internal.waiters.size).toBe(0);
await vi.advanceTimersByTimeAsync(0);
vi.useRealTimers();
});
test("lists available providers via RPC", async () => {
const logger = createMockLogger();
const mock = createMockTransport();
const client = new DaemonClient({
url: "ws://test",
clientId: "clsk_unit_test",
logger,
reconnect: { enabled: false },
transportFactory: () => mock.transport,
});
clients.push(client);
const connectPromise = client.connect();
mock.triggerOpen();
await connectPromise;
const promise = client.listAvailableProviders();
expect(mock.sent).toHaveLength(1);
const request = parseSentFrame(mock.sent[0]);
expect(request.type).toBe("list_available_providers_request");
mock.triggerMessage(
JSON.stringify({
type: "session",
message: {
type: "list_available_providers_response",
payload: {
providers: [
{ provider: "claude", available: true, error: null },
{ provider: "codex", available: false, error: "Missing binary" },
],
error: null,
fetchedAt: "2026-02-12T00:00:00.000Z",
requestId: request.requestId,
},
},
}),
);
await expect(promise).resolves.toEqual({
providers: [
{ provider: "claude", available: true, error: null },
{ provider: "codex", available: false, error: "Missing binary" },
],
error: null,
fetchedAt: "2026-02-12T00:00:00.000Z",
requestId: request.requestId,
});
});
test("requests provider snapshots conditionally and expands the compact response", async () => {
const mock = createMockTransport();
const client = new DaemonClient({
url: "ws://test",
clientId: "clsk_provider_snapshot_test",
reconnect: { enabled: false },
transportFactory: () => mock.transport,
});
clients.push(client);
const connectPromise = client.connect();
mock.triggerOpen();
await connectPromise;
const promise = client.getProvidersSnapshot({ cwd: "/repo", ifNoneMatch: "previous-hash" });
const request = parseSentFrame(mock.sent[0]);
expect(request).toMatchObject({
type: "get_providers_snapshot_request",
cwd: "/repo",
ifNoneMatch: "previous-hash",
});
mock.triggerMessage(
wrapSessionMessage({
type: "get_providers_snapshot_response",
payload: {
entries: [],
compactSnapshot: {
entries: [
{
provider: "pi",
status: "ready",
enabled: true,
models: [
{ id: "model-a", label: "Model A", thinkingSet: 0 },
{ id: "model-b", label: "Model B", thinkingSet: 0 },
],
},
],
thinkingSets: [
{
options: [{ id: "high", label: "High", isDefault: true }],
defaultOptionId: "high",
},
],
},
snapshotHash: "next-hash",
generatedAt: "2026-08-04T00:00:00.000Z",
requestId: request.requestId,
},
}),
);
const response = await promise;
expect(response.entries[0]?.models).toEqual([
{
provider: "pi",
id: "model-a",
label: "Model A",
thinkingOptions: [{ id: "high", label: "High", isDefault: true }],
defaultThinkingOptionId: "high",
},
{
provider: "pi",
id: "model-b",
label: "Model B",
thinkingOptions: [{ id: "high", label: "High", isDefault: true }],
defaultThinkingOptionId: "high",
},
]);
expect(response.entries[0]?.models?.[0]?.thinkingOptions).toBe(
response.entries[0]?.models?.[1]?.thinkingOptions,
);
});
test("lists commands with draft config via RPC", async () => {
const logger = createMockLogger();
const mock = createMockTransport();
const client = new DaemonClient({
url: "ws://test",
clientId: "clsk_unit_test",
logger,
reconnect: { enabled: false },
transportFactory: () => mock.transport,
});
clients.push(client);
const connectPromise = client.connect();
mock.triggerOpen();
await connectPromise;
const promise = client.listCommands({
agentId: "__new_agent__",
draftConfig: {
provider: "codex",
cwd: "/tmp/project",
modeId: "bypassPermissions",
model: "gpt-5",
thinkingOptionId: "off",
},
});
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({
provider: "codex",
cwd: "/tmp/project",
modeId: "bypassPermissions",
model: "gpt-5",
thinkingOptionId: "off",
});
mock.triggerMessage(
JSON.stringify({
type: "session",
message: {
type: "list_commands_response",
payload: {
agentId: "__new_agent__",
commands: [{ name: "help", description: "Show help", argumentHint: "" }],
error: null,
requestId: request.requestId,
},
},
}),
);
await expect(promise).resolves.toEqual({
agentId: "__new_agent__",
commands: [{ name: "help", description: "Show help", argumentHint: "" }],
error: null,
requestId: request.requestId,
});
});
test("lists commands with explicit requestId via RPC", async () => {
const logger = createMockLogger();
const mock = createMockTransport();
const client = new DaemonClient({
url: "ws://test",
clientId: "clsk_unit_test",
logger,
reconnect: { enabled: false },
transportFactory: () => mock.transport,
});
clients.push(client);
const connectPromise = client.connect();
mock.triggerOpen();
await connectPromise;
const promise = client.listCommands({
agentId: "agent-1",
requestId: "req-commands",
});
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-commands");
expect(request.draftConfig).toBeUndefined();
mock.triggerMessage(
JSON.stringify({
type: "session",
message: {
type: "list_commands_response",
payload: {
agentId: "agent-1",
commands: [],
error: null,
requestId: "req-commands",
},
},
}),
);
await expect(promise).resolves.toEqual({
agentId: "agent-1",
commands: [],
error: null,
requestId: "req-commands",
});
});
test("preserves legacy listCommands id overload", async () => {
const logger = createMockLogger();
const mock = createMockTransport();
const client = new DaemonClient({
url: "ws://test",
clientId: "clsk_unit_test",
logger,
reconnect: { enabled: false },
transportFactory: () => mock.transport,
});
clients.push(client);
const connectPromise = client.connect();
mock.triggerOpen();
await connectPromise;
const promise = client.listCommands("agent-1", "req-commands-legacy");
expect(parseSentFrame(mock.sent[0])).toEqual({
type: "list_commands_request",
requestId: "req-commands-legacy",
agentId: "agent-1",
});
mock.triggerMessage(
wrapSessionMessage({
type: "list_commands_response",
payload: {
agentId: "agent-1",
commands: [],
error: null,
requestId: "req-commands-legacy",
},
}),
);
await expect(promise).resolves.toEqual({
agentId: "agent-1",
commands: [],
error: null,
requestId: "req-commands-legacy",
});
});
test("preserves legacy listCommands options overload", async () => {
const logger = createMockLogger();
const mock = createMockTransport();
const client = new DaemonClient({
url: "ws://test",
clientId: "clsk_unit_test",
logger,
reconnect: { enabled: false },
transportFactory: () => mock.transport,
});
clients.push(client);
const connectPromise = client.connect();
mock.triggerOpen();
await connectPromise;
const promise = client.listCommands("__new_agent__", {
requestId: "req-commands-draft-legacy",
draftConfig: {
provider: "codex",
cwd: "/tmp/project",
modeId: "bypassPermissions",
model: "gpt-5",
thinkingOptionId: "off",
},
});
expect(parseSentFrame(mock.sent[0])).toEqual({
type: "list_commands_request",
requestId: "req-commands-draft-legacy",
agentId: "__new_agent__",
draftConfig: {
provider: "codex",
cwd: "/tmp/project",
modeId: "bypassPermissions",
model: "gpt-5",
thinkingOptionId: "off",
},
});
mock.triggerMessage(
wrapSessionMessage({
type: "list_commands_response",
payload: {
agentId: "__new_agent__",
commands: [{ name: "help", description: "Show help", argumentHint: "" }],
error: null,
requestId: "req-commands-draft-legacy",
},
}),
);
await expect(promise).resolves.toEqual({
agentId: "__new_agent__",
commands: [{ name: "help", description: "Show help", argumentHint: "" }],
error: null,
requestId: "req-commands-draft-legacy",
});
});
test("emits output events for the active terminal stream", async () => {
const logger = createMockLogger();
const mock = createMockTransport();
const client = new DaemonClient({
url: "ws://test",
clientId: "clsk_unit_test",
logger,
reconnect: { enabled: false },
transportFactory: () => mock.transport,
});
clients.push(client);
const connectPromise = client.connect();
mock.triggerOpen();
await connectPromise;
const seen: string[] = [];
const unsubscribe = client.onTerminalStreamEvent((event) => {
if (event.type !== "output") {
return;
}
seen.push(new TextDecoder().decode(event.data));
});
const subscribePromise = client.subscribeTerminal("term-1", { requestId: "sub-1" });
await vi.waitFor(() =>
expect(parseSentFrame(mock.sent.at(-1)).type).toBe("subscribe_terminal_request"),
);
mock.triggerMessage(
wrapSessionMessage({
type: "subscribe_terminal_response",
payload: {
subscriptionId: "server-" + String(parseSentFrame(mock.sent.at(-1)).requestId),
terminalId: "term-1",
slot: 11,
error: null,
requestId: "sub-1",
},
}),
);
await subscribePromise;
mock.triggerMessage(
encodeTerminalStreamFrame({
opcode: TerminalStreamOpcode.Output,
slot: 11,
payload: new TextEncoder().encode("hello"),
}),
);
expect(seen).toEqual(["hello"]);
expect(mock.sent).toHaveLength(1);
unsubscribe();
});
test.each([5, 500, 10000])(
"preserves explicit %s history demand on an old ANSI host and normalizes restore",
async (history) => {
const logger = createMockLogger();
const mock = createMockTransport();
const client = new DaemonClient({
url: "ws://test",
clientId: "clsk_unit_test",
logger,
reconnect: { enabled: false },
transportFactory: () => mock.transport,
});
clients.push(client);
const connectPromise = client.connect();
mock.triggerOpen();
await connectPromise;
const events: unknown[] = [];
const replayEvents: unknown[] = [];
client.onTerminalStreamEvent((event) => {
if (event.type === "replay") replayEvents.push(event.replay);
events.push(event);
});
const subscribePromise = client.subscribeTerminal("term-1", {
requestId: "sub-2",
restore: { mode: "visible-snapshot", scrollbackLines: history },
});
await vi.waitFor(() =>
expect(parseSentFrame(mock.sent.at(-1)).type).toBe("subscribe_terminal_request"),
);
expect(parseSentFrame(mock.sent.at(-1)).restore).toEqual({
mode: "visible-snapshot",
scrollbackLines: history,
});
mock.triggerMessage(
wrapSessionMessage({
type: "subscribe_terminal_response",
payload: {
subscriptionId: "server-" + String(parseSentFrame(mock.sent.at(-1)).requestId),
terminalId: "term-1",
slot: 12,
error: null,
requestId: "sub-2",
},
}),
);
await subscribePromise;
const state = {
rows: 1,
cols: 5,
grid: [[{ char: "h" }, { char: "e" }, { char: "l" }, { char: "l" }, { char: "o" }]],
scrollback: [],
cursor: { row: 0, col: 5 },
};
mock.triggerMessage(
encodeTerminalStreamFrame({
opcode: TerminalStreamOpcode.Snapshot,
slot: 12,
payload: encodeTerminalSnapshotPayload(state),
}),
);
expect(events).toEqual([]);
const data = new TextEncoder().encode("\x1b[31mold ANSI host界🙂");
mock.triggerMessage(
encodeTerminalStreamFrame({ opcode: TerminalStreamOpcode.Restore, slot: 12, payload: data }),
);
expect(replayEvents).toEqual([{ type: "begin" }, { type: "data", data }, { type: "end" }]);
},
);
test("sends explicit terminal input and resize commands", async () => {
const logger = createMockLogger();
const mock = createMockTransport();
const client = new DaemonClient({
url: "ws://test",
clientId: "clsk_unit_test",
logger,
reconnect: { enabled: false },
transportFactory: () => mock.transport,
});
clients.push(client);
const connectPromise = client.connect();
mock.triggerOpen();
await connectPromise;
const subscribePromise = client.subscribeTerminal("term-1", { requestId: "sub-3" });
await vi.waitFor(() =>
expect(parseSentFrame(mock.sent.at(-1)).type).toBe("subscribe_terminal_request"),
);
mock.triggerMessage(
wrapSessionMessage({
type: "subscribe_terminal_response",
payload: {
subscriptionId: "server-" + String(parseSentFrame(mock.sent.at(-1)).requestId),
terminalId: "term-1",
slot: 13,
error: null,
requestId: "sub-3",
},
}),
);
await subscribePromise;
mock.sent.length = 0;
client.sendTerminalInput("term-1", {
type: "input",
data: "echo hello\r",
});
client.sendTerminalInput("term-1", {
type: "resize",
rows: 24,
cols: 80,
intent: "update",
});
expect(mock.sent.map(parseSentFrame)).toEqual([
{
type: "terminal_input",
terminalId: "term-1",
message: { type: "input", data: "echo hello\r" },
},
{
type: "terminal_input",
terminalId: "term-1",
message: { type: "resize", rows: 24, cols: 80, intent: "update" },
},
]);
});
test("routes concurrent terminal stream frames by slot", async () => {
const logger = createMockLogger();
const mock = createMockTransport();
const client = new DaemonClient({
url: "ws://test",
clientId: "clsk_unit_test",
logger,
reconnect: { enabled: false },
transportFactory: () => mock.transport,
});
clients.push(client);
const connectPromise = client.connect();
mock.triggerOpen();
await connectPromise;
const seen: string[] = [];
client.onTerminalStreamEvent((event) => {
if (event.type !== "output") {
return;
}
seen.push(`${event.terminalId}:${new TextDecoder().decode(event.data)}`);
});
const subscribeFirstPromise = client.subscribeTerminal("term-1", { requestId: "sub-multi-1" });
await vi.waitFor(() =>
expect(parseSentFrame(mock.sent.at(-1)).type).toBe("subscribe_terminal_request"),
);
mock.triggerMessage(
wrapSessionMessage({
type: "subscribe_terminal_response",
payload: {
subscriptionId: "server-" + String(parseSentFrame(mock.sent.at(-1)).requestId),
terminalId: "term-1",
slot: 21,
error: null,
requestId: "sub-multi-1",
},
}),
);
await subscribeFirstPromise;
const subscribeSecondPromise = client.subscribeTerminal("term-2", { requestId: "sub-multi-2" });
await vi.waitFor(() =>
expect(parseSentFrame(mock.sent.at(-1)).type).toBe("subscribe_terminal_request"),
);
mock.triggerMessage(
wrapSessionMessage({
type: "subscribe_terminal_response",
payload: {
subscriptionId: "server-" + String(parseSentFrame(mock.sent.at(-1)).requestId),
terminalId: "term-2",
slot: 22,
error: null,
requestId: "sub-multi-2",
},
}),
);
await subscribeSecondPromise;
mock.sent.length = 0;
mock.triggerMessage(
encodeTerminalStreamFrame({
opcode: TerminalStreamOpcode.Output,
slot: 22,
payload: new TextEncoder().encode("beta"),
}),
);
mock.triggerMessage(
encodeTerminalStreamFrame({
opcode: TerminalStreamOpcode.Output,
slot: 21,
payload: new TextEncoder().encode("alpha"),
}),
);
client.sendTerminalInput("term-2", {
type: "input",
data: "echo beta\r",
});
client.sendTerminalInput("term-1", {
type: "resize",
rows: 10,
cols: 20,
});
expect(seen).toEqual(["term-2:beta", "term-1:alpha"]);
expect(mock.sent.map(parseSentFrame)).toEqual([
{
type: "terminal_input",
terminalId: "term-2",
message: { type: "input", data: "echo beta\r" },
},
{
type: "terminal_input",
terminalId: "term-1",
message: { type: "resize", rows: 10, cols: 20 },
},
]);
});
test("ignores terminal stream frames after terminal_stream_exit", async () => {
const logger = createMockLogger();
const mock = createMockTransport();
const client = new DaemonClient({
url: "ws://test",
clientId: "clsk_unit_test",
logger,
reconnect: { enabled: false },
transportFactory: () => mock.transport,
});
clients.push(client);
const connectPromise = client.connect();
mock.triggerOpen();
await connectPromise;
const seen: string[] = [];
const unsubscribe = client.onTerminalStreamEvent((event) => {
if (event.type !== "output") {
return;
}
seen.push(new TextDecoder().decode(event.data));
});
const subscribePromise = client.subscribeTerminal("term-1", { requestId: "sub-4" });
await vi.waitFor(() =>
expect(parseSentFrame(mock.sent.at(-1)).type).toBe("subscribe_terminal_request"),
);
mock.triggerMessage(
wrapSessionMessage({
type: "subscribe_terminal_response",
payload: {
subscriptionId: "server-" + String(parseSentFrame(mock.sent.at(-1)).requestId),
terminalId: "term-1",
slot: 14,
error: null,
requestId: "sub-4",
},
}),
);
await subscribePromise;
mock.triggerMessage(
encodeTerminalStreamFrame({
opcode: TerminalStreamOpcode.Output,
slot: 14,
payload: new TextEncoder().encode("before-exit"),
}),
);
expect(seen).toEqual(["before-exit"]);
mock.triggerMessage(
wrapSessionMessage({
type: "terminal_stream_exit",
payload: {
subscriptionId: "server-sub-4",
terminalId: "term-1",
},
}),
);
mock.triggerMessage(
encodeTerminalStreamFrame({
opcode: TerminalStreamOpcode.Output,
slot: 14,
payload: new TextEncoder().encode("after-exit"),
}),
);
expect(seen).toEqual(["before-exit"]);
await vi.waitFor(() =>
expect(parseSentFrame(mock.sent.at(-1)).type).toBe("subscription.release.request"),
);
const release = parseSentFrame(mock.sent.at(-1));
mock.triggerMessage(
wrapSessionMessage({
type: "subscription.release.response",
payload: {
requestId: release.requestId,
subscriptionId: "server-sub-4",
released: true,
},
}),
);
await Promise.resolve();
unsubscribe();
});
test("parses canonical agent_stream tool_call payloads without crashing", async () => {
const logger = createMockLogger();
const mock = createMockTransport();
const client = new DaemonClient({
url: "ws://test",
clientId: "clsk_unit_test",
logger,
reconnect: { enabled: false },
transportFactory: () => mock.transport,
});
clients.push(client);
const connectPromise = client.connect();
mock.triggerOpen();
await connectPromise;
const received: unknown[] = [];
const unsubscribe = client.on("agent_stream", (msg) => {
received.push(msg);
});
mock.triggerMessage(
wrapSessionMessage({
type: "agent_stream",
payload: {
agentId: "agent_cli",
timestamp: "2026-02-08T20:20:00.000Z",
event: {
type: "timeline",
provider: "codex",
item: {
type: "tool_call",
callId: "call_cli_stream",
name: "shell",
status: "running",
detail: {
type: "shell",
command: "pwd",
},
error: null,
},
},
},
}),
);
unsubscribe();
expect(received).toHaveLength(1);
expect(received[0]).toMatchObject({
payload: {
event: {
item: {
status: "running",
error: null,
detail: { type: "shell" },
},
},
},
});
expect(logger.warn).not.toHaveBeenCalled();
});
test("drops legacy agent_stream tool_call payloads and logs validation warning", async () => {
const logger = createMockLogger();
const mock = createMockTransport();
const client = new DaemonClient({
url: "ws://test",
clientId: "clsk_unit_test",
logger,
reconnect: { enabled: false },
transportFactory: () => mock.transport,
});
clients.push(client);
const connectPromise = client.connect();
mock.triggerOpen();
await connectPromise;
const received: unknown[] = [];
const unsubscribe = client.on("agent_stream", (msg) => {
received.push(msg);
});
mock.triggerMessage(
wrapSessionMessage({
type: "agent_stream",
payload: {
agentId: "agent_cli",
timestamp: "2026-02-08T20:20:00.000Z",
event: {
type: "timeline",
provider: "codex",
item: {
type: "tool_call",
callId: "call_cli_stream_legacy",
name: "shell",
status: "inProgress",
detail: {
type: "unknown",
input: { command: "pwd" },
output: null,
},
},
},
},
}),
);
unsubscribe();
expect(received).toHaveLength(0);
expect(logger.warn).toHaveBeenCalled();
});
test.each([false, true])(
"parses canonical timeline responses with chunking=%s",
async (chunked) => {
const logger = createMockLogger();
const mock = createMockTransport();
const client = new DaemonClient({
url: "ws://test",
clientId: "clsk_unit_test",
logger,
reconnect: { enabled: false },
transportFactory: () => mock.transport,
});
clients.push(client);
const connectPromise = client.connect();
mock.triggerOpen({ features: { ownedSubscriptions: true, binaryChunkingV1: chunked } });
await connectPromise;
const received: unknown[] = [];
const unsubscribe = client.on("fetch_agent_timeline_response", (msg) => {
received.push(msg);
});
const command = chunked ? "x".repeat(50 * 1024 * 1024) : "pwd";
const sender = new ChunkedTransport({
frameBytes: 24 * 1024 * 1024,
send: (frame) => mock.triggerMessage(frame),
deliver: () => {},
});
sender.enabled = chunked;
await sender.send(
wrapSessionMessage({
type: "fetch_agent_timeline_response",
payload: {
requestId: "req-1",
agentId: "agent_cli",
agent: null,
direction: "tail",
projection: "projected",
epoch: "epoch-1",
reset: false,
staleCursor: false,
gap: false,
window: { minSeq: 1, maxSeq: 1, nextSeq: 2 },
startCursor: { epoch: "epoch-1", seq: 1 },
endCursor: { epoch: "epoch-1", seq: 1 },
hasOlder: false,
hasNewer: false,
entries: [
{
timestamp: "2026-02-08T20:20:00.000Z",
provider: "codex",
seqStart: 1,
seqEnd: 1,
sourceSeqRanges: [{ startSeq: 1, endSeq: 1 }],
collapsed: [],
item: {
type: "tool_call",
callId: "call_cli_snapshot",
name: "shell",
status: "running",
detail: {
type: "shell",
command,
},
error: null,
},
},
],
error: null,
},
}),
);
unsubscribe();
expect(received).toHaveLength(1);
expect(received[0]).toMatchObject({
payload: {
entries: [
{
item: {
type: "tool_call",
status: "running",
error: null,
detail: { type: "shell", command },
},
},
],
},
});
expect(logger.warn).not.toHaveBeenCalled();
},
);
test("rejects and logs a correlated response that violates the protocol schema", async () => {
const mock = createMockTransport();
const warnings: string[] = [];
const logger: Logger = {
...noopLogger,
warn: (_fields, message) => warnings.push(message ?? ""),
};
const client = new DaemonClient({
url: "ws://test",
clientId: "clsk_unit_test",
logger,
reconnect: { enabled: false },
transportFactory: () => mock.transport,
});
clients.push(client);
const connectPromise = client.connect();
mock.triggerOpen();
await connectPromise;
const response = client.fetchAgentTimeline("agent_cli", {
requestId: "req-invalid",
timeout: 1,
});
mock.triggerMessage(
wrapSessionMessage({
type: "fetch_agent_timeline_response",
payload: {
requestId: "req-invalid",
agentId: "agent_cli",
agent: null,
direction: "tail",
projection: "projected",
epoch: "epoch-1",
reset: false,
staleCursor: false,
gap: false,
window: { minSeq: 1, maxSeq: 1, nextSeq: 2 },
startCursor: { epoch: "epoch-1", seq: 1 },
endCursor: { epoch: "epoch-1", seq: 1 },
hasOlder: false,
hasNewer: false,
entries: [
{
timestamp: "2026-02-08T20:20:00.000Z",
provider: "codex",
seqStart: 1,
seqEnd: 1,
sourceSeqRanges: [{ startSeq: 1, endSeq: 1 }],
collapsed: [],
item: {
type: "tool_call",
callId: "call_cli_invalid",
name: "shell",
status: "inProgress",
detail: {
type: "unknown",
input: { command: "pwd" },
output: null,
},
},
},
],
error: null,
},
}),
);
await expect(response).rejects.toMatchObject({
requestId: "req-invalid",
message: expect.stringMatching(/validation/i),
});
expect(warnings).toEqual(["Message validation failed"]);
});
test("does not reject a request for an invalid correlated progress event", async () => {
const mock = createMockTransport();
const client = new DaemonClient({
url: "ws://test",
clientId: "clsk_unit_test",
logger: noopLogger,
reconnect: { enabled: false },
transportFactory: () => mock.transport,
});
clients.push(client);
const connectPromise = client.connect();
mock.triggerOpen();
await connectPromise;
const response = client.updateDaemon("req-update");
mock.triggerMessage(
wrapSessionMessage({
type: "daemon.update.progress",
payload: {
requestId: "req-update",
phase: "verifying",
},
}),
);
mock.triggerMessage(
wrapSessionMessage({
type: "daemon.update.response",
payload: {
requestId: "req-update",
success: true,
error: null,
previousVersion: "0.1.106",
newVersion: "0.1.107",
},
}),
);
await expect(response).resolves.toMatchObject({
requestId: "req-update",
success: true,
});
});
test("sends subscribe/unsubscribe terminals messages", async () => {
const logger = createMockLogger();
const mock = createMockTransport();
const client = new DaemonClient({
url: "ws://test",
clientId: "clsk_unit_test",
logger,
reconnect: { enabled: false },
transportFactory: () => mock.transport,
});
clients.push(client);
const connectPromise = client.connect();
mock.triggerOpen();
await connectPromise;
const observation = client.observeTerminals({ cwd: "/tmp/project" });
await vi.waitFor(() => expect(mock.sent).toHaveLength(1));
const request = parseSentFrame(mock.sent[0]);
expect(request).toMatchObject({ type: "subscribe_terminals_request", cwd: "/tmp/project" });
expect(request.subscriptionId).toBeUndefined();
mock.triggerMessage(
wrapSessionMessage({
type: "terminals_changed",
payload: {
requestId: request.requestId,
subscriptionId: "server-terminals",
cwd: "/tmp/project",
terminals: [],
},
}),
);
await observation.ready;
const release = observation.release();
await vi.waitFor(() => expect(mock.sent).toHaveLength(2));
const requestRelease = parseSentFrame(mock.sent[1]);
expect(requestRelease).toMatchObject({
type: "subscription.release.request",
subscriptionId: "server-terminals",
});
mock.triggerMessage(
wrapSessionMessage({
type: "subscription.release.response",
payload: {
requestId: requestRelease.requestId,
subscriptionId: "server-terminals",
released: true,
},
}),
);
await release;
});
test("dispatches terminals_changed events to typed listeners", async () => {
const logger = createMockLogger();
const mock = createMockTransport();
const client = new DaemonClient({
url: "ws://test",
clientId: "clsk_unit_test",
logger,
reconnect: { enabled: false },
transportFactory: () => mock.transport,
});
clients.push(client);
const connectPromise = client.connect();
mock.triggerOpen();
await connectPromise;
const received: Array<{ cwd: string; names: string[] }> = [];
const unsubscribe = client.on("terminals_changed", (message) => {
received.push({
cwd: message.payload.cwd,
names: message.payload.terminals.map((terminal) => terminal.name),
});
});
mock.triggerMessage(
wrapSessionMessage({
type: "terminals_changed",
payload: {
cwd: "/tmp/project",
terminals: [
{
id: "term-1",
name: "Dev Server",
},
],
},
}),
);
unsubscribe();
expect(received).toEqual([
{
cwd: "/tmp/project",
names: ["Dev Server"],
},
]);
});
test("sends provider.usage.list.request and resolves provider.usage.list.response", async () => {
const logger = createMockLogger();
const mock = createMockTransport();
const client = new DaemonClient({
url: "ws://test",
clientId: "clsk_unit_test",
logger,
reconnect: { enabled: false },
transportFactory: () => mock.transport,
});
clients.push(client);
const connectPromise = client.connect();
mock.triggerOpen();
await connectPromise;
const usagePromise = client.listProviderUsage({ requestId: "usage-1" });
expect(JSON.parse(assertStr(mock.sent[0]))).toEqual({
type: "session",
message: {
type: "provider.usage.list.request",
requestId: "usage-1",
},
});
mock.triggerMessage(
wrapSessionMessage({
type: "provider.usage.list.response",
payload: {
requestId: "usage-1",
fetchedAt: "2026-06-19T00:00:00.000Z",
providers: [
{
providerId: "glm",
displayName: "GLM coding plan",
status: "available",
planLabel: "GLM coding plan",
windows: [
{
id: "biweekly",
label: "Biweekly",
usedPct: 23,
remainingPct: 77,
},
],
},
],
},
}),
);
await expect(usagePromise).resolves.toEqual({
requestId: "usage-1",
fetchedAt: "2026-06-19T00:00:00.000Z",
providers: [
{
providerId: "glm",
displayName: "GLM coding plan",
status: "available",
planLabel: "GLM coding plan",
windows: [
{
id: "biweekly",
label: "Biweekly",
usedPct: 23,
remainingPct: 77,
},
],
},
],
});
});
test.each([
{
status: "available",
report: {
status: "available",
windows: [],
balances: undefined,
details: undefined,
planLabel: undefined,
},
},
{ status: "error", report: { status: "error", error: "" } },
{
status: "unavailable",
report: { status: "unavailable", problem: { kind: "no_quota", detail: "" } },
},
] as const)(
"maps released-host $status usage and filters report IDs",
async ({ status, report }) => {
const mock = createMockTransport();
const client = new DaemonClient({
url: "ws://test",
clientId: "clsk_unit_test",
logger: createMockLogger(),
reconnect: { enabled: false },
transportFactory: () => mock.transport,
});
clients.push(client);
const connected = client.connect();
mock.triggerOpen({ features: { providerUsageList: true } });
await connected;
const result = client.listUsageReports({
requestId: "legacy-usage",
reportIds: ["claude"],
forceRefresh: true,
});
expect(parseSentFrame(mock.sent[0])).toEqual({
type: "provider.usage.list.request",
requestId: "legacy-usage",
});
const provider = {
providerId: "claude",
displayName: "Claude",
status,
windows: [],
planLabel: null,
fetchedAt: null,
error: null,
};
mock.triggerMessage(
wrapSessionMessage({
type: "provider.usage.list.response",
payload: {
requestId: "legacy-usage",
fetchedAt: "2026-09-30T00:00:00.000Z",
providers: [provider, { ...provider, providerId: "codex" }],
},
}),
);
expect(await result).toStrictEqual({
requestId: "legacy-usage",
reports: [
{
id: "claude",
sourceId: "claude",
sourceLabel: "Claude",
icon: readFileSync(
new URL("../../../plugins/claude-usage-source/icon.svg", import.meta.url),
"utf8",
),
account: {},
fetchedAt: "2026-09-30T00:00:00.000Z",
report,
},
],
});
},
);
test("sends close_items_request and resolves close_items_response", async () => {
const logger = createMockLogger();
const mock = createMockTransport();
const client = new DaemonClient({
url: "ws://test",
clientId: "clsk_unit_test",
logger,
reconnect: { enabled: false },
transportFactory: () => mock.transport,
});
clients.push(client);
const connectPromise = client.connect();
mock.triggerOpen();
await connectPromise;
const responsePromise = client.closeItems(
{
agentIds: ["agent-1"],
terminalIds: ["term-1"],
},
"req-close-items",
);
expect(JSON.parse(assertStr(mock.sent[0]))).toEqual({
type: "session",
message: {
type: "close_items_request",
agentIds: ["agent-1"],
terminalIds: ["term-1"],
requestId: "req-close-items",
},
});
mock.triggerMessage(
wrapSessionMessage({
type: "close_items_response",
payload: {
agents: [{ agentId: "agent-1", archivedAt: "2026-04-01T00:00:00.000Z" }],
terminals: [{ terminalId: "term-1", success: true }],
requestId: "req-close-items",
},
}),
);
await expect(responsePromise).resolves.toEqual({
agents: [{ agentId: "agent-1", archivedAt: "2026-04-01T00:00:00.000Z" }],
terminals: [{ terminalId: "term-1", success: true }],
requestId: "req-close-items",
});
});
test("waitForFinish with timeout=0 omits timeoutMs and has no client deadline", async () => {
useHeartbeatClock();
try {
const logger = createMockLogger();
const mock = createMockTransport();
const client = new DaemonClient({
url: "ws://test",
clientId: "clsk_unit_test",
logger,
reconnect: { enabled: false },
transportFactory: () => mock.transport,
});
clients.push(client);
const connectPromise = client.connect();
mock.triggerOpen();
await connectPromise;
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");
let settled: "pending" | "resolved" | "rejected" = "pending";
void waitPromise.then(
() => {
settled = "resolved";
return null;
},
() => {
settled = "rejected";
return null;
},
);
await vi.advanceTimersByTimeAsync(5 * 60 * 1000);
expect(settled).toBe("pending");
mock.triggerMessage(
wrapSessionMessage({
type: "wait_for_finish_response",
payload: {
requestId: request.requestId,
status: "idle",
final: null,
error: null,
lastMessage: null,
},
}),
);
await expect(waitPromise).resolves.toEqual({
status: "idle",
final: null,
error: null,
lastMessage: null,
});
} finally {
vi.useRealTimers();
}
});
test("wire snapshot callers own expansion and receive hash references unchanged", async () => {
const transport = createMockTransport();
const client = new DaemonClient({
url: "ws://test",
clientId: "wire-catalog",
providerSnapshots: "wire",
reconnect: { enabled: false },
transportFactory: () => transport.transport,
});
clients.push(client);
const connected = client.connect();
transport.triggerOpen({ preserveSent: true });
await connected;
expect(JSON.parse(assertStr(transport.sent[0])).capabilities.provider_snapshot_references).toBe(
true,
);
const received: unknown[] = [];
client.on("providers_snapshot_update", (message) => received.push(message.payload));
const payload = {
entries: [],
snapshotHash: "content",
fetchedAt: { codex: "2026-09-06T12:00:00.000Z" },
generatedAt: "2026-09-06T12:00:00.000Z",
};
transport.triggerMessage(wrapSessionMessage({ type: "providers_snapshot_update", payload }));
expect(received).toEqual([payload]);
const request = client.getProvidersSnapshot();
const sent = parseSentFrame(transport.sent.at(-1)!);
const body = {
...payload,
compactSnapshot: {
entries: [
{
provider: "codex",
enabled: true,
status: "ready",
models: [{ id: "astra", label: "Astra" }],
},
],
thinkingSets: [],
},
requestId: sent.requestId,
};
transport.triggerMessage(
wrapSessionMessage({ type: "get_providers_snapshot_response", payload: body }),
);
expect(await request).toEqual(body);
});
test("creation lifecycle sends the keyed agent and initial prompt as one intent", async () => {
const transport = createMockTransport();
const client = new DaemonClient({
url: "ws://test",
clientId: "creation-contract",
transportFactory: () => transport.transport,
reconnect: { enabled: false },
});
clients.push(client);
const connected = client.connect();
transport.triggerOpen({ features: { creationLifecycle: true, agentRequestReceipts: true } });
await connected;
const creation = client.createAgent({
idempotencyKey: "draft-one",
provider: "codex",
cwd: "/project",
workspaceId: "wks_0123456789abcdef",
initialPrompt: "Start once",
clientMessageId: "first-message",
});
void creation.catch(() => undefined);
const request = parseSentFrame(transport.sent[0]);
expect(request).toMatchObject({
type: "agent.create.request",
idempotencyKey: "draft-one",
initialPrompt: "Start once",
clientMessageId: "first-message",
});
transport.triggerMessage(
wrapSessionMessage({
type: "agent.create.response",
payload: { requestId: request.requestId, agent: null, error: "provider unavailable" },
}),
);
await expect(creation).rejects.toThrow("provider unavailable");
expect(transport.sent).toHaveLength(1);
});
test("creation lifecycle acknowledgement reaches the observer before the final response", async () => {
const transport = createMockTransport();
const client = new DaemonClient({
url: "ws://test",
clientId: "creation-progress",
transportFactory: () => transport.transport,
reconnect: { enabled: false },
});
clients.push(client);
const connected = client.connect();
transport.triggerOpen({ features: { creationLifecycle: true } });
await connected;
const phases: string[] = [];
const creation = client.createAgent({
provider: "codex",
cwd: "/project",
idempotencyKey: "observe-one",
onEvent: (snapshot) => phases.push(snapshot.phase),
});
void creation.catch(() => undefined);
const request = parseSentFrame(transport.sent[0]);
transport.triggerMessage(
wrapSessionMessage({
type: "agent.create.update",
payload: {
kind: "agent",
idempotencyKey: "observe-one",
revision: 0,
phase: "accepted",
workspaceId: null,
agentId: "00000000-0000-4000-8000-000000000001",
error: null,
},
}),
);
expect(phases).toEqual(["accepted"]);
transport.triggerMessage(
wrapSessionMessage({
type: "agent.create.response",
payload: { requestId: request.requestId, agent: null, error: "provider unavailable" },
}),
);
await expect(creation).rejects.toThrow("provider unavailable");
});
test("creation reconnect observation uses connection-owned subscriptions and releases on completion", async () => {
const transport = createMockTransport();
const client = new DaemonClient({
url: "ws://test",
clientId: "creation-reconnect",
transportFactory: () => transport.transport,
reconnect: { enabled: false },
});
clients.push(client);
const features = { creationLifecycle: true, ownedSubscriptions: true };
const connect = client.connect();
transport.triggerOpen({ features });
await connect;
const phases: string[] = [];
const creation = client.createWorkspace({
idempotencyKey: "reconnect-one",
source: { kind: "directory", path: "/project" },
onEvent: (snapshot) => phases.push(snapshot.phase),
});
void creation.catch(() => undefined);
const snapshot = {
kind: "workspace",
idempotencyKey: "reconnect-one",
phase: "accepted",
revision: 0,
workspaceId: "wks_0123456789abcdef",
agentId: null,
error: null,
};
for (const subscriptionId of ["first-connection", "second-connection"]) {
transport.triggerClose();
const reconnected = client.connect();
transport.triggerOpen({ features });
await reconnected;
await expect.poll(() => transport.sent.length).toBe(1);
const request = parseSentFrame(transport.sent[0]);
expect(request).toMatchObject({
type: "creation.subscribe.request",
idempotencyKey: "reconnect-one",
});
transport.triggerMessage(
wrapSessionMessage({
type: "creation.subscribe.response",
payload: {
requestId: request.requestId,
subscriptionId,
snapshot,
error: null,
},
}),
);
}
transport.triggerMessage(
wrapSessionMessage({
type: "workspace.create.update",
payload: {
...snapshot,
subscriptionId: "second-connection",
revision: 1,
phase: "failed",
error: "Provisioning failed",
},
}),
);
expect(await creation).toMatchObject({ error: "Provisioning failed" });
await expect.poll(() => transport.sent.length).toBe(2);
const release = parseSentFrame(transport.sent.at(-1));
expect(release).toMatchObject({
type: "subscription.release.request",
subscriptionId: "second-connection",
});
transport.triggerMessage(
wrapSessionMessage({
type: "subscription.release.response",
payload: {
requestId: release.requestId,
subscriptionId: "second-connection",
},
}),
);
expect(phases).toEqual(["accepted", "failed"]);
});
test("uploadFile started while connecting waits for the connection and uploads", async () => {
const mock = createMockTransport();
const client = new DaemonClient({
url: "ws://test",
clientId: "upload-while-connecting",
logger: createMockLogger(),
reconnect: { enabled: false },
suppressSendErrors: true,
transportFactory: () => mock.transport,
});
clients.push(client);
const connection = client.connect();
expect(client.getConnectionState().status).toBe("connecting");
const upload = client.uploadFile({
fileName: "notes.txt",
mimeType: "text/plain",
bytes: new TextEncoder().encode("hello world"),
modifiedAt: "2026-05-02T00:00:00.000Z",
requestId: "req-upload-connecting",
chunkSize: 5,
});
void upload.catch(() => undefined);
await new Promise((resolve) => setTimeout(resolve, 0));
mock.triggerOpen();
await connection;
await vi.waitFor(() => {
const frames = mock.sent
.filter((frame) => typeof frame !== "string")
.map(assertUint8Array)
.map(decodeFileTransferFrame);
expect(frames.map((frame) => frame.opcode)).toEqual([
FileTransferOpcode.FileBegin,
FileTransferOpcode.FileChunk,
FileTransferOpcode.FileChunk,
FileTransferOpcode.FileChunk,
FileTransferOpcode.FileEnd,
]);
});
const requestIndex = mock.sent.findIndex(
(frame) =>
typeof frame === "string" && parseSentFrame(frame).requestId === "req-upload-connecting",
);
expect(parseSentFrame(mock.sent[requestIndex])).toMatchObject({ type: "file.upload.request" });
expect(requestIndex).toBeLessThan(mock.sent.findIndex((frame) => typeof frame !== "string"));
const file = {
type: "uploaded_file" as const,
id: "upload_req-upload-connecting",
fileName: "notes.txt",
mimeType: "text/plain",
size: 11,
path: "/tmp/paseo-uploads/upload_req-upload-connecting/notes.txt",
};
mock.triggerMessage(
wrapSessionMessage({
type: "file.upload.response",
payload: { requestId: "req-upload-connecting", file, error: null },
}),
);
await expect(upload).resolves.toEqual({ requestId: "req-upload-connecting", file, error: null });
});
test("uploadFile stops sending chunks when the connection closes between sends", async () => {
const mock = createMockTransport();
const client = new DaemonClient({
url: "ws://test",
clientId: "upload-interrupted",
logger: createMockLogger(),
reconnect: { enabled: false },
transportFactory: () => mock.transport,
});
clients.push(client);
const connection = client.connect();
mock.triggerOpen();
await connection;
const upload = client.uploadFile({
fileName: "test.bin",
mimeType: "application/octet-stream",
bytes: new Uint8Array(1024 * 1024),
});
const rejection = expect(upload).rejects.toThrow();
await new Promise((resolve) => setTimeout(resolve, 0));
const beforeClose = mock.sent.length;
mock.triggerClose();
await rejection;
await new Promise((resolve) => setTimeout(resolve, 10));
expect(mock.sent).toHaveLength(beforeClose);
expect(
mock.sent
.filter((frame) => typeof frame !== "string")
.map(assertUint8Array)
.map(decodeFileTransferFrame)
.some((frame) => frame.opcode === FileTransferOpcode.FileEnd),
).toBe(false);
});
test("rejects source installation on an older host before sending a request", async () => {
const transport = createMockTransport();
const client = new DaemonClient({
url: "ws://test",
clientId: "source-gate",
logger: createMockLogger(),
reconnect: { enabled: false },
transportFactory: () => transport.transport,
});
clients.push(client);
const connecting = client.connect();
transport.triggerOpen({ features: { pluginGitManagement: true } });
await connecting;
const sentBefore = transport.sent.length;
await expect(client.installPluginSource({ source: "npm:review" })).rejects.toThrow(
"Update the host",
);
expect(transport.sent.length).toBe(sentBefore);
});
test("reviewed plugin updates gate before requests and preserve exact proposal data", async () => {
for (const supported of [false, true]) {
const transport = createMockTransport();
const client = new DaemonClient({
url: "ws://test",
clientId: "reviewed-updates",
logger: createMockLogger(),
reconnect: { enabled: false },
transportFactory: () => transport.transport,
});
clients.push(client);
const connecting = client.connect();
transport.triggerOpen({
features: {
pluginGitManagement: true,
pluginSourceInstallation: true,
pluginSourceUpdates: supported,
},
});
await connecting;
const proposal = {
id: "review",
expected: {
identity: { kind: "npm" as const, packageName: "review", pluginPath: "." },
installationRoot: "/plugins/review/version-root",
revision: "1.0.0",
},
target: {
kind: "npm" as const,
version: "1.1.0",
resolved: "https://registry.npmjs.org/review/-/review-1.1.0.tgz",
integrity: "sha512-test",
},
};
if (!supported) {
const sent = transport.sent.length;
await expect(client.previewPluginUpdates()).rejects.toThrow("Update the host");
await expect(client.applyPluginUpdates([proposal])).rejects.toThrow("Update the host");
expect(transport.sent.length).toBe(sent);
continue;
}
const checking = client.previewPluginUpdates({ pluginId: "review" });
const check = parseSentFrame(transport.sent.at(-1));
expect(check).toMatchObject({
type: "plugin.source.update.preview.request",
pluginId: "review",
});
const preview = { id: "review", outcome: "update", links: [], proposal };
transport.triggerMessage(
wrapSessionMessage({
type: "plugin.source.update.preview.response",
payload: { requestId: check.requestId, plugins: [preview] },
}),
);
await expect(checking).resolves.toEqual([preview]);
const applying = client.applyPluginUpdates([proposal]);
const apply = parseSentFrame(transport.sent.at(-1));
expect(apply).toEqual({
type: "plugin.source.update.apply.request",
requestId: expect.any(String),
proposals: [proposal],
});
transport.triggerMessage(
wrapSessionMessage({
type: "plugin.source.update.apply.response",
payload: {
requestId: apply.requestId,
plugins: [{ id: "review", outcome: "error", error: "changed since review" }],
},
}),
);
await expect(applying).resolves.toEqual([
{ id: "review", outcome: "error", error: "changed since review" },
]);
}
});
test.each([
[undefined, false],
[{}, false],
[{ usageSources: false, providerUsageList: false }, false],
[{ usageSources: true }, true],
[{ providerUsageList: true }, true],
[{ usageSources: true, providerUsageList: true }, true],
] as const)("usage support for features %j is %s", (features, expected) => {
expect(supportsUsageReports(features)).toBe(expected);
});
test("uses modern usage RPC when both capabilities are advertised", async () => {
const mock = createMockTransport();
const client = new DaemonClient({
url: "ws://test",
clientId: "clsk_unit_test",
logger: createMockLogger(),
reconnect: { enabled: false },
transportFactory: () => mock.transport,
});
clients.push(client);
const connected = client.connect();
mock.triggerOpen({ features: { usageSources: true, providerUsageList: true } });
await connected;
const updates: unknown[] = [];
const result = client.listUsageReports(
{
requestId: "modern-usage",
reportIds: ["claude:work"],
forceRefresh: true,
},
(report) => updates.push(report),
);
expect(parseSentFrame(mock.sent[0])).toEqual({
type: "usage.list_reports.request",
requestId: "modern-usage",
reportIds: ["claude:work"],
forceRefresh: true,
});
const payload = {
requestId: "modern-usage",
reports: [
{
id: "claude:work",
sourceId: "claude",
sourceLabel: "Claude",
account: { label: "Work" },
fetchedAt: "2026-09-30T00:00:00.000Z",
report: { status: "available", windows: [] },
},
],
};
const report = payload.reports[0];
mock.triggerMessage(
wrapSessionMessage({
type: "usage.list_reports.update",
payload: { requestId: "foreign", report },
}),
);
expect(updates).toEqual([]);
mock.triggerMessage(
wrapSessionMessage({
type: "usage.list_reports.update",
payload: { requestId: payload.requestId, report },
}),
);
expect(updates).toEqual([report]);
mock.triggerMessage(
wrapSessionMessage({
type: "usage.list_reports.response",
payload: { requestId: payload.requestId, error: null },
}),
);
mock.triggerMessage(
wrapSessionMessage({
type: "usage.list_reports.update",
payload: { requestId: payload.requestId, report },
}),
);
expect(await result).toStrictEqual(payload);
expect(updates).toEqual([report]);
});
test("rejects usage requests when the host has neither capability", async () => {
const mock = createMockTransport();
const client = new DaemonClient({
url: "ws://test",
clientId: "clsk_unit_test",
logger: createMockLogger(),
reconnect: { enabled: false },
transportFactory: () => mock.transport,
});
clients.push(client);
const connected = client.connect();
mock.triggerOpen({ features: {} });
await connected;
await expect(client.listUsageReports()).rejects.toThrow("Update the host to see usage.");
expect(mock.sent).toEqual([]);
});
test("usage request timeout detaches its update listener", async () => {
vi.useFakeTimers();
try {
const mock = createMockTransport();
const client = new DaemonClient({
url: "ws://test",
clientId: "clsk_unit_test",
logger: createMockLogger(),
reconnect: { enabled: false },
transportFactory: () => mock.transport,
});
clients.push(client);
const connected = client.connect();
mock.triggerOpen({ features: { usageSources: true } });
await connected;
const updates: unknown[] = [];
const result = client.listUsageReports({ requestId: "timeout-usage" }, (report) =>
updates.push(report),
);
const rejected = expect(result).rejects.toThrow("Timeout waiting for message (60000ms)");
await vi.advanceTimersByTimeAsync(60_001);
await rejected;
mock.triggerMessage(
wrapSessionMessage({
type: "usage.list_reports.update",
payload: {
requestId: "timeout-usage",
report: {
id: "source:late",
account: {},
sourceId: "source",
sourceLabel: "Source",
fetchedAt: "2026-01-01T00:00:00.000Z",
report: { status: "available", windows: [] },
},
},
}),
);
expect(updates).toEqual([]);
} finally {
vi.useRealTimers();
}
});
test.each([false, true])(
"agent provenance requires host support=%s while human sends remain compatible",
async (supported) => {
const transport = createMockTransport();
const client = new DaemonClient({
url: "ws://test",
clientId: "provenance",
transportFactory: () => transport.transport,
reconnect: { enabled: false },
});
clients.push(client);
const connecting = client.connect();
transport.triggerOpen({ features: { agentMessageProvenance: supported } });
await connecting;
if (!supported) {
await expect(
client.sendAgentMessage("recipient", "review", { sourceAgentId: "server::sender" }),
).rejects.toThrow("Update the Paseo host");
expect(transport.sent).toHaveLength(0);
}
const send = client.sendAgentMessage("recipient", "review", {
messageId: "delivery",
...(supported ? { sourceAgentId: "server::sender" } : {}),
});
const request = parseSentFrame(transport.sent[0]);
expect(request).toMatchObject({
type: "send_agent_message_request",
text: "review",
messageId: "delivery",
});
expect(request.sourceAgentId).toBe(supported ? "server::sender" : undefined);
transport.triggerMessage(
wrapSessionMessage({
type: "send_agent_message_response",
payload: {
requestId: request.requestId,
agentId: "recipient",
accepted: true,
error: null,
},
}),
);
await send;
},
);
test("measures an outbound message in UTF-8 bytes against the daemon's message limit", async () => {
const mock = createMockTransport();
const client = new DaemonClient({
url: "ws://test",
clientId: "message_limit_unit_test",
transportFactory: () => mock.transport,
reconnect: { enabled: false },
});
clients.push(client);
const connectPromise = client.connect();
mock.triggerOpen();
await connectPromise;
const mib = 1024 * 1024;
// "é" is one UTF-16 unit but two UTF-8 bytes, so this text is ~98 MiB on the wire.
void client.sendMessage("agent-1", "é".repeat(49 * mib)).catch(() => undefined);
expect(mock.sent).toHaveLength(1);
// ~60M characters is under the 100 MiB limit; ~120 MiB of UTF-8 is over it.
await expect(client.sendMessage("agent-1", "é".repeat(60 * mib))).rejects.toThrow(
"Message is too large to send (120.0 MiB). The limit is 100.0 MiB",
);
expect(mock.sent).toHaveLength(1);
expect(client.getConnectionState().status).toBe("connected");
});
test("sends worktree target and autoArchive in create_agent_request", async () => {
const logger = createMockLogger();
const mock = createMockTransport();
const client = new DaemonClient({
url: "ws://test",
clientId: "clsk_unit_test",
logger,
reconnect: { enabled: false },
transportFactory: () => mock.transport,
});
clients.push(client);
const connectPromise = client.connect();
mock.triggerOpen();
await connectPromise;
const createPromise = client.createAgent({
provider: "codex",
cwd: "/tmp/project",
worktree: {
mode: "branch-off",
newBranch: "agent-lifecycle-dispatch",
base: "main",
},
autoArchive: true,
});
expect(mock.sent).toHaveLength(1);
const request = parseSentFrame(mock.sent[0]);
expect(request).toEqual(
expect.objectContaining({
type: "create_agent_request",
worktree: {
mode: "branch-off",
newBranch: "agent-lifecycle-dispatch",
base: "main",
},
autoArchive: true,
}),
);
mock.triggerMessage(
wrapSessionMessage({
type: "status",
payload: {
status: "agent_create_failed",
requestId: request.requestId,
error: "worktree auto archive sentinel",
},
}),
);
await expect(createPromise).rejects.toThrow("worktree auto archive sentinel");
});
test("ordinary lifecycle queues new calls, distinguishes submitted outcomes, and never resends lost requests", async () => {
const transports = [createMockTransport(), createMockTransport(), createMockTransport()];
let attempt = 0;
const client = new DaemonClient({
url: "ws://transport",
clientId: "ordinary-lifecycle",
reconnect: { enabled: false },
transportFactory: () => transports[attempt++]!.transport,
});
clients.push(client);
const first = transports[0]!;
const connecting = client.connect();
const initial = client.getDaemonConfig("initial").catch((error: unknown) => error);
expect(first.sent).toEqual([]);
first.triggerOpen();
await connecting;
expect(parseSentFrame(first.sent[0]).requestId).toBe("initial");
first.triggerMessage(
wrapSessionMessage({
type: "rpc_error",
payload: { requestId: "initial", error: "Denied", code: "access_denied" },
}),
);
expect(await initial).toMatchObject({ code: "access_denied", serverId: "srv_test_1" });
const submitted = client
.patchDaemonConfig({ pluginsEnabled: false }, "submitted")
.catch((error: unknown) => error);
first.triggerClose({ code: 1001, reason: "physical loss" });
const unknown = await submitted;
expect(unknown).toBeInstanceOf(DaemonConnectionError);
expect(unknown).toMatchObject({
code: "DAEMON_CONNECTION_LOST",
outcome: "unknown",
serverId: "srv_test_1",
});
expect(() => client.ensureConnected()).not.toThrow();
const second = transports[1]!;
const neverSent = client
.patchDaemonConfig({ pluginsEnabled: true }, "never-sent")
.catch((error: unknown) => error);
expect(second.sent).toEqual([]);
second.triggerClose({ code: 1001, reason: "handshake lost" });
const unavailable = await neverSent;
expect(unavailable).toBeInstanceOf(DaemonConnectionError);
expect(unavailable).toMatchObject({ code: "DAEMON_CONNECTION_LOST", serverId: "srv_test_1" });
expect((unavailable as DaemonConnectionError).outcome).toBeUndefined();
client.ensureConnected();
const third = transports[2]!;
const recovery = client.getDaemonConfig("recovery").catch((error: unknown) => error);
expect(third.sent).toEqual([]);
third.triggerOpen();
expect(third.sent.map(parseSentFrame).map((frame) => frame.requestId)).toEqual(["recovery"]);
third.triggerMessage(
wrapSessionMessage({
type: "rpc_error",
payload: { requestId: "recovery", error: "Denied", code: "access_denied" },
}),
);
expect(await recovery).toMatchObject({ code: "access_denied" });
});
test.each([false, true])(
"synchronous send rejection leaves no unknown outcome after close (close during handoff: %s)",
async (closeDuringSend) => {
const first = createMockTransport();
const second = createMockTransport();
const rejected = Object.assign(new Error("Transport rejected this send"), {
code: "resource_limit",
});
const originalSend = first.transport.send;
first.transport.send = (data) => {
if (typeof data === "string" && data.includes("set_daemon_config_request")) {
if (closeDuringSend) first.triggerClose({ code: 1001, reason: "closed during send" });
throw rejected;
}
originalSend(data);
};
let attempt = 0;
const client = new DaemonClient({
url: "ws://transport",
clientId: "rejected-send",
reconnect: { enabled: false },
transportFactory: () => [first, second][attempt++]!.transport,
});
clients.push(client);
const connected = client.connect();
first.triggerOpen();
await connected;
await expect(client.patchDaemonConfig({ pluginsEnabled: false }, "rejected")).rejects.toBe(
rejected,
);
first.triggerClose({ code: 1001, reason: "lost connection" });
client.ensureConnected();
const queued = client.getDaemonConfig("not-submitted").catch((error: unknown) => error);
second.triggerClose({ code: 1001, reason: "lost handshake" });
const failure = (await queued) as DaemonConnectionError;
expect(failure).toBeInstanceOf(DaemonConnectionError);
expect(failure.outcome).toBeUndefined();
expect((rejected as Error & { outcome?: string }).outcome).toBeUndefined();
},
);
test("initial acquisition preserves structured transport failures and ordinary offline errors retain their cause", async () => {
const transport = createMockTransport();
const failure = Object.assign(new Error("Destination identity changed"), {
code: "server_id_mismatch",
serverId: "srv_expected",
});
const client = new DaemonClient({
url: "ws://transport",
clientId: "structured-acquisition",
reconnect: { enabled: false },
transportFactory: () => transport.transport,
});
clients.push(client);
const connecting = client.connect();
const rejected = expect(connecting).rejects.toBe(failure);
transport.triggerError(failure);
await rejected;
await expect(client.getDaemonConfig()).rejects.toMatchObject({
code: "DAEMON_CONNECTION_LOST",
cause: failure,
});
});
// ---------------------------------------------------------------------------
// Relay endpoint security through the real secure channel
// ---------------------------------------------------------------------------
import {
createDaemonChannel,
exportPublicKey,
generateKeyPair,
openDaemonSecureChannel,
type SecureChannel,
type Transport as RelayWire,
} from "@getpaseo/relay/e2ee";
/**
* An in-memory socket pair: the client side is a DaemonTransport, the daemon
* side is the relay Transport the daemon's secure channel runs over. Frames
* are delivered asynchronously like a real socket, and a close from either
* side reaches the other with its code, as the browser WebSocket does.
*/
function createRelayLink(options?: { toDaemon?: (text: string) => string }) {
const clientOpen = new Set<() => void>();
const clientClose = new Set<(event?: unknown) => void>();
const clientError = new Set<(event?: unknown) => void>();
const clientMessage = new Set<(data: unknown, isBinary: boolean) => void>();
let closed = false;
const wire: RelayWire = {
send: (data) => {
if (closed) throw new Error("socket closed");
const isBinary = typeof data !== "string";
setTimeout(() => {
for (const handler of clientMessage) handler(data, isBinary);
}, 0);
},
close: (code = 1005, reason = "") => shutdown(code, reason),
onmessage: null,
onclose: null,
onerror: null,
};
const shutdown = (code: number, reason: string) => {
if (closed) return;
closed = true;
setTimeout(() => {
wire.onclose?.(code, reason);
for (const handler of clientClose) handler({ code, reason });
}, 0);
};
const transport: DaemonTransport = {
send: (data) => {
if (closed) throw new Error("socket closed");
if (typeof data === "string") {
const text = options?.toDaemon ? options.toDaemon(data) : data;
setTimeout(() => wire.onmessage?.({ data: text, isBinary: false }), 0);
return;
}
const bytes = data instanceof ArrayBuffer ? new Uint8Array(data) : data;
const copy = bytes.slice().buffer as ArrayBuffer;
setTimeout(() => wire.onmessage?.({ data: copy, isBinary: true }), 0);
},
close: (code = 1005, reason = "") => shutdown(code, reason),
onOpen: (handler) => {
clientOpen.add(handler);
return () => clientOpen.delete(handler);
},
onClose: (handler) => {
clientClose.add(handler);
return () => clientClose.delete(handler);
},
onError: (handler) => {
clientError.add(handler);
return () => clientError.delete(handler);
},
onMessage: (handler) => {
clientMessage.add(handler);
return () => clientMessage.delete(handler);
},
};
return {
transport,
wire,
open: () => {
for (const handler of clientOpen) handler();
},
isClosed: () => closed,
};
}
function serverInfoFrame(features: Record<string, boolean>): string {
return wrapSessionMessage({
type: "status",
payload: {
status: "server_info",
serverId: "srv_test",
hostname: null,
version: null,
features,
},
});
}
/** Opens the daemon side of a link and answers the application hello with the daemon's feature flags. */
function serveProtectedDaemon(
link: ReturnType<typeof createRelayLink>,
keys: ReturnType<typeof generateKeyPair>,
features: Record<string, boolean>,
received: unknown[] = [],
): void {
let channel: SecureChannel | null = null;
const pendingAnswers: string[] = [];
const opened = openDaemonSecureChannel(link.wire, {
daemonKeyPair: keys,
events: {
onmessage: (data) => {
received.push(data);
if (typeof data !== "string") return;
const frame = JSON.parse(data) as { type?: string };
if (frame.type !== "hello") return;
const answer = serverInfoFrame(features);
if (channel) void channel.send(answer);
else pendingAnswers.push(answer);
},
},
});
void (async () => {
try {
channel = await opened;
for (const answer of pendingAnswers.splice(0)) void channel.send(answer);
} catch {
// the test asserts on the client side
}
})();
}
test("connects in legacy mode to a daemon that only speaks the legacy construction", async () => {
const keys = generateKeyPair();
const modes: string[] = [];
const client = new DaemonClient({
url: "ws://relay.test/ws?role=client&serverId=srv_test&v=2",
clientId: "clsk_security_legacy_daemon",
e2ee: {
enabled: true,
daemonPublicKeyB64: exportPublicKey(keys.publicKey),
onSecurityMode: (mode) => modes.push(mode),
},
reconnect: { enabled: false },
transportFactory: () => {
const link = createRelayLink();
const opened = createDaemonChannel(link.wire, keys, {
onmessage: (data) => {
if (typeof data !== "string") return;
if ((JSON.parse(data) as { type?: string }).type !== "hello") return;
void opened.then((ch) => ch.send(serverInfoFrame({ ownedSubscriptions: true })));
},
});
void opened.catch(() => {});
setTimeout(() => link.open(), 0);
return link.transport;
},
});
clients.push(client);
await client.connect();
expect(client.getConnectionState().status).toBe("connected");
expect(modes).toEqual(["legacy"]);
});
test("refuses legacy endpoint security on reconnect once the daemon negotiated protected", async () => {
const keys = generateKeyPair();
const modes: string[] = [];
const links: ReturnType<typeof createRelayLink>[] = [];
const legacyReceived: unknown[] = [];
const client = new DaemonClient({
url: "ws://relay.test/ws?role=client&serverId=srv_test&v=2",
clientId: "clsk_security_latch",
e2ee: {
enabled: true,
daemonPublicKeyB64: exportPublicKey(keys.publicKey),
onSecurityMode: (mode) => modes.push(mode),
},
reconnect: { enabled: true, baseDelayMs: 1, maxDelayMs: 1 },
transportFactory: () => {
const link = createRelayLink();
links.push(link);
if (links.length === 1) {
serveProtectedDaemon(link, keys, { relayProtectedSecurity: true });
} else {
// A daemon that only speaks the legacy construction (older release).
void createDaemonChannel(link.wire, keys, {
onmessage: (data) => legacyReceived.push(data),
}).catch(() => {});
}
setTimeout(() => link.open(), 0);
return link.transport;
},
});
clients.push(client);
await client.connect();
expect(client.getConnectionState().status).toBe("connected");
expect(modes).toEqual(["protected"]);
// The relay drops the session; the client reconnects to a legacy-only daemon.
links[0].wire.close(1012, "Server disconnected");
await vi.waitFor(() => expect(links).toHaveLength(2));
await vi.waitFor(() => expect(links[1].isClosed()).toBe(true));
await vi.waitFor(() => expect(client.getConnectionState().status).toBe("disconnected"));
await new Promise((resolve) => setTimeout(resolve, 20));
expect(links).toHaveLength(2);
expect(modes).toEqual(["protected"]);
expect(legacyReceived).toEqual([]);
expect(client.lastError).toContain(
"Endpoint security handshake failed: daemon offered only legacy endpoint security",
);
});
test("closes before admission when a protected-capable daemon is reached in legacy mode", async () => {
const keys = generateKeyPair();
const daemonReceived: unknown[] = [];
let attempts = 0;
const client = new DaemonClient({
url: "ws://relay.test/ws?role=client&serverId=srv_test&v=2",
clientId: "clsk_security_downgrade",
e2ee: { enabled: true, daemonPublicKeyB64: exportPublicKey(keys.publicKey) },
reconnect: { enabled: true, baseDelayMs: 1, maxDelayMs: 1 },
transportFactory: () => {
attempts += 1;
// A relay in the middle strips the protected offer from the client hello.
const link = createRelayLink({
toDaemon: (text) => {
if (!text.startsWith("{")) return text;
const parsed = JSON.parse(text) as Record<string, unknown>;
if (parsed.type !== "e2ee_hello") return text;
const { protected: _dropped, ...legacyHello } = parsed;
return JSON.stringify(legacyHello);
},
});
serveProtectedDaemon(link, keys, { relayProtectedSecurity: true }, daemonReceived);
setTimeout(() => link.open(), 0);
return link.transport;
},
});
clients.push(client);
const connecting = client.connect();
void connecting.catch(() => {});
const queued = client.ping({ timeoutMs: 10_000 });
void queued.catch(() => {});
await expect(connecting).rejects.toThrow(
"Endpoint security handshake failed: daemon requires protected endpoint security",
);
await expect(queued).rejects.toThrow();
await new Promise((resolve) => setTimeout(resolve, 20));
expect(attempts).toBe(1);
expect(client.getConnectionState().status).toBe("disconnected");
// Only the application hello reached the daemon; the queued command never did.
expect(daemonReceived).toHaveLength(1);
expect(JSON.parse(daemonReceived[0] as string)).toMatchObject({ type: "hello" });
});
test.each([
{ chunked: false, transportChunked: false },
{ chunked: true, transportChunked: false },
{ chunked: false, transportChunked: true },
{ chunked: true, transportChunked: true },
])(
"default terminal observation requests ANSI replay (replay=$chunked, transport=$transportChunked)",
async ({ chunked, transportChunked }) => {
const transport = createMockTransport();
const client = new DaemonClient({
url: "ws://test",
clientId: "clsk_unit_test",
logger: createMockLogger(),
reconnect: { enabled: false },
transportFactory: () => transport.transport,
});
clients.push(client);
const connecting = client.connect();
transport.triggerOpen({
preserveSent: true,
features: {
ownedSubscriptions: true,
"terminal-restore-modes": true,
binaryChunkingV1: transportChunked,
},
});
await connecting;
const hello = JSON.parse(transport.sent[0] as string);
expect(hello.capabilities[CLIENT_CAPS.terminalChunkedReplay]).toBe(true);
expect(hello.capabilities[CLIENT_CAPS.binaryChunkingV1]).toBe(true);
const wire: Uint8Array[] = [];
const sender = new ChunkedTransport({
frameBytes: 17,
send: (frame) => {
if (typeof frame === "string") throw new Error("Expected binary terminal frame");
wire.push(frame);
transport.triggerMessage(frame);
},
deliver: () => {},
});
sender.enabled = transportChunked;
const events: import("./terminal-stream-router").TerminalStreamEvent[] = [];
const observation = client.observeTerminal("default-terminal", (event) => events.push(event));
await vi.waitFor(() =>
expect(parseSentFrame(transport.sent.at(-1)).type).toBe("subscribe_terminal_request"),
);
const request = parseSentFrame(transport.sent.at(-1));
expect(request.restore).toEqual({ mode: "full-snapshot" });
transport.triggerMessage(
wrapSessionMessage({
type: "subscribe_terminal_response",
payload: {
terminalId: "default-terminal",
requestId: request.requestId,
subscriptionId: "default-subscription",
slot: 2,
error: null,
},
}),
);
await observation.ready;
const data = new TextEncoder().encode("\x1b[31mretained 界🙂\x1b[0m");
const metadata = { rows: 24, cols: 80, retainedHistoryLines: 1000 };
if (chunked) {
await sender.send(
encodeTerminalStreamFrame({
opcode: TerminalStreamOpcode.ReplayBegin,
slot: 2,
payload: JSON.stringify(metadata),
}),
);
await sender.send(
encodeTerminalStreamFrame({
opcode: TerminalStreamOpcode.ReplayData,
slot: 2,
payload: data,
}),
);
await sender.send(
encodeTerminalStreamFrame({ opcode: TerminalStreamOpcode.ReplayEnd, slot: 2 }),
);
} else {
// An ANSI-era host selects its existing Restore path for this explicit request.
await sender.send(
encodeTerminalStreamFrame({ opcode: TerminalStreamOpcode.Restore, slot: 2, payload: data }),
);
}
if (transportChunked) {
expect(wire.length).toBeGreaterThan(chunked ? 3 : 1);
expect(wire.some((frame) => frame[0] === 0x31)).toBe(true);
expect(wire.every((frame) => frame.length <= 17)).toBe(true);
} else {
expect(wire.map((frame) => frame[0])).toEqual(chunked ? [6, 7, 8] : [5]);
}
expect(events).toEqual([
{
terminalId: "default-terminal",
subscriptionId: "default-subscription",
type: "replay",
replay: chunked ? { type: "begin", ...metadata } : { type: "begin" },
},
{
terminalId: "default-terminal",
subscriptionId: "default-subscription",
type: "replay",
replay: { type: "data", data },
},
{
terminalId: "default-terminal",
subscriptionId: "default-subscription",
type: "replay",
replay: { type: "end" },
},
]);
const live = new TextEncoder().encode("live after replay 界🙂");
await sender.send(
encodeTerminalStreamFrame({ opcode: TerminalStreamOpcode.Output, slot: 2, payload: live }),
);
expect(events.at(-1)).toEqual({
terminalId: "default-terminal",
subscriptionId: "default-subscription",
type: "output",
data: live,
});
},
);
test("uses the frame budget selected by an asynchronously opened transport", async () => {
const mock = createMockTransport();
mock.transport.maxFrameBytes = 1024;
const client = new DaemonClient({
url: "ws://test",
clientId: "dynamic-budget",
transportFactory: () => mock.transport,
reconnect: { enabled: false },
});
clients.push(client);
const connecting = client.connect();
mock.transport.maxFrameBytes = 16;
mock.triggerOpen({ features: { binaryChunkingV1: true } });
await connecting;
client.sendHostFrame("route", new Uint8Array(100));
await vi.waitFor(() => expect(mock.sent.length).toBeGreaterThan(1));
expect(mock.sent.every((frame) => typeof frame !== "string" && frame.byteLength <= 16)).toBe(
true,
);
});
test.each([false, true])("routed sends wait for origin drain (binary=%s)", async (binary) => {
const mock = createMockTransport();
let drain!: () => void;
let blocked = false;
const send = mock.transport.send;
mock.transport.send = (frame) => {
send(frame);
if (blocked)
return new Promise<void>((resolve) => {
drain = resolve;
});
};
const client = new DaemonClient({
url: "ws://test",
clientId: "routed-drain",
transportFactory: () => mock.transport,
reconnect: { enabled: false },
});
clients.push(client);
const connecting = client.connect();
mock.triggerOpen({ features: { binaryChunkingV1: true } });
await connecting;
blocked = true;
let finished = false;
const write = Promise.resolve(
client.sendHostFrame("route", binary ? new Uint8Array([1]) : "hello"),
).then(() => {
finished = true;
return undefined;
});
await Promise.resolve();
expect(finished).toBe(false);
drain();
await write;
expect(finished).toBe(true);
});
test("a queued admission failure rejects its RPC without closing the client", async () => {
const mock = createMockTransport();
const send = mock.transport.send;
let block = false;
let drain!: () => void;
mock.transport.send = (frame) => {
if (!block) return send(frame);
if (typeof frame !== "string")
return new Promise<void>((resolve) => {
drain = resolve;
});
throw new Error("capacity");
};
const client = new DaemonClient({
url: "ws://test",
clientId: "queued-admission",
transportFactory: () => mock.transport,
reconnect: { enabled: false },
});
clients.push(client);
const connecting = client.connect();
mock.triggerOpen();
await connecting;
block = true;
const first = client.sendHostFrame("route", new Uint8Array([1]));
const ping = client.ping({ timeoutMs: 1000 });
void ping.catch(() => {});
drain();
await first;
await expect(ping).rejects.toThrow("capacity");
expect(client.isConnected).toBe(true);
});
test("RPC deadlines remain active while a preceding frame waits for drain", async () => {
const mock = createMockTransport();
const send = mock.transport.send;
let blocked = false;
let drain!: () => void;
mock.transport.send = (frame) => {
if (!blocked) return send(frame);
if (typeof frame !== "string")
return new Promise<void>((resolve) => {
drain = resolve;
});
};
const client = new DaemonClient({
url: "ws://test",
clientId: "drain-deadline",
transportFactory: () => mock.transport,
reconnect: { enabled: false },
});
clients.push(client);
const connecting = client.connect();
mock.triggerOpen();
await connecting;
blocked = true;
const first = client.sendHostFrame("route", new Uint8Array([1]));
try {
await expect(client.ping({ timeoutMs: 20 })).rejects.toThrow("Timeout waiting for message");
expect(client.isConnected).toBe(true);
} finally {
drain();
await first;
}
});
test.each([false, true])(
"event listener errors do not close the transport (chunked=%s)",
async (chunked) => {
const mock = createMockTransport();
const closed = vi.fn();
mock.transport.close = closed;
const client = new DaemonClient({
url: "ws://test",
clientId: "listener-error",
transportFactory: () => mock.transport,
reconnect: { enabled: false },
});
clients.push(client);
const connecting = client.connect();
mock.triggerOpen({ features: { binaryChunkingV1: chunked } });
await connecting;
const unsubscribe = client.subscribe(() => {
throw new Error("listener failed");
});
const text = wrapSessionMessage({
type: "agent_deleted",
payload: { agentId: "agent-test", requestId: "listener-test" },
});
const payload = new TextEncoder().encode(text);
const frame = chunked ? new Uint8Array([0x31, 1, 0, 3, ...payload]) : text;
expect(() => mock.triggerMessage(frame)).toThrow("listener failed");
expect(closed).not.toHaveBeenCalled();
unsubscribe();
const events: string[] = [];
client.subscribe((event) => {
events.push(event.type);
});
mock.triggerMessage(frame);
expect(events).toEqual(["agent_deleted"]);
await client.sendHostFrame("route", "still connected");
expect(client.isConnected).toBe(true);
},
);
test("malformed chunk metadata still closes the client transport", async () => {
const mock = createMockTransport();
const closed = vi.fn();
mock.transport.close = closed;
const client = new DaemonClient({
url: "ws://test",
clientId: "malformed-chunk",
transportFactory: () => mock.transport,
reconnect: { enabled: false },
});
clients.push(client);
const connecting = client.connect();
mock.triggerOpen({ features: { binaryChunkingV1: true } });
await connecting;
mock.triggerMessage(new Uint8Array([0x31, 2, 0, 3, 65]));
expect(closed).toHaveBeenCalledWith(4003, "Invalid chunked transport");
});
test.each([FileTransferOpcode.FileBegin, FileTransferOpcode.FileChunk, FileTransferOpcode.FileEnd])(
"upload stops at a queued pre-write rejection (opcode=%s)",
async (opcode) => {
const mock = createMockTransport();
const send = mock.transport.send;
let drain!: () => void;
let blocked = false;
const attempts: number[] = [];
mock.transport.send = (frame) => {
if (typeof frame !== "string") {
const decoded = decodeFileTransferFrame(assertUint8Array(frame));
if (decoded) {
attempts.push(decoded.opcode);
if (decoded.opcode === opcode) throw new Error("upload capacity");
} else if (blocked)
return new Promise<void>((resolve) => {
drain = resolve;
});
}
return send(frame);
};
const client = new DaemonClient({
url: "ws://test",
clientId: "upload-admission",
transportFactory: () => mock.transport,
reconnect: { enabled: false },
});
clients.push(client);
const connecting = client.connect();
mock.triggerOpen();
await connecting;
blocked = true;
const first = client.sendHostFrame("route", new Uint8Array([1]));
const upload = client.uploadFile({
fileName: "file.bin",
mimeType: "application/octet-stream",
bytes: new Uint8Array([1, 2]),
chunkSize: 1,
});
let rejected: unknown;
void upload.catch((error) => {
rejected = error;
});
await new Promise<void>((resolve) => setTimeout(resolve, 0));
drain();
await first;
await vi.waitFor(() => expect(rejected).toMatchObject({ message: "upload capacity" }), {
timeout: 500,
});
expect(attempts.at(-1)).toBe(opcode);
expect(client.isConnected).toBe(true);
},
);
test.each(["latency", "dictation start", "dictation finish"])(
"queued control send rejection reaches its caller (%s)",
async (operation) => {
const mock = createMockTransport();
const send = mock.transport.send;
let blocked = false;
let drain!: () => void;
mock.transport.send = (frame) => {
if (!blocked) return send(frame);
if (typeof frame !== "string")
return new Promise<void>((resolve) => {
drain = resolve;
});
throw new Error("control capacity");
};
const client = new DaemonClient({
url: "ws://test",
clientId: "control-admission",
transportFactory: () => mock.transport,
reconnect: { enabled: false },
});
clients.push(client);
const connecting = client.connect();
mock.triggerOpen();
await connecting;
blocked = true;
const first = client.sendHostFrame("route", new Uint8Array([1]));
let result: Promise<unknown>;
if (operation === "latency") result = client.measureLatency({ timeoutMs: 1000 });
else if (operation === "dictation start")
result = client.startDictationStream("dictation", "pcm");
else result = client.finishDictationStream("dictation", 0);
const rejected = expect(result).rejects.toThrow("control capacity");
drain();
await first;
await rejected;
expect(client.isConnected).toBe(true);
},
);
test("routed channels inherit an origin without drain support", async () => {
const { createHostChannelTransport } = await import("./hosts/index.js");
const mock = createMockTransport();
mock.transport.supportsDrain = false;
const client = new DaemonClient({
url: "ws://test",
clientId: "unpaced-route",
transportFactory: () => mock.transport,
reconnect: { enabled: false },
});
clients.push(client);
const connecting = client.connect();
mock.triggerOpen();
await connecting;
const route = createHostChannelTransport(
client,
"srv_destination",
"scope",
"route",
)({ url: "ws://route" });
expect(route.supportsDrain).toBe(false);
route.close();
});
describe("provider options across daemon versions", () => {
const agentSnapshot = {
id: "agent-1",
provider: "codex",
cwd: "/repo",
model: null,
thinkingOptionId: null,
effectiveThinkingOptionId: null,
createdAt: "2026-04-30T00:00:00.000Z",
updatedAt: "2026-04-30T00:00:00.000Z",
lastUserMessageAt: null,
status: "idle",
capabilities: {
supportsStreaming: false,
supportsSessionPersistence: false,
supportsDynamicModes: false,
supportsMcpServers: false,
supportsReasoningStream: false,
supportsToolInvocations: false,
},
currentModeId: null,
availableModes: [],
pendingPermissions: [],
persistence: null,
title: null,
labels: {},
requiresAttention: false,
attentionReason: null,
};
const speedFeature = {
type: "select",
id: "service_tier",
label: "Speed",
value: "priority",
options: [
{ id: "default", label: "Normal", isDefault: true },
{ id: "priority", label: "Fast" },
],
};
async function connect(features: Record<string, boolean>) {
const mock = createMockTransport();
const client = new DaemonClient({
url: "ws://test",
clientId: "provider_options",
transportFactory: () => mock.transport,
reconnect: { enabled: false },
});
clients.push(client);
const connecting = client.connect();
mock.triggerOpen({ features });
await connecting;
const sentMessages = () =>
mock.sent.map(
(frame) => (JSON.parse(frame as string) as { message: Record<string, unknown> }).message,
);
return { mock, client, sentMessages };
}
test("a legacy create and a timeline read return the agent with controls over options", async () => {
const { mock, client, sentMessages } = await connect({});
const legacyAgent = { ...agentSnapshot, features: [speedFeature] };
const answerLast = (reply: (requestId: string) => Record<string, unknown>) => {
const sent = sentMessages();
const requestId = String(sent[sent.length - 1].requestId);
mock.triggerMessage(wrapSessionMessage(reply(requestId) as never));
};
const creating = client.createAgent({ provider: "codex", cwd: "/repo" });
answerLast((requestId) => ({
type: "status",
payload: { status: "agent_created", requestId, agentId: "agent-1", agent: legacyAgent },
}));
const created = await creating;
const reading = client.fetchAgentTimeline("agent-1");
answerLast((requestId) => ({
type: "fetch_agent_timeline_response",
payload: {
requestId,
agentId: "agent-1",
agent: legacyAgent,
direction: "tail",
projection: "projected",
epoch: "epoch-1",
reset: false,
staleCursor: false,
gap: false,
window: { minSeq: 0, maxSeq: 0, nextSeq: 1 },
startCursor: null,
endCursor: null,
hasOlder: false,
hasNewer: false,
entries: [],
error: null,
},
}));
const timeline = await reading;
expect(created.options).toEqual({ service_tier: "priority" });
expect(created.controls).toEqual([expect.objectContaining({ key: "service_tier" })]);
expect(timeline.agent?.options).toEqual({ service_tier: "priority" });
expect(timeline.agent?.controls).toEqual([expect.objectContaining({ key: "service_tier" })]);
});
test("a legacy daemon's features arrive as controls over options", async () => {
const { mock, client } = await connect({});
const received: Array<{ controls?: unknown; options?: unknown }> = [];
client.on("agent_update", (message) => {
if (message.payload.kind === "upsert") received.push(message.payload.agent);
});
mock.triggerMessage(
wrapSessionMessage({
type: "agent_update",
payload: { kind: "upsert", agent: { ...agentSnapshot, features: [speedFeature] } },
}),
);
expect(received).toEqual([
expect.objectContaining({
options: { service_tier: "priority" },
controls: [
expect.objectContaining({
type: "select",
key: "service_tier",
defaultValue: "default",
choices: [
{ value: "default", label: "Normal" },
{ value: "priority", label: "Fast" },
],
}),
],
}),
]);
});
test("a legacy daemon takes option writes as feature writes", async () => {
const { client, sentMessages, mock } = await connect({});
const write = client.setAgentOptions("agent-1", { service_tier: "default" });
const [request] = sentMessages();
expect(request).toMatchObject({
type: "set_agent_feature_request",
agentId: "agent-1",
featureId: "service_tier",
value: "default",
});
mock.triggerMessage(
wrapSessionMessage({
type: "set_agent_feature_response",
payload: { requestId: request.requestId, agentId: "agent-1", accepted: true, error: null },
}),
);
await write;
});
// The old Codex setter rejects a non-string `service_tier` and the old Claude setter reads
// `null` as off, so a reset is sent as the value the old API can represent.
async function resetOnLegacyDaemon(agent: Record<string, unknown>, patch: Record<string, null>) {
const { client, sentMessages, mock } = await connect({});
const reply = (message: Record<string, unknown>) =>
mock.triggerMessage(wrapSessionMessage(message as never));
const writing = client.setAgentOptions("agent-1", patch);
const [fetch] = sentMessages();
expect(fetch).toMatchObject({ type: "fetch_agent_request", agentId: "agent-1" });
reply({
type: "fetch_agent_response",
payload: { requestId: fetch.requestId, agent, project: null, error: null },
});
return { writing, sentMessages, reply };
}
test("a legacy daemon resets a choice to the default the old API names", async () => {
const { writing, sentMessages, reply } = await resetOnLegacyDaemon(
{ ...agentSnapshot, provider: "codex", features: [speedFeature] },
{ service_tier: null },
);
await vi.waitFor(() => expect(sentMessages()).toHaveLength(2));
const write = sentMessages()[1];
expect(write).toMatchObject({
type: "set_agent_feature_request",
featureId: "service_tier",
value: "default",
});
reply({
type: "set_agent_feature_response",
payload: { requestId: write.requestId, agentId: "agent-1", accepted: true, error: null },
});
await writing;
});
test("a legacy daemon resets a toggle to off", async () => {
const { writing, sentMessages, reply } = await resetOnLegacyDaemon(
{
...agentSnapshot,
features: [{ type: "toggle", id: "fast_mode", label: "Fast", value: true }],
},
{ fast_mode: null },
);
await vi.waitFor(() => expect(sentMessages()).toHaveLength(2));
const write = sentMessages()[1];
expect(write).toMatchObject({ featureId: "fast_mode", value: false });
reply({
type: "set_agent_feature_response",
payload: { requestId: write.requestId, agentId: "agent-1", accepted: true, error: null },
});
await writing;
});
test("a legacy daemon cannot reset an option no control draws, and writes nothing", async () => {
const { writing, sentMessages } = await resetOnLegacyDaemon(
{ ...agentSnapshot, features: [speedFeature] },
{ token_budget: null },
);
await expect(writing).rejects.toThrow("Update the host to reset token_budget.");
expect(sentMessages()).toHaveLength(1);
});
test("a legacy daemon applies a config reset as the default the old API names", async () => {
const { client, sentMessages, mock } = await connect({});
const reply = (message: Record<string, unknown>) =>
mock.triggerMessage(wrapSessionMessage(message as never));
const applying = client.applyAgentConfig("agent-1", {
modeId: "plan",
options: { service_tier: null, sandbox_mode: "read-only" },
});
const [fetch] = sentMessages();
expect(fetch).toMatchObject({ type: "fetch_agent_request", agentId: "agent-1" });
reply({
type: "fetch_agent_response",
payload: {
requestId: fetch.requestId,
agent: { ...agentSnapshot, provider: "codex", features: [speedFeature] },
project: null,
error: null,
},
});
await vi.waitFor(() => expect(sentMessages()).toHaveLength(2));
const apply = sentMessages()[1];
expect(apply).toMatchObject({
type: "agent.config.apply.request",
config: {
modeId: "plan",
featureValues: { service_tier: "default", sandbox_mode: "read-only" },
},
});
expect(apply.config).not.toHaveProperty("options");
reply({
type: "agent.config.apply.response",
payload: { requestId: apply.requestId, agentId: "agent-1", accepted: true, error: null },
});
await applying;
});
test("a legacy daemon cannot apply a config reset it cannot name, and applies nothing", async () => {
const { client, sentMessages, mock } = await connect({});
const applying = client.applyAgentConfig("agent-1", { options: { token_budget: null } });
const [fetch] = sentMessages();
mock.triggerMessage(
wrapSessionMessage({
type: "fetch_agent_response",
payload: {
requestId: fetch.requestId,
agent: { ...agentSnapshot, features: [speedFeature] },
project: null,
error: null,
},
} as never),
);
await expect(applying).rejects.toThrow("Update the host to reset token_budget.");
expect(sentMessages()).toHaveLength(1);
});
test("a current daemon takes option writes as one options patch", async () => {
const { client, sentMessages, mock } = await connect({ providerOptionControls: true });
const write = client.setAgentOptions("agent-1", { service_tier: null, sandbox_mode: "x" });
const [request] = sentMessages();
expect(request).toMatchObject({
type: "agent.config.apply.request",
agentId: "agent-1",
config: { options: { service_tier: null, sandbox_mode: "x" } },
});
mock.triggerMessage(
wrapSessionMessage({
type: "agent.config.apply.response",
payload: { requestId: request.requestId, agentId: "agent-1", accepted: true, error: null },
}),
);
await write;
});
test("a legacy draft listing answers with controls and options", async () => {
const { client, sentMessages, mock } = await connect({});
const listing = client.listProviderControls({
provider: "codex",
cwd: "/repo",
providerOptions: { service_tier: "default" },
});
const [request] = sentMessages();
expect(request).toMatchObject({
type: "list_provider_features_request",
draftConfig: { provider: "codex", featureValues: { service_tier: "default" } },
});
mock.triggerMessage(
wrapSessionMessage({
type: "list_provider_features_response",
payload: {
provider: "codex",
features: [{ ...speedFeature, value: "default" }],
error: null,
fetchedAt: "2026-04-30T00:00:00.000Z",
requestId: request.requestId,
},
}),
);
await expect(listing).resolves.toMatchObject({
provider: "codex",
options: { service_tier: "default" },
controls: [expect.objectContaining({ key: "service_tier" })],
});
});
test("a legacy daemon's profiles are read and saved by their options", async () => {
const { client, sentMessages, mock } = await connect({});
const reading = client.getDaemonConfig();
const [read] = sentMessages();
mock.triggerMessage(
wrapSessionMessage({
type: "get_daemon_config_response",
payload: {
requestId: read.requestId,
config: {
mcp: { injectIntoAgents: true },
agentProfiles: [
{
id: "p1",
name: "Fast",
provider: "codex",
featureValues: { service_tier: "fast" },
},
],
},
},
}),
);
const { config } = await reading;
expect(config.agentProfiles?.[0]).toMatchObject({ options: { service_tier: "fast" } });
expect(config.agentProfiles?.[0]).not.toHaveProperty("featureValues");
void client.patchDaemonConfig({ agentProfiles: config.agentProfiles }).catch(() => {});
const written = sentMessages().find((message) => message.type === "set_daemon_config_request");
expect(written).toMatchObject({
config: { agentProfiles: [{ featureValues: { service_tier: "fast" } }] },
});
expect(written).not.toMatchObject({ config: { agentProfiles: [{ options: {} }] } });
});
test("a legacy daemon's profile with both featureValues and options keeps both when saved", async () => {
// `options` survives on a legacy daemon's passthrough profile after a downgrade, while its
// own app keeps editing `featureValues`.
const { client, sentMessages, mock } = await connect({});
const reading = client.getDaemonConfig();
const [read] = sentMessages();
mock.triggerMessage(
wrapSessionMessage({
type: "get_daemon_config_response",
payload: {
requestId: read.requestId,
config: {
mcp: { injectIntoAgents: true },
agentProfiles: [
{
id: "p1",
name: "Mixed",
provider: "codex",
featureValues: { service_tier: "fast", sandbox_mode: "workspace-write" },
options: { sandbox_mode: "read-only" },
},
],
},
},
}),
);
const { config } = await reading;
expect(config.agentProfiles?.[0]).toMatchObject({
options: { service_tier: "fast", sandbox_mode: "read-only" },
});
expect(config.agentProfiles?.[0]).not.toHaveProperty("featureValues");
void client.patchDaemonConfig({ agentProfiles: config.agentProfiles }).catch(() => {});
const written = sentMessages().find((message) => message.type === "set_daemon_config_request");
expect(written).toMatchObject({
config: {
agentProfiles: [{ featureValues: { service_tier: "fast", sandbox_mode: "read-only" } }],
},
});
});
test("a legacy daemon applies a profile's options as feature values", async () => {
const { client, sentMessages } = await connect({});
void client.applyAgentConfig("agent-1", { options: { service_tier: "fast" } }).catch(() => {});
const [request] = sentMessages();
expect(request).toMatchObject({
type: "agent.config.apply.request",
config: { featureValues: { service_tier: "fast" } },
});
});
test("a cold legacy create lists the provider's features before routing its options", async () => {
const { client, sentMessages, mock } = await connect({});
void client
.createAgent({
config: {
provider: "claude",
cwd: "/repo",
providerOptions: { fast_mode: true, sandbox_mode: "read-only" },
},
})
.catch(() => {});
await vi.waitFor(() => expect(sentMessages()).toHaveLength(1));
const [listing] = sentMessages();
expect(listing).toMatchObject({
type: "list_provider_features_request",
draftConfig: { provider: "claude", cwd: "/repo" },
});
mock.triggerMessage(
wrapSessionMessage({
type: "list_provider_features_response",
payload: {
provider: "claude",
features: [{ type: "toggle", id: "fast_mode", label: "Fast", value: false }],
error: null,
fetchedAt: "2026-04-30T00:00:00.000Z",
requestId: listing.requestId,
},
}),
);
await vi.waitFor(() => expect(sentMessages()).toHaveLength(2));
expect(sentMessages()[1].config).toMatchObject({
featureValues: { fast_mode: true },
providerOptions: { sandbox_mode: "read-only" },
});
});
test("a legacy workspace create routes its agent's options from its own model's controls", async () => {
const { client, sentMessages, mock } = await connect({ creationLifecycle: true });
mock.triggerMessage(
wrapSessionMessage({
type: "agent_update",
payload: {
kind: "upsert",
agent: { ...agentSnapshot, provider: "codex", model: "mini", features: [] },
},
}),
);
void client
.createWorkspace({
source: { kind: "directory", path: "/repo" },
agent: {
config: {
provider: "codex",
cwd: "/repo",
model: "gpt-5.5",
providerOptions: { service_tier: "fast" },
},
},
} as never)
.catch(() => {});
await vi.waitFor(() => expect(sentMessages()).toHaveLength(1));
const [listing] = sentMessages();
expect(listing).toMatchObject({
type: "list_provider_features_request",
draftConfig: { provider: "codex", cwd: "/repo", model: "gpt-5.5" },
});
mock.triggerMessage(
wrapSessionMessage({
type: "list_provider_features_response",
payload: {
provider: "codex",
features: [speedFeature],
error: null,
fetchedAt: "2026-04-30T00:00:00.000Z",
requestId: listing.requestId,
},
}),
);
const sentTypes = () => sentMessages().map((message) => message.type);
await vi.waitFor(() => expect(sentTypes()).toContain("workspace.create.request"));
const created = sentMessages()[sentTypes().indexOf("workspace.create.request")];
expect(created).toMatchObject({
agent: { config: { featureValues: { service_tier: "fast" } } },
});
});
test("a legacy create lists the controls of its own model, whatever a snapshot showed", async () => {
const { client, sentMessages, mock } = await connect({});
// A Haiku agent has no Fast control; that says nothing about Opus.
mock.triggerMessage(
wrapSessionMessage({
type: "agent_update",
payload: {
kind: "upsert",
agent: { ...agentSnapshot, provider: "claude", model: "haiku", features: [] },
},
}),
);
void client
.createAgent({
config: {
provider: "claude",
cwd: "/repo",
model: "opus",
modeId: "plan",
thinkingOptionId: "high",
providerOptions: { fast_mode: true, sandbox_mode: "read-only" },
},
})
.catch(() => {});
await vi.waitFor(() => expect(sentMessages()).toHaveLength(1));
const [listing] = sentMessages();
expect(listing).toMatchObject({
type: "list_provider_features_request",
draftConfig: {
provider: "claude",
cwd: "/repo",
model: "opus",
modeId: "plan",
thinkingOptionId: "high",
},
});
mock.triggerMessage(
wrapSessionMessage({
type: "list_provider_features_response",
payload: {
provider: "claude",
features: [{ type: "toggle", id: "fast_mode", label: "Fast", value: false }],
error: null,
fetchedAt: "2026-04-30T00:00:00.000Z",
requestId: listing.requestId,
},
}),
);
await vi.waitFor(() => expect(sentMessages()).toHaveLength(2));
expect(sentMessages()[1].config).toMatchObject({
model: "opus",
featureValues: { fast_mode: true },
providerOptions: { sandbox_mode: "read-only" },
});
});
});