Add personal Muse operator setup and composer controls (#16450)

Add Muse invitation, connection readiness and scoped operator controls.

Keep connection state tied to the current company, person, agent and binding. Surface failed reads and invalidate stale cleanup observations; require current authority and exact readback. Preserve Dot/Grok setup, source-trust limits and existing design tokens.

Carry actual merged backend e6dcc6b095 through a normal forward merge. Preserve its project-write authority fix, existing migration bytes and upstream idle-serving behavior; no new UI behavior is authored by this source carry. Current-head CI and substantive review remain merge gates; final canonical and deployed-provider qualification remain separate.

Refs #16450.

Co-Authored-By: Paperclip <noreply@paperclip.ing>
This commit is contained in:
DottaandPaperclip authored and GitHub committed 2026-10-17 23:01:42 -05:00
1 parent 7c7d2d8d2c
commit 896f3c7f3b
45 files changed
+2470 -116

No files matched your search

+14
View File
@@ -1400,6 +1400,20 @@ as execution. Codex CLI defaults permit workspace writes and network access for
Paperclip coordination without disabling its sandbox; explicit operator
restrictions and execution-target network denials remain effective.
### Experimental personal Muse Runner provider
The `paperclip_runner` adapter can select `provider: "muse"` behind the default-off
Muse and native Runner gates. This connects personal Meta Muse through a
company- and named-agent-bound receiver, distinct from human-authorized MCP.
Pairing, detector contact, and a verified background reply are separate setup
milestones. Idle first-party collaboration retains both agent and authorizing
user permissions; task execution additionally requires the admitted native run.
Only one assignment may execute per connection, and unresolved remote work or
native effects block overlap across cancellation, reconnect, and provider changes.
Remote interruption and external usage remain unconfirmed or unavailable unless
supported by evidence. Release requires the live journeys and bounded cadence
qualification in [Personal Muse receiver](muse-personal-agent.md).
## 11.2 Process Adapter
Config shape:
+8
View File
@@ -749,6 +749,14 @@ while preserving agent pairing, normal run admission and task authority.
Personal grants cannot authorize Runner operations. See
[OpenAI Dot Runner](openai-dot-runner.md) for its supported release boundary.
Personal Meta Muse is another experimental named Runner agent. Its managed
receiver uses short-lived pairing and rotating private credentials; its human
authorizer remains distinct from the agent identity. Paperclip owns admission,
task authority, and native finalization. Detector health alone does not prove
execution, and uncertain remote effects prevent overlapping dispatch. See
[Personal Muse receiver](muse-personal-agent.md) for setup, stopping limits, and
the required live qualification before release.
### Experimental connection routing
A virtual AI connection can rotate new task/agent allocations through an
+18
View File
@@ -464,3 +464,21 @@ Independently addressable examples live under `Connections/In-task connections`
The footer also appears on `/design-guide`. All stories use offline fixtures;
they do not create provider sessions. The viewer fixture intentionally scales a
fixed desktop viewport, preserving the current provider embed's sizing limitation.
## Personal Muse connection — 2026-10-10
The shared `new-agent/ExternalAgentInviteDialog.tsx` and
`ExternalAgentInviteContent.tsx` now support personal Muse alongside Dot. They
reuse the agent picker, name and role form, setup instruction, and single-row
footer. The Muse journey distinguishes pairing, detector contact, an independent
background reply, and the existing agent lifecycle gate. Company/operator drafts
contain only the name, role, and agent reference; pairing tickets stay transient.
`MuseRunnerConnection.tsx` is the agent settings surface for connection health,
verification, repair, disconnection, and attestation of a specific stop boundary.
Its examples appear in the Design Guide. The [guided Muse stories](../../ui/storybook/stories/agents/external-agent-invite/muse.stories.tsx)
use the real invitation and connection details components with offline fixtures,
including setup milestones, recovery, disabled cleanup, and the exact stop boundary.
The [Storybook catalog](../../maps/storybook-map/INDEX.md) registers these under
agent hiring and lifecycle. The examples remain unreviewed for Vault/provider parity
and do not establish provider connectivity or remote stopping.
+7
View File
@@ -10,6 +10,13 @@ The explicit-only [live provider connection suite](../tests/runner-e2e/PROVIDER-
is a Product E2E workflow for fresh subscription/API-key/gateway connections,
with attended login and independent artifact checks against local or staging targets.
The bounded [personal Muse receiver probe](muse-personal-agent.md#bounded-24-hour-probe)
uses ordinary task APIs and retained native receipts to measure unattended
receiver behavior. It is a separate manual qualification probe, not a registered
Product E2E campaign. Synthetic receiver tests cannot qualify personal Muse;
real setup, clarification and fresh-conversation collaboration remain separate
release gates.
Paperclip has two live eval families with different questions, owners, and
evidence. Choose the family before selecting a model, profile, or case.
+117
View File
@@ -0,0 +1,117 @@
# Personal Muse receiver
This experimental integration connects the existing personal Meta Muse to a named Paperclip Runner agent. It is separate from Muse Code. The provider is disabled by default; implementation and synthetic tests do not qualify the personal service. See the [architecture and release gates](plans/2026-10-10-muse-runner-architecture.md).
## Current compatibility and release gates
October 13, 2026 read-only probes of personal Muse reconfirmed a five-second
minimum for managed hooks, no secrets in hook scripts, and no script arguments.
The approved asset6 detector uses a no-argument script and a nonsecret binding
header. Its public response contains only an opaque wake reference; an observed
check does not authenticate Muse or establish readiness.
Production credentials belong in Muse's Secure Vault/authd. The asset6 setup uses
public-client OAuth, dynamic client registration, S256 PKCE and automatic refresh.
Muse's bundled surrogate helper supplies worker HTTP authentication without
reading or persisting raw credentials. The October 13 provider test preserved the pairing query in authd's Connect flow and completed scoped authenticated reads and automatic refresh. Keep the provider disabled
until the current OAuth setup, real work journeys, installed Cloud path and new
24-hour qualification pass. Local and scripted tests are not live qualification.
## Connection and work
An operator uses the existing external-agent invitation, selects **Muse — Personal agent**, and supplies the agent's name and role. Paperclip creates or resumes the operator's unfinished hire, respecting approval requirements. Copy the transient setup instruction into the current Muse conversation. Muse downloads versioned, digest-pinned assets and requests a Vault-backed Paperclip connection. The user follows Muse’s Connect link; a ten-minute single-use pairing ticket binds the OAuth grant to the exact company, agent and authorizing operator. No Muse cookie export or manually copied long-lived key is part of setup.
Asset6 setup records the operator’s standing authorization to receive, validate and autonomously accept ordinary tasks for the exact company, agent and binding. The client installs that nonsecret scope with the verified instructions in a binding-specific `~/workspace/skills/paperclip-muse-<binding UUID>/SKILL.md`, discoverable in later conversations. A valid in-scope assignment does not require another acceptance approval. Task text cannot expand this delegation; Muse’s tool, site and safety permissions and Paperclip approval gates still apply. Native acceptance must be observed before any task effect. Each wake carries the workflow forward; canonical answers resume the same admitted work through result publication and native completion. Assets1–5 remain available with their original bytes.
Approve Paperclip's hostname through Muse's permission UI. Read the permission scope shown by Muse: its standing approval may cover a parent domain and all its subdomains. Paperclip cannot grant this permission on Muse's behalf. Setup distinguishes paired, receiver detected, and an authenticated background reply; the detector alone cannot finish setup. Hiring approval and native lifecycle readiness remain additional gates.
Muse’s Secure Vault/authd owns access and refresh credentials. The Python client stores only nonsecret connection metadata and stable operation receipts. The no-argument Bash detector reads the public receiver profile and produces opaque wake references. Empty checks and failures must not wake the model. Access expires after fifteen minutes, refresh rotates automatically, and thirty days of authenticated inactivity requires reconnecting. Signal checks do not extend credential activity.
Assigned work claims an existing native run. Submitted progress, questions, task documents and completion use Paperclip's native authority and receipts. Paperclip displays reported progress; it cannot inspect every private Muse conversation/tool call. When idle, Muse can read permitted company work, create tasks and comment as the named agent. Other Paperclip execution operations require the current assignment.
Native Muse document writes retain source trust. Higher-trust Muse readers cannot receive quarantined document bodies or revision summaries through direct reads or accepted-plan context. A later rewrite or promotion does not promote older revisions; explicit promotion applies only to the exact current revision. Historical content whose provenance cannot be verified is withheld with an explicit reason.
Stop revokes run authority immediately. Remote stopping is best effort, and unknown native effects or unconfirmed claimed work prevent overlapping dispatch. Reconnect does not clear those barriers. Disconnect revokes all OAuth authority immediately; a subsequent public410 disables the detector. Authenticated post-disconnect cleanup is unavailable with Muse’s supported single Vault credential, so cleanup remains unconfirmed unless independently observed. Worker-reported quiescence, operator attestation, detector removal and authoritative native effects are separate evidence. Unknown Muse usage/cost remains unavailable.
## Reproducible verification
### OAuth exchange diagnostics
The agent's scoped Audit view records static `muse.oauth_code_exchange_rejected_*`
actions for a rejected exchange attributable to a persisted Muse code, grant and
binding. Each category is recorded once per binding generation, with the first
recorded attempt's handler-entry, decision, authorization and code-deadline times.
The `failedChecks` list contains observed failed predicates; overlapping checks
do not establish one exclusive cause. Public OAuth errors remain generic. A
successful exchange records `muse.oauth_code_exchange_succeeded` atomically with
code consumption and credential issuance.
Pairing precedes token issuance. An expired credential status without an observed
refresh deadline does not by itself prove revocation. Authorization codes expire
after one minute, including callback handling and admission-lock wait; the setup
ticket's ten-minute deadline is separate. Unknown codes, including expired rows
already pruned during registration, and missing historical binding linkage cannot
safely produce company activity.
Diagnostic storage failure also leaves no record. Absence of an event therefore
does not prove that no token request arrived. Activity contains no OAuth code,
verifier, token, request/client identifier, callback URL, header or secret hash.
Run the targeted protocol, Runner and UI tests before repository-wide checks. The receiver/client must remain behind the experimental feature until these real journeys are measured:
1. Pair from a current personal Muse chat, approve the hostname, then wait for an independent background reply.
2. Assign a real research task; verify a useful saved task document and native task finalization.
3. Have Muse ask a structured question, answer it in Paperclip, and verify continuation on the same authorized run.
4. Assign follow-up work and verify proactive task reading/commenting from a fresh Muse conversation without an incoming task.
5. Exercise reconnect, cancellation, stale contact and unconfirmed stop through the actual UI.
Preserve source/Runner revisions, exact environment, evidence mode, outcome receipts, interventions and latency. Record private provider behavior only when actually observed. Authenticated transport alone does not prove unseen private tool activity.
## Observed native workflow
On October 10, 2026, the scripted HTTP peer completed two ordinary native Muse
runs against integration revision `01ecffd32ab73d763c05ee0209e64d86b8ebdea6`
with smoke script revision `eb371d06673764fbdde7cc848ccf336bbd547c3a`.
The first run claimed its assignment, saved a document, received a structured
question answer through Paperclip, persisted and consumed the answer, and
finalized through the native Runner. Reopening the task produced one follow-up
run that read the new comment, revised the document and finalized. The browser
showed the answered question, task completion and document revision two.
This was a synthetic authenticated receiver, with no personal Muse model or
managed hook installed. Both runs committed native finalization; cleanup revoked
the test connection and left no uncertain work. It validates the Paperclip
workflow, not the personal Muse service or its five-second polling behavior.
## Bounded 24-hour probe
`scripts/smoke/muse-qualification.ts` is an operator-run receiver reliability probe using normal task APIs. It is not a registered Product E2E campaign and cannot substitute for the real journeys above. Authenticate through `paperclipai auth login`; the probe uses the saved operator credential and never accepts a Muse cookie or credential argument. State and retained evidence should live in a private directory outside Git.
```sh
node cli/node_modules/tsx/dist/cli.mjs scripts/smoke/muse-qualification.ts start \
--state /absolute/private/muse-qualification.json \
--api-base https://your-paperclip-host.example \
--company-id COMPANY_UUID --agent-id AGENT_UUID \
--core-revision EXACT_CORE_SHA --runner-revision EXACT_RUNNER_SHA \
--environment self-hosted --evidence-mode live
node cli/node_modules/tsx/dist/cli.mjs scripts/smoke/muse-qualification.ts check \
--state /absolute/private/muse-qualification.json
```
Start records a fixed server-owned deadline. Run `check` regularly during the experiment (for example every five minutes using the host's supported scheduler). Each eligible hour has one deterministic task identity. Hours six through nine are reserved for sustained idle. A lost creation response retries its original idempotency key; pending work blocks later samples. There are twenty-one planned slots and at least twenty successful unattended samples are required. No direct Muse chat may be used to make a sample pass.
The server's deadline fencing is independent of the watcher. `check` also performs deadline cleanup before task/document reads, so a broken sample cannot prolong the experiment. Use `stop` with the same state path to end early. `report` reads retained evidence without creating work. If a command is killed while holding its local state lock, verify it is no longer running before removing that lock; the server deadline remains authoritative.
Retain the detector's actual registered hook ID and exact script when installing it. After revocation, and after any qualification deadline has passed, remove the detector in Muse and use `hooks.list({ include_disabled: true })` to verify that exact hook ID and script are absent; an ordinary listing can hide a disabled definition. In the agent's Muse connection settings, choose **Record detector removal observation** and enter the retained hook ID, the actual UTC listing time and the SHA-256 of the secret-free retained observation. Paperclip records `operator_runtime_attestation`, not independent provider confirmation, worker quiescence or release of unknown native effects. Never replace the listing time with the submission time or force cleanup flags.
Use `--evidence-mode synthetic` for a scripted receiver. Synthetic evidence can calibrate the checker but cannot qualify personal Muse. The report independently requires full duration, native completion/document receipts, cadence coverage, sustained idle, and resolved remote uncertainty. It reports queue-to-offer, queue-to-claim, native acceptance, accepted result and full finalization latency separately. The requested interval remains five seconds; at least 90% of steady observed intervals must be at most seven seconds. Missing persisted contact evidence or any internal or boundary receiver gap longer than one minute fails qualification. Do not replace this with the prior sixty-second hook.
The public report is an explicit allowlist without task/account/session IDs or credentials. Retain the underlying private evidence for audit. The report's receiver qualification result is separate from its release result; the latter additionally requires the independently verified product journeys. The prior stopped experiment's four replies do not count toward this probe.
Credential-free checker calibration:
```sh
node --test scripts/smoke/muse-qualification-report.test.mjs scripts/smoke/muse-qualification.test.mjs
```
Cloud must deploy the matching exact protocol manifest and preserve tenant authority. Its companion generator verifies parity with `packages/shared/src/muse-protocol.ts`. A connected receiver currently inhibits tenant sleep; unavailable ingress returns a retryable failure and never implies a delivered task.
@@ -1,6 +1,10 @@
# Bring personal Muse onto the shared external Runner
Date: 2026-10-09. Status: implementation plan; the port and live qualification have not started.
Date: 2026-10-09. Status: historical investigation and implementation plan.
Implementation continued on October 10 using the [accepted architecture](2026-10-10-muse-runner-architecture.md)
and `origin/master` at `0e37a385e678d1a568f483c31ae1c19024d2df98`.
The baseline table below records the original investigation. Live qualification
has not started; see the [current compatibility limit](../muse-personal-agent.md#current-compatibility-and-release-gates).
## Recommendation
@@ -151,7 +155,11 @@ Acceptance: hire approval, name/role draft, reload/resume, concurrent setup wind
First prove a real research/document task, a clarification answered in Paperclip, a follow-up assignment, and proactive read/create/comment from a fresh Muse conversation. Then run a new bounded 24-hour qualification with at least 20 unattended samples and sustained idle periods. The previous experiment remains stopped; none of its replies count toward this run.
Use the recovered qualification tooling after adapting it to the new receipts and generation identities. Schedule deterministic sample slots; never enqueue over active or uncertain work. A simulated worker or direct chat intervention cannot make an unattended sample pass. Preserve intervention and outage classifications in secret-free evidence.
Use the recovered qualification tooling after adapting it to the new receipts and generation identities. Schedule deterministic sample slots; never enqueue over active or uncertain work.
Future checker state version 3 retains a terminal failed sample and may collect a distinct later current-hour slot only after exactly one fenced, unclaimed assignment and its matching failed native run are established, with a healthy connection and no live assignment, pending input, uncertainty, or stop hold. It never retries a failed task or catches up a missed hour. A lost task-create response can be retried with its original idempotency key only within that same slot hour. After the hour passes, an uncertain creation without an issue ID remains unresolved and blocks later slots; it is neither dropped nor created late. Version 3 refuses version 2 state; existing campaigns must finish and clean up with their original checker and state. Continuing collection does not make a failed campaign pass: the unchanged grader requires at least 20 successful native samples and success for every persisted sample.
A simulated worker or direct chat intervention cannot make an unattended sample pass. Preserve intervention and outage classifications in secret-free evidence.
Record requested hook interval and actual signal-contact gaps separately. Predeclare the five-second qualification threshold (the earlier harness used at least 90% of steady-state gaps at seven seconds or less); retain a sustained idle window of at least two hours. Separate scheduler queue-to-offer, queue-to-authenticated-claim, claim-to-accepted-result, and queue-to-native-finalization latency. Report counts, tails, missing claims, pending results, and failures, not only averages.
@@ -0,0 +1,450 @@
# Personal Muse production readiness
Date: 2026-10-13. Owner: this integration task. This updates the accepted Muse plan and October 10 architecture against origin/master `833769ab557255b11dd8f650f43ed7333653f5e4`.
## Release contract
| Area | Required outcome / boundary |
| --- | --- |
| Bot and identity | Existing personal Meta Muse at muse.ai; a named company agent with a separately recorded authorizing user. |
| Release targets | Public HTTPS self-hosted instances and Paperclip Cloud using ordinary managed Runner execution. No control-plane execution fallback. |
| Assignment journey | Ordinary assignment wakes Muse, authenticates and claims once, uses its own relevant native tools, publishes an inspectable document, and finalizes through the native Runner. |
| Idle journey | A fresh Muse conversation discovers the installed client, reads permitted company work, creates governed intake without a human placeholder task, and returns useful attributed work. |
| Human setup | Invite → copy current setup instruction → hostname approval in Muse → watched pairing/receiver/authenticated reply milestones → Done. No cookie export or manually copied durable API key. |
| Credentials | Single-use 10-minute pairing feeds a dedicated Muse OAuth authorization. Secure Vault/authd owns public-client DCR, PKCE S256, 15-minute access and rotating refresh with 30-day authenticated inactivity expiry. The worker uses credential surrogates and stores no raw tokens. Public polling never extends credentials. |
| Public receiver | Explicitly approved by the user on October 13. Nonsecret binding UUID selects an opaque wake-presence signal. No task text, global cursor, credentials, or assignment authority. All worker operations remain authenticated. Public contact cannot establish readiness. |
| Provider placement | Muse owns its personal model and tools. One admitted assignment uses the shared external Runner. Idle connection traffic allocates no Runner. Paperclip workspace/shell and third-party gateway tools remain deferred. |
| Delivery and recovery | One active assignment; stable request/event identities; current company/agent/authorizer/run checks; unknown effects require reconciliation; controller restart must preserve original identities. |
| Stops and cleanup | Fence authority immediately; best-effort remote stop with explicit confirmed/unconfirmed evidence; uncertainty blocks automatic overlap/retry. Disconnect disables the receiver and reports unreachable cleanup honestly. |
| Capabilities and costs | Required first-party task/document/questions/collaboration/intake tools. External Muse usage/cost unavailable unless reported; existing budget/approval gates remain. |
| Qualification bound | New 24-hour campaign, at least 20 unattended samples and at least two sustained idle hours. Request five-second hooks; at least 90% measured steady gaps ≤7 seconds and no gap >1 minute. No silent 60-second fallback. |
| Evidence | Separate scripted, actual Muse, local native Runner, managed Linux artifact, and deployed-tenant results. Preserve failed attempts, operator nudges, exact revisions, latency, and cleanup. |
| Rollout | Default off until selected gates pass. Prepare reviewable app, ingress, and Runner artifacts; verify the real staged serving revision before production rollout approval. |
## Delivery sequence and acceptance matrix
| Gate | Existing evidence | Required action / next evidence |
| --- | --- | --- |
| Current master integration | October 10 stacked PRs and two scripted native runs; stale for current source | Reconcile task-workspace/heartbeat extractions, migration numbering, authorizer and skill-test JWT scope; preserve Dot compatibility. |
| Real receiver feasibility | Four old 60-second replies; old experiment stopped. October 10 hooks forbid secrets and accept no script arguments. | Publish a new immutable asset version, install a no-argument secret-free receiver, authenticate a fresh readiness challenge on current Muse. |
| Natural onboarding | Existing invite/settings components and stories; simulated and browser evidence | Fresh invite, expiry/renewal, reload/resume, hiring approval, site permission, current challenge, readiness and Done on actual candidate. |
| Both work directions | Scripted task/document/question/follow-up proof, no actual Muse run | Actual research/document task using Muse tools; Paperclip clarification answer; follow-up; fresh-conversation proactive intake with no queued task. |
| Correctness/recovery | Existing protocol/client/authority/Rust tests and failure evidence | Duplicate/lost responses; competing claims; pause/reassignment/cancel; actual controller restart; credential refresh/replay/revocation; stopped-run authority and cleanup. |
| Cloud/installed artifact | Cloud PR731 exact bounded routing; no deployed Muse proof | Current ingress parity/header tests; normal Linux packaged Runner capability; named synthetic staging tenant through ordinary managed execution and real serving digests. |
| 24-hour reliability | Not run; prior experiment is not reusable qualification | Freeze candidate; bounded unattended monitor with deterministic samples, cadence and queue-to-claim/full-response timings; report incomplete evidence and owned-resource cleanup. |
| Review and release | Previous draft PRs were green/5of5 on old heads | Current targeted checks → required full checks/token gates → current-head CI/review. Keep manageable logical PRs/dependencies. Separate review-ready, qualified, merged, deployed, and canary-verified. |
Next critical-path gate: establish explicit standing delegation in onboarding,
then prove actual autonomous acceptance and completion before the live campaign.
The previous receiver test remains stopped. No October 10 scripted evidence establishes actual Muse execution, first-time onboarding, Cloud deployment, or unattended reliability. Existing credentials and private fixture data are never copied into this plan or public PRs.
## Current provider evidence and architecture correction
October13 actual personal Muse read-only probes reconfirmed five-second secret-free no-argument hooks. Persistent skills live at `~/workspace/skills/paperclip-muse/SKILL.md` and are discovered by `muse.skill_search` from fresh side conversations.
The production credential contract is Secure Vault/authd. The old0600 test credential file is an explicit test exception, not production setup. Muse reports supported `credentials.request_api_access` public-client OAuth2/DCR/PKCE and automatic refresh; a worker obtains only `hsurr` surrogates through the bundled `dynamic_credentials.py` helper, with Sentinel substituting the real access credential during approved egress. There is no supported direct pairing-token injection into Vault.
Decision: reuse the existing OAuth engine with an explicit Muse agent profile and one nullable binding-to-grant link. Keep Muse commands/queries and native authority separate from personal MCP and Dot identity. Preserve Dot refresh behavior. Add only exact OAuth endpoints under the bounded Muse namespace, and use the operator's one-use pairing ticket for exact company/agent/authorizer authorization.
Strict disconnect and explicit OAuth revocation invalidate the grant immediately. Post-revocation authenticated cleanup cannot be promised with the supported single Vault credential. Public410 disables the detector; stopping/cleanup stay unconfirmed without provider observation or exact operator attestation. Run-level fencing while the connection is live still supports authenticated control receipts. No cleanup credential is written to files and revoked OAuth authority is never revived.
Next critical gate is supported authd OAuth bootstrap plus the secret-free receiver on actual Muse. Simulated private-file client tests remain labeled deterministic compatibility evidence only.
## October 13 implementation checkpoint
The fresh candidate uses master `833769ab557255b11dd8f650f43ed7333653f5e4`.
Five additive migrations 339–343 preserve the master journal prefix and the four
previous Muse migration SQL identities. The fifth links an exact OAuth grant.
Master's workspace/instruction preparation stays excluded for external providers.
Local verification: 213 targeted Runner/runtime tests, rebuilt debug Runner
capability metadata, server/UI typechecks, HTTP privacy 79 cases, connection UI,
and token gates passed. Cloud ingress matches 20 exact Core routes; its full suite
passed 3260 tests with 92 intentional skips, plus local QA smoke. These checks are
synthetic/local, not actual Muse or deployed managed-runtime proof.
Review exposed OAuth lock-order and revocation-window races. The candidate now
uses admission/binding locking for OAuth lifecycle, validates credential authority
inside guarded idle writes and native dispatch, and reconciles revoked grants.
OAuth concurrency and regression checks remain in progress. No new hook has been
installed; the previous experiment remains stopped.
The disposable authenticated qualification company is isolated from the primary
instance. The next provider gate is authd Connect, including preservation of the
pairing-ticket query, a surrogate-authenticated read, then an independent receiver
reply. Do not begin the 24-hour clock before the candidate and receiver are frozen.
The operator approved the complete bounded public protocol on October13.
The dedicated `/api/muse/v1` route is now installed on the existing HTTPS8443
service; all unrelated Tailscale configuration was verified unchanged. Muse's
OAuth grant is not yet installed. The final control checks passed51 Muse cases,
101 existing Dot/personal OAuth cases,23 supported-client cases and11 protocol
cases. The rendered expiry journey revealed a missing Reconnect action, now
covered by the actual invitation component regression. UI checks passed27 cases.
## Actual provider checkpoint, October 13 at 17:26 UTC
The current source candidate is `51ad4ba59021d5d281736b4c5d4d541d58036aea`,
following integration commit `f6285c7b5a0a82b5cbba53ea67ab18bc99426eee`.
The later change surfaces Vault Connect in the current Muse conversation before
background installation; it does not alter the immutable receiver asset2 bytes.
The first actual installation attempt created a visible background task but
made no observed installation, credential request, or hook progress. A read-only
operator status inquiry after approximately ten minutes found no active worker,
installed files, or new hook. That attempt is failed/assisted onboarding evidence,
not an unattended success. Its expired one-use ticket was replaced normally.
One subsequent ticket expired while its Connect action was pending; it was also
replaced through the ordinary connection API.
An explicit current-conversation credential request then completed Muse's normal
Vault Connect flow. The user authorized the agent to perform the test connection
consent. Authd preserved the pairing-ticket query and supplied its actual callback,
public-client registration and S256 PKCE. Paperclip observed an active credential
for the intended disposable company and named agent at 17:26 UTC. No credential
was exported from Muse, copied manually, or written by the worker. This establishes
actual supported OAuth bootstrap, not yet a surrogate-authenticated worker read,
background reply, task execution, or unattended first-time setup.
The native smoke fixture also passed two scripted assignments on the integrated
candidate: document publication, structured clarification/answer consumption,
native finalization and follow-up. Its first attempt failed because the Runner
WebSocket resolved to a public hostname exposing only the approved Muse routes.
The fixture was repaired using the existing `PAPERCLIP_API_URL` loopback setting;
no new public Runner route or provider fallback was added. A fresh ordinary run
then passed. These remain local synthetic results, separate from personal Muse
and managed Cloud qualification.
The required full Core test suite is still running. Updated reviewable PR slices
are being reconstructed on the frozen master cutoff. The five-second receiver,
real work in both directions, deployed managed artifact, and new 24-hour campaign
remain release gates. The previous experiment remains stopped.
## Actual provider checkpoint, October 13 at 18:44 UTC
Serving source `d80cca8d7ec2d5e19f8396b6b563acf515f1a48b` publishes immutable
asset3. Core and Cloud match 24 bounded protocol routes. Full workspace typecheck
and build passed on this source. Required full tests have retained failures in
unchanged baseline suites; focused reruns passed where recorded, but the complete
run is not green. Remaining serialized phases are still being executed.
The actual Secure Vault grant completed an authenticated scoped identity read.
Aggregate metadata also proves a rotating refresh and a 900-second maximum
access-token lifetime. Neither raw credentials nor surrogate headers were
exported. Early network reads required normal provider approvals and exceeded
the old 30-second timeout. Asset3 adds a bounded 120-second preflight and an
honest timeout/permission-wait diagnostic. A manual server restart and approved
provider permission actions are recorded setup interventions, not unattended
first-time onboarding evidence. Muse's actual Always allow card covered the
parent domain and subdomains; the setup now warns about the displayed scope.
Muse installed the five-second managed hook, persistent skill, and asset3 client.
A subsequent independent detector wake confirmed the authenticated readiness
challenge at 18:23:47 UTC. Paperclip observed paired, receiver detected, and
background reply verified separately. This establishes actual background
connectivity, not full task execution or proven effective five-second cadence.
The first real research/clarification assignment was queued at 18:30:22.994 UTC
and offered 2.096 seconds later. Muse woke and read the authenticated offer, but
asked for fresh permission to accept. There was no claim, native acceptance,
document, question, or result. The normal deadline fenced the assignment and
failed its native run at 18:40:24.485 UTC. Muse then observed the revocation and
reported no work. No admitted effects or uncertain-operation holds were present.
This is a failed unattended acceptance attempt; it is not a successful sample.
Diagnosis: setup clearly says signals and offers do not grant execution authority
but does not clearly grant standing permission to request native acceptance.
Correct onboarding must authorize autonomous receipt, validation, and acceptance
for the exact company and named agent. Work still requires the native accepted
receipt and current lease; task content cannot expand delegation or override
Muse's own permissions. Publish corrected instructions as asset4 without changing
asset1–3 bytes. Deliver this authorization to the existing test connection as a
recorded onboarding repair, then test a fresh assignment without a chat nudge.
Review also found a resumed-hire retention defect and missing agent-attributed
readiness/cleanup activity history. Fixes and regression checks are in progress.
The task thread must show Waiting for Muse until native acceptance rather than
implying the worker is thinking.
The new 24-hour test has not begun. Actual work in both directions, clarification,
follow-up, effective cadence, managed Linux execution, and deployed tenant proof
remain release gates. The old experiment stays stopped and the provider stays
experimental/default off.
## Asset4 assembly and prequalification repair
Asset4 adds exact company/agent standing delegation, binding-specific persistent
skills and authenticated installed-version reporting. Historical asset1–3 bytes
are unchanged. Muse acknowledged a direct scoped user authorization at 19:14 UTC
and reported no inline mailbox read, claim, or challenge acknowledgement. This is
a recorded onboarding repair, not a successful task or qualification sample.
The final stack also retains resumed invitations after readiness, audits
readiness/cleanup with the named agent and authorizing person, and displays
Waiting for Muse/Dot until native acceptance. Question sets share Runner's
196 KiB serialized UTF-8 bound. The five migration SQL files and original journal
timestamps are preserved, with coherent snapshot predecessors. No live database
reset or direct receipt edit was performed during assembly.
Prequalification receiver measurements over 20 minutes contained 214 contacts:
median gap 5.780 seconds, p90 6.126 seconds, maximum 7.584 seconds; 210 of 213 gaps
were at most seven seconds. This is promising cadence evidence, not the required
24-hour observation or a completed assignment.
The full frozen asset3 test coverage retains failed attempts. Its control file
passed 52 cases. Unchanged bounded reruns passed the invitation-list and native
bootstrap cases where recorded, while one pipelines case remains reproducibly
unresolved. A clean frozen-master baseline is being tested before attributing
that failure. The required final-candidate checks and live journeys remain open.
## Asset4 task and stopping checkpoint, October 13 at 20:11 UTC
Frozen `dc28979f7d5b7788d0598e0ea1f6e49a58381def` passed workspace typecheck
and build, 929 targeted Vitest cases and 29 Python cases. Its independent
asset4 readiness reply took 18.116 seconds. Historical onboarding interventions
remain recorded; this does not establish unattended first-time setup.
A fresh real task was queued at 19:43:28.861 UTC and offered 3.506 seconds later.
Muse claimed it at 19:45:43.230 UTC and received native acceptance 379 ms later,
without a chat nudge. Queue-to-claim was 134.369 seconds. Muse asked the required
structured question; the operator answered Engineering audience through Paperclip
at 19:55:51.903 UTC. The worker read and acknowledged the answer but reported
only the next steps. It never consumed that input, published the required memo,
or finalized. A premature native finish was rejected while input was pending.
This is incomplete prequalification, not a successful delivery sample.
The ordinary task Stop action cancelled the native run and fenced the assignment
at 20:09:18.225 UTC. Muse independently submitted the exact-boundary quiescence
report. Native effects were not uncertain. The connection reports worker_reported;
this does not confirm termination of every private Muse effect. No manual receipt,
claim, completion or operator stop attestation was fabricated.
Asset5 is a source correction, not yet qualified: its generic wake explicitly
loads the paired skill and executes/resumes in-scope admitted work rather than
ending at mailbox inspection. Canonical answers resume the same live assignment;
rejected premature completion does not revoke otherwise-current authority.
Historical assets1–4 retain their exact bytes; credentials, scope, native leases,
provider permissions and approval gates remain unchanged.
Current master cutoff is now `c7f4b74111d16f0b57112fa6d0b6a59c2d63bfe8`.
Master added migrations339–340. The Muse suffix is renumbered341–345 with exact
SQL hashes. A normal-engine rehearsal preserved all old history/grant/binding/
credential values, appended only the two missing master migrations, and matched
a fresh combined schema. No live database reset or receipt edits occurred.
Snapshots incorporate exactly the two new master table changes and retain five
newest snapshots. Normal Drizzle generation reported no extra schema migration.
Review race/replay fixes prevent a late send error from marking a settled receipt
unknown and prevent exact duplicate results from reopening a settled assignment.
A meaningful before/after regression reproduced three failures on old source and
passed all four cases after repair. Broader original Core failures remain retained;
the candidate's required full-check result is not green by inference.
The new 24-hour campaign has not begun. Real completed work, follow-up, proactive
intake, the updated receiver, current-head CI/review, managed Linux and deployed
tenant gates remain open. The old experiment stays stopped and Muse stays off by
default.
## Current-master assembly and asset6 repairs
The test fixture is deliberately offline during assembly. The persistent OAuth
grant, database and bounded HTTPS path remain intact. Asset6 has not yet been
installed in Muse. Its source uses immutable version-specific directories for
all six asset versions, with no mutable historical aliases. The client repairs a
crash between saving verified identity and publishing the persistent paired skill.
A definitively rejected premature finish can progress to a new completion phase
only after canonical input consumption; unknown or accepted effects retain their
original identities and are never blindly repeated.
Current admission rechecks the presented access credential after every lock wait,
before reserving new work. Durable reservations recover under current native and
grant authority without retaining an obsolete bearer across ordinary refresh.
Cancelled answers remain in audit history but do not appear as actionable pending
input. Qualification occupancy ends at recorded native termination and includes
independent stop/effect holds across reconnects; it cannot count unresolved remote
work as idle. Before/after cases reproduced eight product failures and passed the
repaired ten-case subset. All three assembled Muse server suites passed 78 Vitest
cases, including 68 control cases and the wrapper's 30 Python assertions. Server
typecheck and module boundaries passed; original failed attempts remain retained.
Task retry controls now treat waiting_for_provider as active execution, so a prior
failure cannot offer overlapping retry while Muse/Dot awaits acceptance. The full
TaskChatThread suite passed 229 cases. Storybook fingerprint changes are reviewed
against the fixed current-master cutoff; source-only review does not imply live
render or accessibility qualification. Fresh final renders remain a separate gate.
Cloud candidate 14ce9db6da196bbf27f35686475aa0cf8b3ee240 passed typecheck and the full
suite: 3,279 passed and 92 skipped. Its 36 exact protocol routes match Core manifest
71fc2c8550eb52fe4842d0ce4bde021ee0ad35d9eadaa7038316857a79b24b2b.
It depends on a separate conditional cleanup repair for a computer claim committed
after lease cancellation, before provider allocation. The repair preserves started
provider resources and replacement claims; no real provider allocation occurred.
Neither candidate is deployed to a managed Muse tenant.
The last real assignment remains incomplete and its worker_reported stop hold
is not released. Any release requires an honest ordinary exact-boundary operator
attestation. No new assignment or 24-hour campaign begins over that hold. The old
experiment remains stopped.
## Asset6 live failure and current-master repair, October 13 at 22:01 UTC
The frozen asset6 candidate was `7305efe445022205a6440ef8a6caa02460edba9d`.
Its independent authenticated background verification succeeded. A fresh real
research assignment was offered 3.120 seconds after creation and claimed after
103.649 seconds, with native acceptance another 431 milliseconds later. Muse
submitted the structured question. The operator answered through Paperclip.
The worker acknowledged the input notification but the answer helper failed;
there was no canonical consumption, published document, or native completion.
This is failed prequalification, not a reliability sample.
After ordinary Stop and task cancellation, Muse independently reported the exact
worker quiescent. The operator used the existing exact-boundary attestation to
release that hold. Independent private tools were not certified stopped. A
read-only diagnostic after cancellation requested only checkpoint shape fields.
Muse confirmed valid JSON object, with the helper's `inputs` field missing. No
checkpoint contents, credential files, Vault values or surrogate headers were
read. The diagnostic was an operator intervention, not unattended success.
The client and model shared the same continuation pathname. Immutable asset7
separates helper-owned input receipts from model-owned work state, preserving
legacy prepared command identities and failing closed on malformed helper
receipts. This correction and its live retry are still in progress. The fixture
is deliberately offline for source assembly; its database and OAuth grant remain.
The master cutoff has advanced to `edcfcaf3f69abfda12fcbc63225f7346f53450aa`.
New master migrations341–342 require the Muse suffix343–347. SQL identity,
applied history and credential values must be preserved by normal migration
reconciliation. Master's new Storybook annotations and ignored generated outputs
replace the obsolete aggregate catalog/fingerprint workflow.
Frozen7305 passed full workspace typecheck and build. The required local full
test command remains incomplete: owned embedded PostgreSQL initialization hit
host shared-memory limits, and one adapter-utils publication-race assertion
failed in the broader remainder. Both attempts and clean focused reruns are
retained. Source equality and a passing focused master case do not prove those
failures were inherited. No full-green local claim is made.
The frozen Linux Runner compiled and passed installed-package lookup/startup and
its network-disabled Muse native contract. A first twenty-minute packing attempt
was stopped at its bound; a separately bounded pack-only recovery passed using
byte-identical official pack/install/consumer function bodies. This is local
Linux emulation and synthetic protocol evidence, not a deployed managed tenant
or personal Muse task completion. New master contains no Runner/Rust/eval source
changes, to be rechecked on assembly.
Next critical gate: freeze asset7 on the fixed current-master stack and complete
the real research/document/clarification task. Follow-up and fresh-conversation
proactive intake must then pass before starting the new24-hour campaign. The old
experiment remains stopped, Muse remains experimental and disabled by default,
and no Cloud tenant or production rollout is claimed qualified.
## Frozen asset7 verification and next candidate, October 13
The assembled serving candidate634da654bbb11ad5edeef4a726818bb17a81382d
passed normal full workspace typecheck and build. Typecheck first failed because
a newly declared master dependency was absent from inherited installed packages;
a normal cached install restored the declared dependency without source or lock
changes. Both attempts are retained. The normal debug Runner compiled; the
release artifact and serving revision were recorded separately.
The required full test command on634 was stopped after602.360 seconds at its
original ten-minute bound while fixtures were still progressing. PostgreSQL
initialization worked in this attempt. There is no completed aggregate result;
this is an incomplete budget-limited attempt, not the earlier shared-memory
failure. The next frozen candidate requires a fresh normal full command with a
ninety-minute bound and completion of all healthy phases.
The original three-suite Muse check had142 passes and one failed race assertion.
The claim test relied on an arbitrary50-millisecond delay before reassignment
committed. A test-only repair waits for the actual database lock blocked by the
held reassignment transaction, then requires the precise under-lock rejection,
no sent command and an unclaimed assignment. On that isolated repair over634,
all143 cases passed. The original failure is retained.
All20 current Muse Storybook journeys rendered and the CopyAndVerify play passed.
The broader selected normal functional batch was37 of40; original intermittent
and fixture failures remain retained. Accessibility reporters failed24 of40,
including shared sidebar/panel failures also observed on Dot. Visual inspection
does not establish an unpublished Linux visual baseline or provider readiness.
Personal Muse installed pinned immutable asset7 with its existing Vault grant
and five-second hook. The independent background verification was accepted at
22:46:53.253 UTC, about19.839 seconds after the helper recorded its request.
A new ordinary research assignment was offered2.854 seconds after the recorded
queue time, claimed after152.833 seconds and accepted natively another540
milliseconds later. No Muse chat nudge was used. It submitted the structured
audience question. The answer is still pending: automatic approval review
requires the human's specific audience choice despite the disposable-test
authorization. No input consumption, document or completion is claimed yet.
This prelude is not an unattended qualification sample.
The fixed master cutoff advanced to e0f30a4c7186498b71b2637f253b3399997dac7d.
Its new agent-creator migration is preserved. Foundation reconciliation passed
five normal-engine rehearsals with unchanged existing migration/grant/token
records. The four unchanged Muse migrations are0344–0347; actual final OAuth
requires normal generation as0348, retaining its SQL hash and original timestamp.
The live database has not been reset.
Review uncovered two independent compatibility/security defects. Idle Muse
creates/comments must preserve the resolved source-trust policy, and both modern
and legacy native history readers must sanitize quarantined comments for a
higher-trust current run while preserving legitimate low-trust access. Meaningful
before/after cases cover both boundaries. The published client also rejected
65–128 selected options that the canonical question contract accepts. Immutable
asset8 corrects that cap; versions1–7 retain their exact bytes. Its three Muse
suites passed148 outer Vitest tests including53 Python assertions (separate
count). The finite Core/Cloud protocol now has44 method/path pairs; future asset9
is rejected. Full candidate, review and live gates remain pending.
The new24-hour campaign has not started. Real task completion, follow-up and
fresh-conversation proactive activity must pass on the frozen release candidate
before that campaign. The old experiment stays stopped. The provider remains
experimental and disabled by default; no managed Cloud tenant or rollout is
claimed qualified.
### October 13 final review repairs and verification boundary
The fixed upstream baseline remains `e0f30a4c7186498b71b2637f253b3399997dac7d`; the live fixture still serves `634da654bbb11ad5edeef4a726818bb17a81382d` with installed immutable client 7. The live research assignment reached native acceptance and submitted its structured question. It remains pending a canonical human audience choice; two ordinary UI submission attempts were rejected by automatic approval review, so no answer was inserted through another route. This pending human input is not a successful task, delivery failure, or unattended qualification sample.
Fresh review repairs are additive: idle task history now applies the current named agent's trust policy; Muse question admission rejects unsupported request IDs and text validation before any stored native input or runtime request; immutable client 9 uses individually bounded input receipts and migrates bounded historical aggregates without changing prior consume identities or removing original evidence. Published clients 1–8 remain byte-identical. The changed Runner Rust source requires new native artifacts and Linux package qualification; prior binaries do not qualify this candidate.
Cloud routing candidate `a05efb8ad0f88b6d6905b9e953492265a5d9ea0b` admits 48 exact method/path pairs with manifest `98b7acb058c2eb349c6be9ae4b5b48a2f9a4562ceb546d46bd22b05d05cf4f8d`. Normal typecheck, build:test, parity and targeted tenant/protocol tests pass. Complete normal npm test passes: 3,279 passed, zero failures, 92 skipped, 307.656 seconds including test compilation. This is source verification, not a deployed Cloud journey.
A preliminary live-client-7 timestamp window observed 318 contacts / 317 intervals over approximately 30 minutes. Every interval was at most seven seconds (median 5,863 ms, p90 6,111 ms, maximum 6,934 ms); persisted evidence was complete. This supports the faster hook setting only. The new bounded 24-hour campaign has not started, and the previous stopped experiment stays stopped. The complete Core suite remains not started until final source assembly and targeted checks finish.
### October 14 corrected source after actual infrastructure failure
The prior frozen `aa11b95c06f652ecfd940d52208f16dca7b49660` passed normal workspace types (53.182 seconds), build (26.382 seconds), 238 focused outer cases plus64 Python cases, and all20 functional client9 Storybook journeys. Sixteen unchanged accessibility reports remain. The normal full test command started at00:16:22 UTC, then encountered five actual PostgreSQL shared-memory startup failures. It was stopped after22m46s as incomplete infrastructure, with no complete phase aggregate; workspace and159 serialized files were unattempted. Owned test processes and proven stopped data directories were cleaned. No global IPC or system setting changed. This differs from the prior634 ten-minute attempt, which was interrupted while healthy fixtures progressed.
The unchanged Linux offline package helper compiled the new native Runner, then failed because it omitted a declared root optional Copilot dependency. The partial ELF and package are retained as unqualified; no installed native pass is claimed. Foundation repair `1b25de8524f44670100cd699488e4dc689d0573a` includes installed optional roots and rejects invalid present roots, with six collector cases and38 relevant Node checks passing. Rust source remains unchanged.
Feature document/audit tip `93626bf0a23f62c7e5c2fa7325d98a26bdf5a1ad` fixes current-policy document projection and first-change audit attribution. Seven baseline failures became passes; its full101-case Muse suite and16 Python cases passed. The updated Feature `7d8b38ce763c36856f33997ac74482721bdbcea9` merges the packaging foundation and validates expiry, current nonce and unread cursor before readiness wake selection. It retains99 paths over Foundation; Composer `4ee2fe63b353d4a73427a41f8541d5ef58dae3e6` retains exactly3 UI paths. These upstream partitions preserve19 stories; the final OAuth slice preserves the twentieth. Upstream266 tests, types and gates pass.
Final-only `cea76fffe4cc5b4ca20eedd477ae6178f61cc1d1` retains stop control while feature flags are off, only for a presented current credential, current grant/family/person authority and a bounded matching unreleased fence. Ordinary assignments, task content, inputs, readiness and effects stay denied. Expired-access feature-off polls remain quiet. Disconnect and OAuth revocation keep their strict denial and detector-disable behavior; no hidden cleanup credential or post-revocation exception exists. Sixteen focused tests include actual PostgreSQL lock waits followed by token expiry;172 full Muse cases plus64 Python and five selected OAuth/Dot compatibility cases pass. Published assets1–9 and48-route manifest remain identical.
The corrected freeze requires fresh combined tests and the complete normal Linux check/package workflows. These lanes use isolated default shared memory and normal embedded PostgreSQL without helper, database or test overrides. They are prepared only at this commit. The live fixture still serves634/client7 and waits for the canonical audience input; no task result, unattended campaign, deployed Cloud proof or production readiness is claimed.
### October 14: final transport fixtures and provider-specific expiry
The normal isolated Linux package helper completed on `1b7cededade8e8c2389f4dd96d3c5996e2f747b2`: 256 tarballs, an empty-store offline install including Copilot, bundled Runner lookup/startup/exports/evals, and an actual installed Muse native invalid-question, answer-consumption, recovery, and canonical-completion sequence. A separate stronger cleanup observation proved the owned child exited; its original immediate cleanup observation remains preserved. This is a package proof for that source, not a deployed Cloud or personal Muse qualification.
The isolated Linux focused suite then found three real Final test failures: merged legacy signal-credential fixtures could not authenticate through the retired lane. `150a6bbadfe544b8cdfb82957e636eca4a628933` adapts those three cases to the public binding-ID receiver and opaque signal references. It preserves all expiry, current nonce, unread cursor, stability, first-change audit, and privacy assertions. The original 180-pass/3-fail evidence is retained; PostgreSQL treatment must pass on the final assembly. The complete normal Core command has not started on this assembly.
The actual personal Muse research run expired after 120 minutes while its clarification remained unanswered. It produced no document, accepted result, or finalization. After an explicit post-failure cleanup check, Muse reported the exact worker stopped; the ordinary operator UI attestation released only that worker hold. These interventions do not count as unattended completion. No canonical audience answer has been received, and the clarification gate remains unverified.
The failure also exposed Dot wording in the shared Muse expiry event. Foundation `d8bf622e09f7bcc8c3d9b021ea57a625ff1dcd3e` changes only the three assignment-error sites through the existing provider codec. Muse receives its own name and `muse_assignment_expired`; Dot keeps its exact prior code and text. The new native baseline had two passing Dot cases and two failing Muse cases; all 18 shared-backend cases then passed in both debug and release. This Rust change requires a new package proof and proper debug build before the complete normal Core suite. The immutable client assets, DB migrations, UI source, shared contracts, and 48-route Cloud manifest stay unchanged.
## Detector teardown evidence after the deadline
OAuth revocation remains immediate and final. After the full qualification deadline, an authorized Board operator can record an exact detector-removal observation in the disconnected agent connection section. Retain the actual registered hook ID and generated binding-specific script from installation. Remove that hook through Muse’s supported runtime, then inspect `hooks.list({include_disabled:true})`: the exact ID must be absent, including disabled definitions. A disabled hook, omitted disabled entries, silence, or a removal request is insufficient.
The Board confirmation records the retained hook ID, exact server-derived script, a SHA-256 digest of the secret-free listing observation, the observation time, and the actual operator. Do not paste raw listings, credentials, URLs, or private tool output. The server checks current Board change authority, latest binding/generation/revision and exact qualification/deadline, then atomically records flags and audit provenance. It does not call Muse, verify installation independently, stop private work, clear unknown native effects, or revive revoked credentials. Refresh is required after offline or changed connection evidence.
Grader revision `paperclip.muse-qualification.v4` distinguishes authenticated worker reports from operator attestations based on a supported runtime observation. Both are reported evidence, not independent provider confirmation. Exact source provenance and post-deadline timing are required; the 24-hour duration, five-second cadence, sustained idle window, at least twenty samples, every-sample success and all stop/effect uncertainty requirements remain unchanged. Earlier report revisions and sealed campaign results remain historical.
+68
View File
@@ -0,0 +1,68 @@
import assert from "node:assert/strict";
import { existsSync, readFileSync } from "node:fs";
import path from "node:path";
import { test } from "node:test";
import { fileURLToPath } from "node:url";
import { catalogFromStories, renderIndex, scanStories, validateCatalog } from "./storybook-catalog.mjs";
import { featureIndex } from "./feature-map.mjs";
const root = fileURLToPath(new URL("../", import.meta.url));
const file = "ui/storybook/stories/agents/external-agent-invite/muse.stories.tsx";
const stableId = "onboarding-external-agent-invitation-muse-personal-agent";
const index = featureIndex(root);
const stories = scanStories(root);
const catalog = catalogFromStories(stories, index);
const muse = stories.find(story => story.file === file);
// These IDs predate the move; existing links must survive the feature title change.
test("real Muse suite preserves all existing journey IDs and resolves relocated imports", () => {
assert.ok(muse, "Muse suite must be discovered in its agent feature home");
assert.equal(muse.metaId, stableId);
assert.deepEqual(muse.ids, [
"start-here", "name-and-role", "copy-setup", "paired", "receiver-detected",
"finishing-setup", "ready", "approval", "loading-invitation",
"authenticated-instance-required", "missing-public-host", "no-recent-response",
"replaced-prompt", "mobile", "mobile-details", "light", "copy-and-verify",
"settings", "stop-boundary", "expired-connection",
].map(suffix => `${stableId}--${suffix}`));
assert.equal(muse.plays, 1);
assert.equal(existsSync(path.join(root, "ui/storybook/stories/external-agent-invite/muse.stories.tsx")), false);
const source = readFileSync(path.join(root, file), "utf8");
for (const [, relative] of source.matchAll(/from "(\.[^"]+)"/g)) {
assert.ok(existsSync(path.resolve(root, path.dirname(file), `${relative}.tsx`))
|| existsSync(path.resolve(root, path.dirname(file), `${relative}.ts`)), `Unresolved moved import: ${relative}`);
}
});
test("Muse registration validates actual production subjects with honest fixture limits", () => {
const entries = catalog.entries.filter(entry => entry.file === file);
assert.equal(entries.length, 1);
const entry = entries[0];
assert.equal(entry.stableId, stableId);
assert.equal(entry.sidebarTitle, muse.title);
assert.deepEqual(entry.capabilities, ["agents/hire", "agents/lifecycle", "access/invitations"]);
assert.equal(entry.status, "unreviewed");
assert.equal(entry.implementation, "production");
assert.equal(entry.boundary, "fixtures");
const source = readFileSync(path.join(root, file), "utf8");
assert.ok(source.includes("component: ExternalAgentInvitePreview"));
for (const subject of [
"../../../support/external-agent-invite/ExternalAgentInvitePreview",
"@/components/MuseRunnerConnection",
"../../../support/external-agent-invite/muse-fixtures",
]) assert.ok(source.includes(`from "${subject}"`), `Missing rendered subject or fixture import: ${subject}`);
assert.match(entry.notes, /simulated API state/);
assert.match(entry.notes, /Does not establish live provider/);
const errors = validateCatalog(catalog, stories, index);
// Other suites retain their outstanding reviews; this assertion covers Muse only.
assert.deepEqual(errors.filter(error => error.startsWith(file)), []);
assert.ok(!errors.includes("Duplicate story IDs"));
});
test("generated catalog navigation links the one Muse suite from every registered capability", () => {
const generated = renderIndex(catalog, stories, index);
for (const capability of ["agents/hire", "agents/lifecycle", "access/invitations"]) {
const line = generated.split("\n").find(text => text.startsWith(`- \`${capability}\`:`));
assert.ok(line?.includes(`../../${file}`), `Missing Muse lookup: ${capability}`);
}
});
@@ -226,6 +226,15 @@ describe("Paperclip Runner Codex configuration", () => {
expect(dot).toContain("Dot connection");
expect(dot).toContain("Allow externally billed provider");
});
it("uses the shared Muse connection and excludes local harness, workspace, and lifecycle fields", async () => {
const muse = await renderRunner({ provider: "muse" });
expect(muse).toContain("Muse connection");
expect(muse).toContain("Allow externally billed provider");
expect(muse).not.toContain('aria-label="Harness"');
expect(muse).not.toContain("Runner lifecycle");
expect(muse).not.toContain("Workspace files and commands");
expect(muse).not.toContain("Capture raw provider traces");
});
it("keeps the general harness picker hidden for an existing Grok Bot", async () => {
const html = await renderRunner({ provider: "grok_bot" });
expect(html).not.toContain('aria-label="Harness"');
+15 -5
View File
@@ -1,5 +1,6 @@
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue, SelectPopover } from "../../components/ui/select";
import { AdapterMark } from "../../components/AdapterMark";
import { MuseRunnerConnection } from "../../components/MuseRunnerConnection";
import { GrokBotConnection } from "../../components/GrokBotConnection";
import { DotRunnerConnection } from "../../components/DotRunnerConnection";
import { configFieldsForSection } from "../config-sections";
@@ -99,6 +100,8 @@ export function CodexLocalConfigFields({
isPaperclipRunnerProvider(runnerProvider)
? PAPERCLIP_RUNNER_PERMISSION_CAPABILITIES[runnerProvider]
: { configurable: false, defaultMode: "provider-managed", options: [], description: "Unknown provider configuration is preserved." };
const isMuse = runnerProvider === "muse";
const isExternal = isMuse || runnerProvider === "openai_dot" || runnerProvider === "grok_bot";
const configuredRunnerPermissionMode =
runnerManaged && runnerPermissionCapability.configurable
? isCreate
@@ -224,7 +227,7 @@ export function CodexLocalConfigFields({
/>
</Field>
)}
{runnerManaged && !hideRunnerHarness && runnerProvider !== "openai_dot" && runnerProvider !== "grok_bot" && (
{runnerManaged && !hideRunnerHarness && !isExternal && (
<Field configSection="adapter"
label="Harness"
hint="Choose the agent harness that runs your tasks."
@@ -299,6 +302,13 @@ export function CodexLocalConfigFields({
<ToggleField label="Allow externally billed provider" hint="Dot does not report token usage or cost. Paperclip cannot enforce a provider spend ceiling; known company and agent budget limits still apply."
checked={runnerSchemaValue("allowUnmeteredProvider", false) === true} onChange={value => updateRunnerSchemaValue("allowUnmeteredProvider", value)} />
</>}
{runnerManaged && isMuse && <>
<Field configSection="adapter" label="Muse connection" hint="Pairing, receiver contact, background reply, and agent setup are verified separately.">
<MuseRunnerConnection companyId={companyId} agentId={agentId} />
</Field>
<ToggleField label="Allow externally billed provider" hint="Muse does not report usage or cost. Paperclip cannot enforce a Muse spend ceiling; known company and agent budget limits still apply."
checked={runnerSchemaValue("allowUnmeteredProvider", false) === true} onChange={value => updateRunnerSchemaValue("allowUnmeteredProvider", value)} />
</>}
{runnerManaged && runnerProvider === "acpx" && runnerSchemaValue("acpxAgent", "claude") !== "grok" && (
<Field configSection="adapter" label="ACP agent" hint="Cursor uses Paperclip questions; per-run cost is unavailable. Pi accepts any provider/model ID. GitHub Copilot awaits qualification.">
<Select
@@ -481,7 +491,7 @@ export function CodexLocalConfigFields({
/>
</>
)}
{runnerManaged && runnerPermissionCapability.configurable && (runnerPermissionCapability.options.length > 1 || runnerPermissionModeUnsupported) && (
{runnerManaged && !isExternal && runnerPermissionCapability.configurable && (runnerPermissionCapability.options.length > 1 || runnerPermissionModeUnsupported) && (
<Field
label="Permission mode"
hint={`${runnerPermissionCapability.description} The selected mode does not widen Paperclip's workspace, network, credential, or planning boundaries.`}
@@ -541,7 +551,7 @@ export function CodexLocalConfigFields({
)}
</Field>
)}
{runnerManaged && runnerProvider !== "openai_dot" && runnerProvider !== "grok_bot" && (
{runnerManaged && !isExternal && (
<Field configSection="runPolicy"
label="Runner lifecycle"
hint="Turn by turn suspends after each run. Warm keeps the same provider process available between governed runs."
@@ -562,7 +572,7 @@ export function CodexLocalConfigFields({
/>
</Field>
)}
{runnerManaged && runnerProvider !== "openai_dot" && runnerProvider !== "grok_bot" && runnerLifecycleMode === "warm" && (
{runnerManaged && !isExternal && runnerLifecycleMode === "warm" && (
<Field configSection="runPolicy"
label="Warm idle timeout (ms)"
hint="After this much inactivity, runnerd checkpoints and suspends the provider session. The maximum is 24 hours."
@@ -824,7 +834,7 @@ export function CodexLocalConfigFields({
)}
</>
)}
{runnerProvider !== "openai_dot" && runnerProvider !== "grok_bot" && <LocalWorkspaceRuntimeFields
{!isExternal && <LocalWorkspaceRuntimeFields
isCreate={isCreate}
values={values}
set={set}
+2
View File
@@ -53,6 +53,8 @@ export interface AdapterConfigFieldsProps {
managedSandboxOnly?: boolean;
/** Show Dot for new selections only when its experimental prerequisites are enabled. */
openAiDotEnabled?: boolean;
/** Personal Muse is offered only with its own flag and the native Runner enabled. */
museEnabled?: boolean;
hideRunnerHarness?: boolean;
}
+20
View File
@@ -5,6 +5,7 @@ import {
} from "@/lib/tenant-session-recovery";
import { api, detachInflightGet } from "./client";
import { toolsApi } from "./tools";
import { museInvitationsApi } from "./museInvitations";
interface Deferred<T> {
promise: Promise<T>;
@@ -237,3 +238,22 @@ describe("managed-account request isolation", () => {
expect(await previousUser).toEqual({ apps: ["previous-user-account"] });
});
});
describe("Muse operator request isolation", () => {
it.each([
["invitation resume", () => museInvitationsApi.resume("company")],
["connection evidence", () => museInvitationsApi.connection("company", "agent")],
] as const)("does not join the previous operator's %s request", async (_name, read) => {
const previous = deferred<Response>();
const current = deferred<Response>();
fetchMock.mockReturnValueOnce(previous.promise).mockReturnValueOnce(current.promise);
const previousOperator = read();
const currentOperator = read();
expect(fetchMock).toHaveBeenCalledTimes(2);
expect(fetchMock.mock.calls.every(([, options]) => options.cache === "no-store")).toBe(true);
current.resolve(jsonResponse({ operator: "current" }));
previous.resolve(jsonResponse({ operator: "previous" }));
expect(await currentOperator).toEqual({ operator: "current" });
expect(await previousOperator).toEqual({ operator: "previous" });
});
});
+23
View File
@@ -0,0 +1,23 @@
import { api, detachInflightGet } from "./client";
import type {
MuseInvitation, MuseInvitationInput, MuseConnection, MusePairing, MusePairingInput,
MuseVerifyInput, MuseVerifyResult, MuseRevokeInput, MuseDetectorCleanupAttestationInput, MuseAttestStopInput,
} from "@paperclipai/shared";
const path = (companyId: string, agentId: string) => `/companies/${companyId}/agents/${agentId}/muse-binding`;
function accountScopedRead<T>(path: string, signal?: AbortSignal) {
// Query keys carry the operator. Path-only GET coalescing must not join a
// request issued under the previous account; React Query handles same-key reads.
detachInflightGet(path);
return api.get<T>(path, { signal, cache: "no-store" });
}
export const museInvitationsApi = {
resume: (companyId: string, signal?: AbortSignal) => accountScopedRead<MuseInvitation | null>(`/companies/${companyId}/muse-invitations`, signal),
create: (companyId: string, input: MuseInvitationInput) => api.post<MuseInvitation>(`/companies/${companyId}/muse-invitations`, input),
connection: (companyId: string, agentId: string, signal?: AbortSignal) => accountScopedRead<MuseConnection>(path(companyId, agentId), signal),
pair: (companyId: string, agentId: string, input: MusePairingInput) => api.post<MusePairing>(path(companyId, agentId), input),
verify: (companyId: string, agentId: string, input: MuseVerifyInput) => api.post<MuseVerifyResult>(path(companyId, agentId) + "/verify", input),
revoke: (companyId: string, agentId: string, input: MuseRevokeInput) => api.post<void>(path(companyId, agentId) + "/revoke", input),
attestDetectorCleanup: (companyId: string, agentId: string, input: MuseDetectorCleanupAttestationInput) => api.post<void>(path(companyId, agentId) + "/attest-detector-cleanup", input),
attestStop: (companyId: string, agentId: string, input: MuseAttestStopInput) => api.post<void>(path(companyId, agentId) + "/attest-stop", input),
};
+25 -17
View File
@@ -632,11 +632,12 @@ export function AgentConfigForm(props: AgentConfigFormProps) {
: undefined;
const isDotRunner = runnerProvider === "openai_dot";
const isGrokBotRunner = runnerProvider === "grok_bot";
const isExternalBotRunner = isDotRunner || isGrokBotRunner;
const isMuseRunner = runnerProvider === "muse";
const isExternalRunner = isDotRunner || isGrokBotRunner || isMuseRunner;
const showAdapterTestEnvironmentButton = !isGrokBotRunner && (props.showAdapterTestEnvironmentButton ?? true);
const showInlineAdapterTestEnvironmentButton =
showAdapterTestEnvironmentButton && !props.onTestActionChange && !props.compactTestFeedback;
const isLocal = !isExternalBotRunner && (adapterCaps.supportsInstructionsBundle || adapterCaps.supportsSkills || adapterCaps.supportsLocalAgentJwt);
const isLocal = !isExternalRunner && (adapterCaps.supportsInstructionsBundle || adapterCaps.supportsSkills || adapterCaps.supportsLocalAgentJwt);
// The legacy working directory is an absolute path on the host, so the
// managed-sandbox-only policy hides it. A stored value stays untouched; it is
@@ -918,7 +919,7 @@ export function AgentConfigForm(props: AgentConfigFormProps) {
const modelProvider = adapterType === "opencode_local" && aiConnectionBindingSchema.safeParse(
(overlay.runtime.runtimeConfig as Record<string, unknown> | undefined)?.aiConnection ?? runtimeConfig.aiConnection,
).data?.provider === "openrouter" ? "openrouter" : runnerProvider;
const connectionModels = useConnectionModels(isExternalBotRunner ? null : selectedCompanyId, isCreate ? undefined : aiConnectionBindingSchema.safeParse((overlay.runtime.runtimeConfig as Record<string, unknown> | undefined)?.aiConnection ?? runtimeConfig.aiConnection).data, aiRoutingHarness(adapterType, runnerProvider, eff("adapterConfig", "acpxAgent", config.acpxAgent)));
const connectionModels = useConnectionModels(isExternalRunner ? null : selectedCompanyId, isCreate ? undefined : aiConnectionBindingSchema.safeParse((overlay.runtime.runtimeConfig as Record<string, unknown> | undefined)?.aiConnection ?? runtimeConfig.aiConnection).data, aiRoutingHarness(adapterType, runnerProvider, eff("adapterConfig", "acpxAgent", config.acpxAgent)));
// Fetch adapter models for the effective provider, including unsaved changes.
const modelAdapterType = isCodexHarness ? harnessType : adapterType;
const modelQueryKey = selectedCompanyId
@@ -934,7 +935,7 @@ export function AgentConfigForm(props: AgentConfigFormProps) {
environmentId: currentDefaultEnvironmentId || null,
provider: modelProvider,
}),
enabled: Boolean(selectedCompanyId) && !isExternalBotRunner && !connectionModels,
enabled: Boolean(selectedCompanyId) && !isExternalRunner && !connectionModels,
});
const [refreshModelsError, setRefreshModelsError] = useState<string | null>(null);
const [refreshingModels, setRefreshingModels] = useState(false);
@@ -985,9 +986,10 @@ export function AgentConfigForm(props: AgentConfigFormProps) {
// Resolve the effective instructions-file gate once. The instructions file
// is an absolute host path, so the managed-sandbox-only policy hides it for
// every adapter without a per-adapter edit.
hideInstructionsFile: hideInstructionsFile || hideHostPaths || isExternalBotRunner,
managedSandboxOnly: hideHostPaths || isExternalBotRunner,
hideInstructionsFile: hideInstructionsFile || hideHostPaths || isExternalRunner,
managedSandboxOnly: hideHostPaths || isExternalRunner,
openAiDotEnabled: experimentalSettings?.enableOpenAiDot === true,
museEnabled: experimentalSettings?.enableMuse === true && experimentalSettings?.enableNativeRunner === true,
};
// Section toggle state — advanced always starts collapsed
@@ -1638,13 +1640,16 @@ export function AgentConfigForm(props: AgentConfigFormProps) {
{showAdapterTypeField && !isGrokBotRunner && (
<Field label="Harness" hint={help.adapterType}>
<AdapterTypeDropdown
value={isDotRunner ? "openai_dot" : isCodexHarness ? harnessType : adapterType}
value={isDotRunner ? "openai_dot" : isMuseRunner ? "muse" : isCodexHarness ? harnessType : adapterType}
disabledTypes={adapterPickerDisabledTypes}
openAiDotEnabled={experimentalSettings?.enableOpenAiDot === true}
nativeRunnerEnabled={experimentalSettings?.enableNativeRunner === true}
museEnabled={experimentalSettings?.enableMuse === true && experimentalSettings?.enableNativeRunner === true}
onChange={(choice) => {
const dot = choice === "openai_dot";
const t = dot ? "paperclip_runner" : choice;
const muse = choice === "muse";
const external = dot || muse;
const t = external ? "paperclip_runner" : choice;
if (isCreate) {
// Reset all adapter-specific fields to defaults when switching adapter type
const { adapterType: _at, ...defaults } = defaultCreateValues;
@@ -1663,9 +1668,9 @@ export function AgentConfigForm(props: AgentConfigFormProps) {
} else if (t === "paperclip_runner") {
nextValues.model = DEFAULT_CODEX_LOCAL_MODEL;
}
if (dot) {
if (external) {
nextValues.model = "";
nextValues.adapterSchemaValues = { provider: "openai_dot" };
nextValues.adapterSchemaValues = { provider: muse ? "muse" : "openai_dot" };
}
set!(nextValues);
} else {
@@ -1685,7 +1690,7 @@ export function AgentConfigForm(props: AgentConfigFormProps) {
? DEFAULT_OPENCODE_LOCAL_MODEL
: t === "cursor"
? DEFAULT_CURSOR_LOCAL_MODEL
: dot ? ""
: external ? ""
: t === "paperclip_runner"
? resolvePaperclipRunnerTransitionModel(adapterType, config.model)
: "",
@@ -1693,8 +1698,8 @@ export function AgentConfigForm(props: AgentConfigFormProps) {
modelReasoningEffort: "",
variant: "",
mode: "",
...(dot
? { provider: "openai_dot", lifecycleMode: "per_turn", allowUnmeteredProvider: false, dotAttachmentAccess: false, dotWorkspaceAccess: false }
...(external
? { provider: muse ? "muse" : "openai_dot", lifecycleMode: "per_turn", allowUnmeteredProvider: false, ...(dot ? { dotAttachmentAccess: false, dotWorkspaceAccess: false } : {}) }
: t === "paperclip_runner"
? {
...paperclipRunnerTransitionConfig(adapterType, eff("adapterConfig", "model", config.model)),
@@ -1708,7 +1713,7 @@ export function AgentConfigForm(props: AgentConfigFormProps) {
</Field>
)}
{!isExternalBotRunner && !isCreate && selectedCompanyId && <AiConnectionField companyId={selectedCompanyId} agentId={props.agent.id} agentName={props.agent.name} adapterType={aiRoutingHarness(adapterType, eff("adapterConfig", "provider", config.provider), eff("adapterConfig", "acpxAgent", config.acpxAgent))}
{!isExternalRunner && !isCreate && selectedCompanyId && <AiConnectionField companyId={selectedCompanyId} agentId={props.agent.id} agentName={props.agent.name} adapterType={aiRoutingHarness(adapterType, eff("adapterConfig", "provider", config.provider), eff("adapterConfig", "acpxAgent", config.acpxAgent))}
routerAdapterType={adapterType} value={aiRuntimeConnectionBindingSchema.safeParse((overlay.runtime.runtimeConfig as Record<string, unknown> | undefined)?.aiConnection ?? runtimeConfig.aiConnection).data}
model={String(eff("adapterConfig", "model", config.model) ?? "")} environmentId={currentDefaultEnvironmentId || undefined} legacy
onChange={binding => mark("runtime", "runtimeConfig", { ...runtimeConfig, aiConnection: binding })} />}
@@ -2158,7 +2163,7 @@ export function AgentConfigForm(props: AgentConfigFormProps) {
) : null}
{/* ---- Debugging ---- */}
{!isCreate && canConfigureProviderTrace ? (
{!isExternalRunner && !isCreate && canConfigureProviderTrace ? (
<div className={cn(!cards && "border-b border-border")}>
{cards ? (
<h3 className="mb-3 flex items-center gap-2 text-sm font-medium">
@@ -2199,7 +2204,7 @@ export function AgentConfigForm(props: AgentConfigFormProps) {
{props.compactTestFeedback && showInlineAdapterTestEnvironmentFeedback && showAdapterTestEnvironmentButton && (
<RuntimeTestCard
variant={isDotRunner ? "prerequisites" : "connection"}
variant={isExternalRunner ? "prerequisites" : "connection"}
state={testActionPending ? "running" : testActionError || testEnvironment.error ? "fail" : testResult?.status ?? "idle"}
result={testResult ?? null}
error={testActionError ?? (testEnvironment.error instanceof Error ? testEnvironment.error.message : null)}
@@ -3710,21 +3715,24 @@ export function AdapterTypeDropdown({
onChange,
disabledTypes,
openAiDotEnabled = false,
museEnabled = false,
nativeRunnerEnabled = false,
}: {
value: string;
onChange: (type: string) => void;
disabledTypes: Set<string>;
openAiDotEnabled?: boolean;
museEnabled?: boolean;
nativeRunnerEnabled?: boolean;
}) {
const selectedDisplay = getAdapterDisplay(value);
const adapterList = useMemo(() =>
[...listAdapterOptions(type => adapterLabels[type] ?? getAdapterLabel(type)),
...(openAiDotEnabled ? [{ value: "openai_dot", label: "OpenAI Dot", experimental: true, comingSoon: false }] : []),
...(museEnabled ? [{ value: "muse", label: "Muse — Personal agent", experimental: true, comingSoon: false }] : []),
].filter(item => (item.value !== "paperclip_runner" || nativeRunnerEnabled) && !disabledTypes.has(item.value))
.sort((a, b) => Number(a.value === "paperclip_runner") - Number(b.value === "paperclip_runner")),
[disabledTypes, openAiDotEnabled, nativeRunnerEnabled]);
[disabledTypes, openAiDotEnabled, nativeRunnerEnabled, museEnabled]);
return <SelectPopover aria-label="Harness" value={value} onValueChange={onChange}
displayValue={<span className="inline-flex min-w-0 items-center gap-1.5">
<span aria-hidden="true" className="inline-flex shrink-0"><AdapterMark type={value} className="size-4" /></span>
+5 -5
View File
@@ -1,4 +1,4 @@
import { useEffect, useId, useLayoutEffect, useRef, useState, type Ref } from "react";
import { useEffect, useId, useLayoutEffect, useRef, useState, type Ref, type ReactNode } from "react";
import { createPortal } from "react-dom";
import { Check, Copy, Terminal, X } from "lucide-react";
import { Button } from "@/components/ui/button";
@@ -23,10 +23,10 @@ export interface AgentSetupPromptProps {
initialCopyStatus?: "idle" | "copied" | "failed";
onCopied?: () => void;
/** Brand a handoff for one recipient; omit for the general agent logo stack. */
agent?: { name: string; src: string; className?: string };
agent?: { name: string; className?: string } & ({ src: string; icon?: never } | { icon: ReactNode; src?: never });
}
const defaultAgents = [
const defaultAgents: NonNullable<AgentSetupPromptProps["agent"]>[] = [
{ name: "Codex", src: "/brands/codex-color.svg", className: "" },
{ name: "ChatGPT", src: "/brands/apps/openai.svg", className: "dark:invert" },
{ name: "Claude", src: "/brands/claude-color.svg", className: "" },
@@ -38,7 +38,7 @@ function AgentLogos({ ref, hidden, agents }: { ref: Ref<HTMLSpanElement>; hidden
{agents.map((agent) => (
<span key={agent.name} className="agent-setup-logo-slot">
<span className="agent-setup-logo">
<img src={agent.src} alt="" className={cn("size-5", agent.className)} draggable={false} />
{agent.src ? <img src={agent.src} alt="" className={cn("size-5", agent.className)} draggable={false} /> : agent.icon}
</span>
</span>
))}
@@ -275,7 +275,7 @@ export function AgentSetupPrompt({
<span ref={flyingLogos} className="agent-setup-flight-layer" aria-hidden="true">
{agents.map((agent, index) => (
<span key={agent.name} className="agent-setup-logo agent-setup-flying-logo" style={flight.origins[index]}>
<img src={agent.src} alt="" className={cn("size-5", agent.className)} draggable={false} />
{agent.src ? <img src={agent.src} alt="" className={cn("size-5", agent.className)} draggable={false} /> : agent.icon}
</span>
))}
</span>,
@@ -0,0 +1,331 @@
// @vitest-environment jsdom
import { act } from "react";
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
import { createRoot, type Root } from "react-dom/client";
import { afterEach, beforeEach, expect, it, vi } from "vitest";
import { MuseConnectionDetails, MuseRunnerConnection } from "./MuseRunnerConnection";
import { MuseConnectionChecks } from "./new-agent/ExternalAgentInviteContent";
import { museConnectionState } from "@/hooks/useMuseConnection";
import { museConnection, stoppedMuseConnection, museStopBoundary, musePairing } from "../../storybook/support/external-agent-invite/muse-fixtures";
import { museInvitationsApi } from "@/api/museInvitations";
import { MUSE_CLIENT_VERSION, type MuseConnection } from "@paperclipai/shared";
vi.mock("@/api/companies-query", () => ({ useAccountIdentity: () => ({ userId: "operator", settled: true, failed: false }) }));
Object.assign(globalThis, { IS_REACT_ACT_ENVIRONMENT: true });
let container: HTMLDivElement; let root: Root; let cache: QueryClient | null;
const handlers = { onTest: vi.fn(), onRepair: vi.fn(), onPause: vi.fn(), onDisconnect: vi.fn(), onRefresh: vi.fn(), onAttest: vi.fn(), onAttestDetectorCleanup: vi.fn() };
beforeEach(() => { container = document.createElement("div"); document.body.append(container); root = createRoot(container); cache = null; vi.clearAllMocks(); });
afterEach(async () => { await act(async () => root.unmount()); cache?.clear(); vi.restoreAllMocks(); container.remove(); vi.useRealTimers(); });
const button = (name: string) => [...container.querySelectorAll("button")].find(b => b.textContent === name)!;
async function render(connection = museConnection) { await act(async () => root.render(<MuseConnectionDetails connection={connection} {...handlers} />)); }
it("does not treat receiver contact as worker verification or lifecycle readiness", async () => {
const state = museConnectionState(museConnection);
expect(state.ready).toBe(false);
await act(async () => root.render(<MuseConnectionChecks state={state} />));
expect(container.textContent).toContain("Receiver detected: complete");
expect(container.textContent).toContain("Background reply verified: waiting");
expect(container.textContent).not.toContain("Muse is ready for tasks");
const checksPassed: MuseConnection = { ...museConnection, binding: { ...museConnection.binding!, status: "ready", backgroundReplyVerified: true } };
expect(museConnectionState(checksPassed).ready).toBe(false);
expect(museConnectionState({ ...checksPassed, agentLifecycleState: "ready", agentStatus: "pending_approval" }).ready).toBe(false);
expect(museConnectionState({ ...checksPassed, agentLifecycleState: "ready" }).ready).toBe(true);
});
it("shows contact lag, exact last verified reply, version, and unavailable costs", async () => {
await render();
expect(container.textContent).toContain("Last persisted public receiver contact");
expect(container.textContent).toContain("Authenticated worker activity");
expect(container.textContent).toContain("Last verified replyNot observed");
expect(container.textContent).toContain(`Client version${MUSE_CLIENT_VERSION}`);
expect(container.textContent).toContain("may lag by up to 30 seconds");
expect(container.textContent).toContain("Usage and costUnavailable");
expect(container.textContent).not.toContain("$0");
});
it("retains cleanup and precise worker attestation while disabled without resolving unknown effects", async () => {
await render(stoppedMuseConnection);
expect(container.textContent).toContain("Detector removal: not confirmed. Worker quiescence: not confirmed");
expect(container.textContent).toContain("Cleanup cannot be confirmed through the revoked connection");
expect(container.textContent).not.toContain("Cleanup capability expires");
expect(container.textContent).toContain("Native effect outcomes remain unknown");
await act(async () => button("Attest this worker stopped").click());
expect(container.textContent).toContain(museStopBoundary.runId);
expect(button("Confirm worker stopped").disabled).toBe(true);
await act(async () => container.querySelector<HTMLInputElement>('input[type="checkbox"]')!.click());
await act(async () => button("Confirm worker stopped").click());
expect(handlers.onAttest).toHaveBeenCalledWith({ boundary: museStopBoundary, expectedRevision: stoppedMuseConnection.binding!.stop.bindingRevision, workerStopped: true });
expect(container.textContent).toContain("does not resolve or replay those effects");
expect(button("Connect Muse").disabled).toBe(true);
});
it.each(["boundary", "revision"])("withdraws an attestation when the server %s changes", async change => {
await render(stoppedMuseConnection);
await act(async () => button("Attest this worker stopped").click());
await act(async () => container.querySelector<HTMLInputElement>('input[type="checkbox"]')!.click());
const stop = { ...stoppedMuseConnection.binding!.stop, ...(change === "revision" ? { bindingRevision: 8 }
: { boundary: { ...museStopBoundary, stopNonce: "66666666-6666-4666-8666-666666666666" } }) };
await render({ ...stoppedMuseConnection, binding: { ...stoppedMuseConnection.binding!, stop } });
expect(container.querySelector('[aria-label="Attest this Muse worker stopped"]')).toBeNull();
expect(handlers.onAttest).not.toHaveBeenCalled();
});
it("uses no-recent-response wording for an expired independent background test", async () => {
await render({ ...museConnection, binding: { ...museConnection.binding!, challengeExpiresAt: "2026-01-01T00:00:00Z" } });
expect(container.textContent).toContain("No recent response.");
expect(container.textContent).toContain("Silence does not identify a permission problem");
expect(container.querySelector('a[href="https://muse.ai"]')).not.toBeNull();
});
it("distinguishes a detector removal request from acknowledged removal", async () => {
const pending = structuredClone(stoppedMuseConnection);
pending.binding!.cleanup.detectorRemoved = false;
Object.assign(pending.binding!.cleanup, { detectorRemovalRequested: true });
await render(pending);
expect(container.textContent).toContain("Detector removal requested; completion is not confirmed");
expect(container.textContent).toContain("Detector removal: not confirmed");
expect(container.textContent).not.toContain("Detector removal: acknowledged");
});
it("uses the stop boundary's old binding revision across a repaired connection refresh", async () => {
const repaired: MuseConnection = { ...stoppedMuseConnection, binding: { ...stoppedMuseConnection.binding!,
id: "55555555-5555-4555-8555-555555555555", generation: 3, revision: 1, status: "connected",
stop: { ...stoppedMuseConnection.binding!.stop, bindingRevision: 8 },
} };
await render(repaired);
await act(async () => button("Attest this worker stopped").click());
await act(async () => container.querySelector<HTMLInputElement>('input[type="checkbox"]')!.click());
await render({ ...repaired, binding: { ...repaired.binding!, revision: 2 } });
expect(container.querySelector<HTMLInputElement>('input[type="checkbox"]')!.checked).toBe(true);
await act(async () => button("Confirm worker stopped").click());
expect(handlers.onAttest).toHaveBeenCalledWith({ boundary: museStopBoundary, expectedRevision: 8, workerStopped: true });
});
it("disables attestation until the exact stop binding revision is available", async () => {
await render({ ...stoppedMuseConnection, binding: { ...stoppedMuseConnection.binding!,
stop: { ...stoppedMuseConnection.binding!.stop, bindingRevision: null },
} });
expect(button("Attest this worker stopped").disabled).toBe(true);
expect(container.textContent).toContain("Stop binding revision is unavailable");
await act(async () => button("Attest this worker stopped").click());
expect(container.querySelector('[aria-label="Attest this Muse worker stopped"]')).toBeNull();
expect(handlers.onAttest).not.toHaveBeenCalled();
});
it("connects after Disconnect without replacing the revoked historical binding", async () => {
const load = vi.spyOn(museInvitationsApi, "connection").mockResolvedValue(museConnection);
const revoke = vi.spyOn(museInvitationsApi, "revoke").mockImplementation(async () => {
load.mockResolvedValue({ ...museConnection, binding: { ...museConnection.binding!, status: "revoked", revision: 8 } });
});
const nextPairing = { ...musePairing, bindingId: "55555555-5555-4555-8555-555555555555", generation: 3, revision: 1,
expiresAt: new Date(Date.now() + 10 * 60_000).toISOString() };
const pair = vi.spyOn(museInvitationsApi, "pair").mockImplementation(async () => {
load.mockResolvedValue({ ...museConnection, binding: { ...museConnection.binding!, id: nextPairing.bindingId,
generation: nextPairing.generation, revision: nextPairing.revision, status: "pairing", paired: false } });
return nextPairing;
});
cache = new QueryClient({ defaultOptions: { queries: { retry: false, gcTime: 0 } } });
await act(async () => root.render(<QueryClientProvider client={cache!}><MuseRunnerConnection companyId="company" agentId="agent" /></QueryClientProvider>));
await act(async () => { await new Promise(resolve => setTimeout(resolve, 30)); });
await act(async () => button("Disconnect").click());
await act(async () => { await new Promise(resolve => setTimeout(resolve, 30)); });
expect(revoke).toHaveBeenCalledWith("company", "agent", { bindingId: museConnection.binding!.id, generation: museConnection.binding!.generation, expectedRevision: 7 });
expect(button("Connect Muse").disabled).toBe(false);
await act(async () => button("Connect Muse").click());
expect(pair).toHaveBeenCalledWith("company", "agent", {});
await act(async () => { await new Promise(resolve => setTimeout(resolve, 30)); });
expect(container.textContent).toContain("Copy setup prompt");
});
const lastVerifiedReply = () => [...container.querySelectorAll("dt")].find(label => label.textContent === "Last verified reply")!.nextElementSibling!.textContent;
async function renderRunner() {
cache = new QueryClient({ defaultOptions: { queries: { retry: false, gcTime: 0 } } });
await act(async () => root.render(<QueryClientProvider client={cache!}><MuseRunnerConnection companyId="company" agentId="agent" /></QueryClientProvider>));
await act(async () => { await vi.advanceTimersByTimeAsync(30); });
}
it("polls a retest after an earlier successful reply and shows its expiry without erasing history", async () => {
vi.useFakeTimers(); vi.setSystemTime(new Date("2026-10-10T15:00:00Z"));
const ready: MuseConnection = { ...museConnection, agentLifecycleState: "ready", binding: { ...museConnection.binding!,
status: "ready", backgroundReplyVerified: true, lastVerifiedReplyAt: "2026-10-10T14:59:00Z" } };
const load = vi.spyOn(museInvitationsApi, "connection").mockResolvedValue(ready);
const expiresAt = new Date(Date.now() + 15_000).toISOString();
vi.spyOn(museInvitationsApi, "verify").mockImplementation(async () => {
load.mockResolvedValue({ ...ready, binding: { ...ready.binding!, revision: 8, challengeExpiresAt: expiresAt } });
return { bindingId: ready.binding!.id, generation: ready.binding!.generation, revision: 8, expiresAt, status: "pending" };
});
await renderRunner();
const previousReply = lastVerifiedReply();
expect(previousReply).not.toBe("Not observed");
await act(async () => button("Test background reply").click());
await act(async () => { await vi.advanceTimersByTimeAsync(30); });
expect(container.textContent).toContain("Waiting for Muse to reply to the current background test");
expect(button("Testing background reply…").disabled).toBe(true);
expect(lastVerifiedReply()).toBe(previousReply);
const readsWhileTesting = load.mock.calls.length;
await act(async () => { await vi.advanceTimersByTimeAsync(5000); });
expect(load.mock.calls.length).toBeGreaterThan(readsWhileTesting);
expect(button("Testing background reply…").disabled).toBe(true);
await act(async () => { await vi.advanceTimersByTimeAsync(10_000); });
expect(container.textContent).toContain("No recent response.");
expect(container.textContent).not.toContain("Muse is ready for tasks");
expect(lastVerifiedReply()).toBe(previousReply);
expect(button("Test background reply").disabled).toBe(false);
});
it("finishes a current retest only after the server confirms a new background reply", async () => {
vi.useFakeTimers(); vi.setSystemTime(new Date("2026-10-10T15:00:00Z"));
const ready: MuseConnection = { ...museConnection, agentLifecycleState: "ready", binding: { ...museConnection.binding!,
status: "ready", backgroundReplyVerified: true, lastVerifiedReplyAt: "2026-10-10T14:59:00Z" } };
const load = vi.spyOn(museInvitationsApi, "connection").mockResolvedValue(ready);
const expiresAt = new Date(Date.now() + 60_000).toISOString();
vi.spyOn(museInvitationsApi, "verify").mockImplementation(async () => {
load.mockResolvedValue({ ...ready, binding: { ...ready.binding!, revision: 8, challengeExpiresAt: expiresAt } });
return { bindingId: ready.binding!.id, generation: ready.binding!.generation, revision: 8, expiresAt, status: "pending" };
});
await renderRunner();
const previousReply = lastVerifiedReply();
await act(async () => button("Test background reply").click());
await act(async () => { await vi.advanceTimersByTimeAsync(30); });
expect(container.textContent).toContain("Waiting for Muse to reply to the current background test");
expect(lastVerifiedReply()).toBe(previousReply);
load.mockResolvedValue({ ...ready, binding: { ...ready.binding!, revision: 9, lastVerifiedReplyAt: "2026-10-10T15:00:02Z" } });
await act(async () => { await vi.advanceTimersByTimeAsync(5000); });
expect(container.textContent).toContain("Muse is ready for tasks");
expect(container.textContent).not.toContain("Waiting for Muse to reply to the current background test");
expect(lastVerifiedReply()).not.toBe(previousReply);
expect(button("Test background reply").disabled).toBe(false);
});
it("marks public contact unverified and does not promise cleanup through a revoked Vault connection",async()=>{
const connection=structuredClone(stoppedMuseConnection);
connection.binding!.cleanup.authenticatedAfterRevocation=false;
connection.binding!.cleanup.expiresAt=null;
await render(connection);
expect(container.textContent).toContain("Public receiver checks are unverified");
expect(container.textContent).toContain("Cleanup cannot be confirmed through the revoked connection");
expect(container.textContent).not.toContain("Cleanup capability expires");
});
it("does not reuse historical readiness after the authenticated connection expires", async () => {
const expired: MuseConnection = { ...museConnection, agentLifecycleState: "ready", binding: {
...museConnection.binding!, status: "ready", credentialState: "expired", refreshExpiresAt: null,
paired: true, receiverDetected: true, backgroundReplyVerified: true,
} };
expect(museConnectionState(expired).ready).toBe(false);
await render(expired);
expect(container.textContent).toContain("This Muse connection expired. Reconnect Muse with a fresh setup prompt.");
expect(container.textContent).not.toContain("Muse is ready for tasks");
});
it("withdraws cached readiness after a failed update and restores it only after refresh", async () => {
vi.useFakeTimers(); vi.setSystemTime(new Date("2026-10-15T15:00:00Z"));
const ready: MuseConnection = { ...museConnection, agentLifecycleState: "ready", binding: { ...museConnection.binding!,
status: "ready", backgroundReplyVerified: true, lastVerifiedReplyAt: "2026-10-15T14:59:00Z" } };
const load = vi.spyOn(museInvitationsApi, "connection").mockResolvedValue(ready);
await renderRunner();
expect(container.textContent).toContain("Muse is ready for tasks");
const priorReply = lastVerifiedReply();
load.mockRejectedValueOnce(new Error("Synthetic connection update failed"));
await act(async () => { await vi.advanceTimersByTimeAsync(5030); });
expect(container.textContent).toContain("Connection updates paused. Refresh to check the latest evidence.");
expect(container.textContent).not.toContain("Muse is ready for tasks");
expect(container.textContent).toContain("Synthetic connection update failed");
expect(lastVerifiedReply()).toBe(priorReply);
expect(button("Test background reply").disabled).toBe(true);
expect(button("Repair connection").disabled).toBe(true);
expect(button("Disconnect").disabled).toBe(false);
expect(button("Refresh").disabled).toBe(false);
await act(async () => button("Refresh").click());
await act(async () => { await vi.advanceTimersByTimeAsync(30); });
expect(container.textContent).toContain("Muse is ready for tasks");
expect(container.textContent).not.toContain("Connection updates paused");
expect(button("Test background reply").disabled).toBe(false);
});
function cleanupConnection(): MuseConnection {
return { ...stoppedMuseConnection, binding: { ...stoppedMuseConnection.binding!, qualification: { id: "88888888-8888-4888-8888-888888888888", expiresAt: "2026-10-15T14:00:00Z" }, cleanup: { ...stoppedMuseConnection.binding!.cleanup, attestationEligible: true, hookScript: `~/hooks/scripts/paperclip-muse-${stoppedMuseConnection.binding!.id}.sh`, detectorEvidence: null } } };
}
async function fillDetectorObservation() {
await act(async () => button("Record detector removal observation").click());
await act(async () => {
const setter = Object.getOwnPropertyDescriptor(HTMLInputElement.prototype, "value")!.set!;
for (const [label, value] of [["Registered Muse hook ID", "test-registered-hook"], ["Detector removal observation SHA-256", "d".repeat(64)], ["Detector removal observed at (UTC)", "2026-10-15T14:59:00.000Z"]]) {
const input = container.querySelector<HTMLInputElement>(`input[aria-label="${label}"]`)!;
setter.call(input, value); input.dispatchEvent(new Event("input", { bubbles: true }));
}
});
await act(async () => container.querySelector<HTMLInputElement>('[aria-label="Record exact Muse detector removal"] input[type="checkbox"]')!.click());
}
it("records exact detector observation while disabled and reads back authoritative operator provenance", async () => {
vi.useFakeTimers(); vi.setSystemTime(new Date("2026-10-15T15:00:00Z"));
const connection = cleanupConnection();
const load = vi.spyOn(museInvitationsApi, "connection").mockResolvedValue(connection);
const record = vi.spyOn(museInvitationsApi, "attestDetectorCleanup").mockImplementation(async () => {
load.mockResolvedValue({ ...connection, binding: { ...connection.binding!, revision: connection.binding!.revision + 1, cleanup: { ...connection.binding!.cleanup, detectorRemoved: true, attestationEligible: false, detectorEvidence: { bindingId: connection.binding!.id, generation: connection.binding!.generation, qualificationId: connection.binding!.qualification!.id, hookScript: connection.binding!.cleanup.hookScript!, source: "operator_runtime_attestation", recordedAt: new Date().toISOString(), observedAt: new Date().toISOString(), operatorId: "operator", hookId: "test-registered-hook" } } } });
});
await renderRunner(); expect(button("Record detector removal observation").disabled).toBe(false);
await fillDetectorObservation(); expect(container.textContent).toContain(connection.binding!.cleanup.hookScript);
expect(container.textContent).toContain("include_disabled: true");
expect(button("Confirm detector observation").disabled).toBe(false);
const observedAt = "2026-10-15T14:59:00.000Z";
vi.setSystemTime(new Date("2026-10-15T15:05:00Z"));
await act(async () => button("Confirm detector observation").click());
await act(async () => { await vi.advanceTimersByTimeAsync(30); });
expect(record).toHaveBeenCalledExactlyOnceWith("company", "agent", expect.objectContaining({ bindingId: connection.binding!.id, generation: connection.binding!.generation, expectedRevision: connection.binding!.revision, qualificationId: connection.binding!.qualification!.id, hookScript: connection.binding!.cleanup.hookScript, observation: { method: "hooks.list", includeDisabled: true, hookId: "test-registered-hook", outcome: "exact_hook_absent", sha256: "d".repeat(64), observedAt } }));
expect(container.textContent).toContain("Detector removal: operator-attested");
expect(container.textContent).toContain("not independent provider confirmation");
expect(container.textContent).toContain("Native effect outcomes remain unknown");
expect(container.querySelector('[aria-label="Record exact Muse detector removal"]')).toBeNull();
});
it.each(["revision", "binding", "offline"])("invalidates detector confirmation after a %s update", async change => {
vi.useFakeTimers(); vi.setSystemTime(new Date("2026-10-15T15:00:00Z"));
const connection = cleanupConnection(), load = vi.spyOn(museInvitationsApi, "connection").mockResolvedValue(connection);
const record = vi.spyOn(museInvitationsApi, "attestDetectorCleanup").mockResolvedValue(undefined);
await renderRunner(); await fillDetectorObservation();
if (change === "offline") load.mockRejectedValue(new Error("Connection update unavailable"));
else load.mockResolvedValue({ ...connection, binding: { ...connection.binding!, ...(change === "revision" ? { revision: connection.binding!.revision + 1 } : { id: "55555555-5555-4555-8555-555555555555", generation: 3 }) } });
await act(async () => { await cache!.refetchQueries({ queryKey: ["muse-binding"] }); await vi.advanceTimersByTimeAsync(30); });
expect(container.querySelector('[aria-label="Record exact Muse detector removal"]')).toBeNull(); expect(record).not.toHaveBeenCalled();
if (change === "offline") expect(button("Record detector removal observation").disabled).toBe(true);
});
it("rechecks the server snapshot before submitting a cached detector confirmation", async () => {
vi.useFakeTimers(); vi.setSystemTime(new Date("2026-10-15T15:00:00Z"));
const connection = cleanupConnection(), load = vi.spyOn(museInvitationsApi, "connection").mockResolvedValue(connection);
const record = vi.spyOn(museInvitationsApi, "attestDetectorCleanup").mockResolvedValue(undefined);
await renderRunner(); await fillDetectorObservation();
load.mockResolvedValue({ ...connection, binding: { ...connection.binding!, revision: connection.binding!.revision + 1 } });
await act(async () => button("Confirm detector observation").click()); await act(async () => { await vi.advanceTimersByTimeAsync(30); });
expect(record).not.toHaveBeenCalled(); expect(container.textContent).toContain("Connection changed");
});
it("requires the actual detector listing time and reconfirms every observation edit", async () => {
vi.useFakeTimers(); vi.setSystemTime(new Date("2026-10-15T15:00:00Z"));
await render(cleanupConnection());
await fillDetectorObservation();
const setter = Object.getOwnPropertyDescriptor(HTMLInputElement.prototype, "value")!.set!;
const edit = async (label: string, value: string) => act(async () => {
const input = container.querySelector<HTMLInputElement>(`input[aria-label="${label}"]`)!;
setter.call(input, value); input.dispatchEvent(new Event("input", { bubbles: true }));
});
const confirm = async () => act(async () => container.querySelector<HTMLInputElement>('[aria-label="Record exact Muse detector removal"] input[type="checkbox"]')!.click());
for (const [label, value] of [["Registered Muse hook ID", "another-registered-hook"], ["Detector removal observation SHA-256", "e".repeat(64)], ["Detector removal observed at (UTC)", "2026-10-15T14:58:00.000Z"]]) {
expect(button("Confirm detector observation").disabled).toBe(false);
await edit(label, value);
expect(button("Confirm detector observation").disabled).toBe(true);
await confirm();
}
for (const value of ["", "not-a-time", "2026-02-30T14:58:00.000Z"]) {
await edit("Detector removal observed at (UTC)", value); await confirm();
expect(button("Confirm detector observation").disabled).toBe(true);
}
expect(handlers.onAttestDetectorCleanup).not.toHaveBeenCalled();
await edit("Detector removal observed at (UTC)", "2026-10-15T13:00:00.000Z"); await confirm();
vi.setSystemTime(new Date("2026-10-15T15:10:00Z"));
await act(async () => button("Confirm detector observation").click());
expect(handlers.onAttestDetectorCleanup).toHaveBeenCalledExactlyOnceWith(expect.objectContaining({ observation: {
method: "hooks.list", includeDisabled: true, hookId: "another-registered-hook", outcome: "exact_hook_absent",
sha256: "e".repeat(64), observedAt: "2026-10-15T13:00:00.000Z",
} }));
});
+152
View File
@@ -0,0 +1,152 @@
import { Bot } from "lucide-react";
import { useEffect, useState } from "react";
import { useMutation, useQueryClient } from "@tanstack/react-query";
import { museDetectorCleanupAttestationSchema } from "@paperclipai/shared";
import type { MuseConnection, MusePairing, MuseAttestStopInput, MuseDetectorCleanupAttestationInput } from "@paperclipai/shared";
import { agentsApi } from "@/api/agents";
import { useMuseConnection, useMuseConnectionState } from "@/hooks/useMuseConnection";
import { queryKeys } from "@/lib/queryKeys";
import { formatDateTime } from "@/lib/utils";
import { AgentSetupPrompt } from "./AgentSetupPrompt";
import { MuseConnectionChecks } from "./new-agent/ExternalAgentInviteContent";
import { Input } from "./ui/input";
import { Button } from "./ui/button";
export interface MuseConnectionDetailsProps {
connection: MuseConnection;
pairing?: MusePairing | null;
busy?: boolean;
error?: string;
offline?: boolean;
onTest: () => void;
onRepair: () => void;
onPause: () => void;
onDisconnect: () => void;
onRefresh: () => void;
onAttest: (input: MuseAttestStopInput) => void;
onAttestDetectorCleanup?: (input: MuseDetectorCleanupAttestationInput) => void;
}
/** The settings view uses the same connection evidence as the invitation. */
export function MuseConnectionDetails({ connection, pairing, busy, error, offline = false, onTest, onRepair, onPause, onDisconnect, onRefresh, onAttest, onAttestDetectorCleanup }: MuseConnectionDetailsProps) {
const b = connection.binding;
const [attestation, setAttestation] = useState<MuseAttestStopInput | null>(null);
const [confirmed, setConfirmed] = useState(false);
const [detectorSnapshot, setDetectorSnapshot] = useState<Omit<MuseDetectorCleanupAttestationInput, "observation" | "detectorRemoved"> | null>(null);
const [detectorHookId, setDetectorHookId] = useState("");
const [observationDigest, setObservationDigest] = useState("");
const [detectorObservedAt, setDetectorObservedAt] = useState("");
const detectorObservedAtValid = museDetectorCleanupAttestationSchema.shape.observation.shape.observedAt.safeParse(detectorObservedAt).success;
const [detectorConfirmed, setDetectorConfirmed] = useState(false);
const detectorIdentity = JSON.stringify([b?.id, b?.generation, b?.revision, b?.qualification?.id, b?.cleanup.hookScript, b?.cleanup.attestationEligible, offline]);
useEffect(() => { setDetectorSnapshot(null); setObservationDigest(""); setDetectorObservedAt(""); setDetectorHookId(""); setDetectorConfirmed(false); }, [detectorIdentity]);
const boundaryIdentity = JSON.stringify(b?.stop.boundary);
const stopBindingRevision = b?.stop.bindingRevision;
useEffect(() => { setAttestation(null); setConfirmed(false); }, [boundaryIdentity, stopBindingRevision]);
const live = !!b && b.status !== "revoked";
const state = useMuseConnectionState(connection, { offline });
const testing = state.testing;
const configure = !offline && connection.enabled && connection.canConfigureConnection && !!connection.publicOrigin;
const timestamp = (value: string | null) => value ? formatDateTime(value, { includeSeconds: true }) : "Not observed";
return <div className="space-y-4">
<p className="text-sm text-muted-foreground">Muse uses its own tools and permissions. Paperclip coordinates company tasks and questions. Provider usage and cost are unavailable.</p>
{!connection.enabled && <p className="text-sm text-muted-foreground">Muse is disabled in experimental settings. Connection cleanup and stop evidence remain available.</p>}
{!connection.publicOrigin && <p className="text-sm text-muted-foreground">An authenticated instance with a public HTTPS URL is required to connect Muse.</p>}
{b && <>
<MuseConnectionChecks state={state} />
<dl className="space-y-2 text-xs">
{[
["Last persisted public receiver contact", timestamp(b.lastReceiverContactAt)],
["Authenticated worker activity", timestamp(b.lastWorkerActivityAt)],
["Last verified reply", timestamp(b.lastVerifiedReplyAt)],
["Credential status", b.credentialState],
["Refresh credential expires", timestamp(b.refreshExpiresAt)],
["Client version", b.clientVersion ?? "Not reported"],
["Live assignments", String(b.liveAssignments)],
["Uncertain native operations", String(b.uncertainOperations)],
["Pending answers", String(b.pendingInputs)],
["Usage and cost", "Unavailable"],
].map(([label, value]) => <div key={label} className="flex flex-wrap items-baseline justify-between gap-2"><dt className="text-muted-foreground">{label}</dt><dd className="font-mono">{value}</dd></div>)}
</dl>
<p className="text-xs text-muted-foreground">Receiver contact is persisted in batches and may lag by up to {Math.ceil(b.contactPersistenceLagMs / 1000)} seconds. Public receiver checks are unverified. Only an authenticated reply confirms Muse can work.</p>
{b.status === "revoked" && <p role="status" className="text-sm text-muted-foreground">Disconnected. Paperclip credentials and execution authority were revoked.</p>}
{(b.cleanup.pending || b.status === "revoked") && <div className="space-y-2 text-sm text-muted-foreground">
{"detectorRemovalRequested" in b.cleanup && b.cleanup.detectorRemovalRequested === true && !b.cleanup.detectorRemoved && <p>Detector removal requested; completion is not confirmed.</p>}
<p>Detector removal: {b.cleanup.detectorEvidence?.source === "operator_runtime_attestation" ? "operator-attested" : b.cleanup.detectorRemoved ? "acknowledged" : "not confirmed"}. Worker quiescence: {b.cleanup.workerQuiescenceReported ? "reported" : "not confirmed"}.</p>
{b.cleanup.authenticatedAfterRevocation === false && <p>After disconnect, the receiver disables itself when it next reaches Paperclip. Cleanup cannot be confirmed through the revoked connection.</p>}
{b.cleanup.expiresAt && <p className="text-xs">Cleanup capability expires {formatDateTime(b.cleanup.expiresAt)}.</p>}
{b.cleanup.detectorEvidence && <p>Recorded {timestamp(b.cleanup.detectorEvidence.recordedAt)}: {b.cleanup.detectorEvidence.source === "operator_runtime_attestation" ? "operator attestation from a supported runtime hook listing" : "authenticated worker report"}. This is not independent provider confirmation.</p>}
{b.cleanup.attestationEligible && onAttestDetectorCleanup && !detectorSnapshot && <Button type="button" variant="outline" disabled={busy || offline} onClick={() => {
if (!b.cleanup.hookScript) return;
setDetectorSnapshot({ bindingId: b.id, generation: b.generation, expectedRevision: b.revision, qualificationId: b.qualification?.id ?? null, hookScript: b.cleanup.hookScript });
}}>Record detector removal observation</Button>}
<p>Detector removal, worker stopping, and sandbox cleanup are separate. Paperclip cannot verify every private Muse tool or stop all personal Muse conversations.</p>
</div>}
{b.stop.status !== "none" && <div className="space-y-3 text-sm">
<p className="text-muted-foreground">{b.stop.status === "operator_attested" ? "You attested that this assignment’s Muse worker stopped."
: b.stop.status === "worker_reported" ? "Muse reported that this assignment’s worker is quiescent. Independent private effects remain unconfirmed."
: "Paperclip fenced this assignment’s authority. Its external stop cannot be confirmed."}</p>
{b.stop.nativeEffectsUnknown && <p role="status" className="text-muted-foreground">Native effect outcomes remain unknown and block replacement work. Worker attestation does not resolve or replay those effects.</p>}
{b.stop.boundary && stopBindingRevision == null && b.stop.status !== "operator_attested" && <p role="status" className="text-muted-foreground">Stop binding revision is unavailable. Refresh to inspect this assignment before attesting.</p>}
{b.stop.boundary && b.stop.status !== "operator_attested" && !attestation && <Button type="button" variant="outline" disabled={busy || stopBindingRevision == null} onClick={() => {
if (!b.stop.boundary || stopBindingRevision == null) return;
setAttestation({ boundary: { ...b.stop.boundary }, expectedRevision: stopBindingRevision, workerStopped: true }); setConfirmed(false);
}}>Attest this worker stopped</Button>}
</div>}
</>}
{detectorSnapshot && <section aria-label="Record exact Muse detector removal" className="space-y-3 rounded-md border border-border p-3">
<p className="text-sm font-medium">Record an observation of this exact detector</p>
<p className="break-all font-mono text-xs">{detectorSnapshot.hookScript}</p>
<p className="text-sm text-muted-foreground">Use Muse’s supported <code>{"hooks.list({ include_disabled: true })"}</code> after revocation to verify this exact registered script is absent. Disabled checks, silence, or a removal request are insufficient. The retained hook ID and observation are your report. Paperclip does not verify installation, query Muse, or remove the hook.</p>
<label className="block space-y-2 text-sm">Registered hook ID<Input aria-label="Registered Muse hook ID" value={detectorHookId} onChange={event => { setDetectorHookId(event.target.value); setDetectorConfirmed(false); }} autoComplete="off" /></label>
<label className="block space-y-2 text-sm">Observation SHA-256<Input aria-label="Detector removal observation SHA-256" value={observationDigest} onChange={event => { setObservationDigest(event.target.value); setDetectorConfirmed(false); }} autoComplete="off" /></label>
<p className="text-xs text-muted-foreground">Use the digest of the secret-free runtime observation. Do not paste the listing, a URL, credentials, or private tool output.</p>
<label className="block space-y-2 text-sm">Observed at (UTC)<Input aria-label="Detector removal observed at (UTC)" value={detectorObservedAt} onChange={event => { setDetectorObservedAt(event.target.value); setDetectorConfirmed(false); }} placeholder="YYYY-MM-DDTHH:mm:ss.sssZ" autoComplete="off" /></label>
<p className="text-xs text-muted-foreground">Enter the actual runtime listing time, not the time you submit this form. Paperclip checks it against revocation and the qualification deadline.</p>
<label className="flex items-start gap-2 text-sm"><input type="checkbox" checked={detectorConfirmed} onChange={event => setDetectorConfirmed(event.target.checked)} className="mt-1 accent-primary" /><span>I observed this exact hook absent in the supported runtime listing after revocation. This attests detector removal only, not worker stopping or private effects.</span></label>
<div className="flex items-center justify-between gap-3"><Button type="button" variant="ghost" onClick={() => setDetectorSnapshot(null)}>Cancel</Button><Button type="button" disabled={!detectorConfirmed || !detectorObservedAtValid || !/^[a-z0-9][a-z0-9_-]{0,63}$/.test(detectorHookId) || !/^[a-f0-9]{64}$/.test(observationDigest) || busy || offline} onClick={() => onAttestDetectorCleanup?.({ ...detectorSnapshot, detectorRemoved: true, observation: { method: "hooks.list", includeDisabled: true, hookId: detectorHookId, outcome: "exact_hook_absent", observedAt: detectorObservedAt, sha256: observationDigest } })}>Confirm detector observation</Button></div>
</section>}
{attestation && <section aria-label="Attest this Muse worker stopped" className="space-y-3 rounded-md border border-border p-3">
<p className="text-sm font-medium">Confirm the worker for this exact assignment stopped</p>
<dl className="space-y-2 text-xs">
{Object.entries(attestation.boundary).map(([key, value]) => <div key={key} className="flex flex-wrap items-baseline justify-between gap-2"><dt className="text-muted-foreground">{key}</dt><dd className="break-all font-mono">{value === null ? "None" : String(value)}</dd></div>)}
</dl>
<label className="flex items-start gap-2 text-sm"><input type="checkbox" checked={confirmed} onChange={event => setConfirmed(event.target.checked)} className="mt-1 accent-primary" /><span>I have stopped this Muse worker for the named assignment and run. This does not certify independent private tools or unknown native effects.</span></label>
<div className="flex items-center justify-between gap-3">
<Button type="button" variant="ghost" onClick={() => setAttestation(null)}>Cancel</Button>
<Button type="button" disabled={!confirmed || busy} onClick={() => onAttest(attestation)}>Confirm worker stopped</Button>
</div>
</section>}
{pairing && !b?.paired && <div className="space-y-2">
<AgentSetupPrompt key={pairing.bindingId + pairing.revision} prompt={pairing.setupInstruction} label="Copy setup prompt" title="Connect your Muse" agent={{ name: "Muse", icon: <Bot className="size-5" /> }} description="Paste into your current Muse conversation. Complete Connect Paperclip if Muse asks, then approve the Paperclip hostname." />
<p className="text-xs text-muted-foreground">One-use setup expires {formatDateTime(pairing.expiresAt)}. Muse keeps the connection credential in its secure vault. Hostname permission applies to future requests to this Paperclip hostname.</p>
</div>}
<div className="flex flex-wrap items-center gap-2">
{live && <Button type="button" variant="outline" disabled={!configure || b.credentialState !== "active" || !b.paired || busy || testing} onClick={onTest}>{testing ? "Testing background reply…" : "Test background reply"}</Button>}
<Button type="button" variant="outline" disabled={!configure || busy} onClick={onRepair}>{b?.credentialState === "expired" ? "Reconnect Muse" : live ? "Repair connection" : "Connect Muse"}</Button>
{connection.agentStatus !== "paused" && connection.agentStatus !== "terminated" && <Button type="button" variant="outline" disabled={busy} onClick={onPause}>Pause agent</Button>}
{live && <Button type="button" variant="outline" disabled={busy} onClick={onDisconnect}>Disconnect</Button>}
<Button type="button" variant="ghost" disabled={busy} onClick={onRefresh}>Refresh</Button>
<Button type="button" asChild variant="ghost"><a href="https://muse.ai" target="_blank" rel="noopener noreferrer">Open Muse</a></Button>
</div>
{error && <p role="alert" className="text-sm text-destructive">{error}</p>}
</div>;
}
export function MuseRunnerConnection({ companyId, agentId }: { companyId?: string; agentId?: string }) {
const cache = useQueryClient();
const muse = useMuseConnection(companyId, agentId);
const pause = useMutation({ mutationFn: () => agentsApi.pause(agentId!, companyId), onSuccess: () => {
void muse.refresh(); void cache.invalidateQueries({ queryKey: queryKeys.agents.detail(agentId!) });
if (companyId) void cache.invalidateQueries({ queryKey: queryKeys.agents.list(companyId) });
} });
if (!agentId) return <p className="text-sm text-muted-foreground">Save the agent, then connect your personal Muse here.</p>;
const error = muse.state.error ?? muse.pair.error ?? muse.verify.error ?? muse.revoke.error ?? muse.attest.error ?? muse.attestDetector.error ?? pause.error;
if (muse.identity.failed) return <div className="space-y-3"><p role="alert" className="text-sm text-destructive">Unable to confirm your account. Refresh to inspect this Muse connection.</p><Button type="button" variant="outline" onClick={() => { void cache.invalidateQueries({ queryKey: queryKeys.auth.session }); }}>Refresh</Button></div>;
if (!muse.state.data) return <div className="space-y-3"><p role="status" className="text-sm text-muted-foreground">{muse.state.error ? "Unable to load the Muse connection." : "Loading Muse connection…"}</p>{error && <p role="alert" className="text-sm text-destructive">{error.message}</p>}<Button type="button" variant="outline" onClick={() => { void muse.state.refetch(); }}>Refresh</Button></div>;
return <MuseConnectionDetails key={muse.identity.userId} connection={muse.state.data} pairing={muse.pairing} offline={muse.state.isError}
busy={muse.pair.isPending || muse.verify.isPending || muse.revoke.isPending || muse.attest.isPending || muse.attestDetector.isPending || pause.isPending}
error={error?.message} onTest={() => { if (muse.binding) muse.verify.mutate(muse.binding); }} onRepair={muse.repair}
onPause={() => pause.mutate()} onDisconnect={() => { if (muse.binding) muse.revoke.mutate(muse.binding); }}
onRefresh={() => { void muse.state.refetch(); }} onAttest={input => muse.attest.mutate(input)} onAttestDetectorCleanup={input => muse.attestDetector.mutate(input)} />;
}
+71
View File
@@ -2573,6 +2573,77 @@ describe("TaskChatThread runtime transcript selection", () => {
},
);
it.each(["Muse", "Dot"].flatMap(
(provider) => [false, true].map((hasTranscript) => ({ provider, hasTranscript })),
))(
"hides a previous agent's Retry while waiting for $provider acceptance (transcript: $hasTranscript)",
async ({ provider, hasTranscript }) => {
const onRetryFailedRun = vi.fn();
if (hasTranscript) transcriptState.transcriptByRun.set("previous-failure", [{
kind: "assistant", ts: "2026-08-25T18:00:01.000Z", text: "Starting the task.",
}]);
const failedRun = {
runId: "previous-failure",
runtimeMode: "legacy" as const,
status: "failed",
errorCode: "legacy_process_exited",
agentId: "previous-agent",
agentName: "Direct Codex",
adapterType: "codex_local",
createdAt: "2026-08-25T18:00:00.000Z",
startedAt: "2026-08-25T18:00:00.000Z",
finishedAt: "2026-08-25T18:00:02.000Z",
};
const waitingRun = {
runId: "external-offer",
runtimeMode: "native" as const,
status: "queued",
agentId: "external-agent",
agentName: provider,
adapterType: "paperclip_runner",
createdAt: "2026-08-25T18:01:00.000Z",
startedAt: null,
finishedAt: null,
execution: {
phase: "waiting_for_provider" as const,
label: `Waiting for ${provider}`,
cause: null,
lastConfirmedActivityAt: null,
retryAt: null,
attempt: 1,
maxAttempts: 3,
recoveryOwner: null,
nextAction: null,
permittedActions: [],
predecessorRunId: null,
successorRunId: null,
},
};
const renderRun = (waiting: boolean) => render(
<TaskChatThread
comments={[]}
onAdd={async () => {}}
issueStatus="in_progress"
onRetryFailedRun={onRetryFailedRun}
linkedRuns={waiting ? [failedRun, waitingRun] : [failedRun]}
/>,
);
// The linked projection must guard Retry before active/live-run polling arrives.
renderRun(true);
expect(container.textContent).toContain("Your message is preserved.");
expect(container.textContent).not.toContain("You can retry this message now.");
expect(container.querySelector('[data-testid="task-chat-run-failed-try-again"]')).toBeNull();
expect(onRetryFailedRun).not.toHaveBeenCalled();
renderRun(false);
expect(container.textContent).toContain("You can retry this message now.");
const retry = container.querySelector<HTMLButtonElement>('[data-testid="task-chat-run-failed-try-again"]');
expect(retry).not.toBeNull();
flushSync(() => retry!.click());
await Promise.resolve();
expect(onRetryFailedRun).toHaveBeenCalledExactlyOnceWith("previous-failure");
},
);
it.each(["active execution", "pending decision", "recovery hold"].flatMap(
(gate) => [false, true].map((hasTranscript) => ({ gate, hasTranscript })),
))(
+1
View File
@@ -589,6 +589,7 @@ export function TaskChatThread(props: TaskChatThreadProps) {
) return false;
return [
"working",
"waiting_for_provider",
"retry_scheduled",
"reconnecting",
"finishing",
@@ -1,12 +1,16 @@
import { useId } from "react";
import { AGENT_ROLES, AGENT_ROLE_LABELS } from "@paperclipai/shared";
import type { MuseInvitationDraft } from "@/lib/muse-invitation-draft";
import { Input } from "../ui/input";
import { Link } from "@/lib/router";
import { ArrowLeft, Check, Circle, CircleAlert, Loader2 } from "lucide-react";
import { ArrowLeft, Bot, Check, Circle, CircleAlert, Loader2 } from "lucide-react";
import { AdapterMark } from "../AdapterMark";
import { AgentSetupPrompt } from "../AgentSetupPrompt";
import { Button } from "../ui/button";
import { DialogDescription, DialogTitle } from "../ui/dialog";
import { cn } from "@/lib/utils";
export type ExternalAgentPreset = "dot" | "grok_bot" | "hermes" | "other";
export type ExternalAgentPreset = "dot" | "grok_bot" | "muse" | "hermes" | "other";
export type DotConnectionState = {
phase: "waiting" | "connected" | "subscribed" | "testing" | "finishing" | "ready";
problem?: "event_timeout" | "prompt_unavailable" | "offline" | "agent_unavailable";
@@ -15,13 +19,14 @@ export type DotConnectionState = {
const presets = [
{ id: "dot", name: "Dot", adapter: "openai_dot", description: "Your Dot in ChatGPT" },
{ id: "grok_bot", name: "Grok Bot", adapter: "grok_bot", description: "An existing Bot in Grok" },
{ id: "muse", name: "Muse — Personal agent", adapter: "muse", description: "Your personal Muse at muse.ai" },
{ id: "hermes", name: "Hermes", adapter: "hermes_gateway", description: "An existing Hermes agent" },
{ id: "other", name: "Other", adapter: "http", description: "Any agent that can connect to Paperclip" },
] as const;
export function ExternalAgentPresetPicker({ onSelect, dotDisabledReason, grokDisabledReason }: { onSelect: (preset: ExternalAgentPreset) => void; dotDisabledReason?: string; grokDisabledReason?: string }) {
return <div className="grid grid-cols-2 gap-3 sm:grid-cols-4">
{presets.map(preset => <button
export function ExternalAgentPresetPicker({ onSelect, dotDisabledReason, grokDisabledReason, museEnabled = false }: { onSelect: (preset: ExternalAgentPreset) => void; dotDisabledReason?: string; grokDisabledReason?: string; museEnabled?: boolean }) {
return <div className={cn("grid grid-cols-2 gap-3", !museEnabled && "sm:grid-cols-4")}>
{presets.filter(preset => preset.id !== "muse" || museEnabled).map(preset => <button
key={preset.id}
type="button"
onClick={() => onSelect(preset.id)}
@@ -78,11 +83,65 @@ export function DotConnectionChecks({ state }: { state: DotConnectionState }) {
</section>;
}
export type MuseConnectionState = {
paired: boolean;
receiverDetected: boolean;
backgroundReplyVerified: boolean;
ready: boolean;
finishing?: boolean;
testing?: boolean;
problem?: "no_recent_response" | "prompt_unavailable" | "offline" | "agent_unavailable" | "disconnected" | "disabled" | "connection_expired";
};
/** Receiver contact is separate from an authenticated, independent worker reply. */
export function MuseConnectionChecks({ state }: { state: MuseConnectionState }) {
const milestones = [
{ title: "Paired with Paperclip", done: state.paired, description: "Paperclip authorized the scoped Muse agent connection." },
{ title: "Receiver detected", done: state.receiverDetected, description: "Paperclip observed a public receiver check. Only an authenticated reply confirms Muse can work." },
{ title: "Background reply verified", done: state.backgroundReplyVerified, description: "Muse replied to an independent background test." },
];
const checksPassed = milestones.every(item => item.done);
const message = state.problem === "offline" ? "Connection updates paused. Refresh to check the latest evidence."
: state.problem === "disconnected" ? "Disconnected. These are the last observed connection checks. Detector cleanup and worker stop evidence remain separate."
: state.problem === "connection_expired" ? "This Muse connection expired. Reconnect Muse with a fresh setup prompt. Previous checks do not restore access."
: state.problem === "disabled" ? "Muse is disabled. These are the last observed connection checks; new work cannot start."
: state.problem === "prompt_unavailable" ? "This setup prompt was replaced or expired. Create a fresh prompt to continue."
: state.problem === "agent_unavailable" ? "Connection checks passed. Resolve this agent’s setup or approval blocker before assigning tasks."
: state.problem === "no_recent_response" ? "No recent response. Open Muse to check the current conversation and hostname permission, then test again. Silence does not identify a permission problem."
: state.testing ? "Waiting for Muse to reply to the current background test."
: checksPassed && state.ready ? "Muse is ready for tasks. All connection checks and agent setup passed."
: checksPassed ? "All connection checks passed. Paperclip is finishing agent setup."
: !state.paired ? "Watching for Muse to pair. Copy the setup prompt into your current Muse conversation."
: !state.receiverDetected ? "Muse is paired. Waiting for the first persisted receiver contact."
: "Receiver detected. Waiting for Muse to reply to the background test.";
return <section aria-label="Muse connection checks" className="space-y-4">
<ol className="space-y-4">
{milestones.map(item => <li key={item.title} className="flex items-start gap-3">
<span className={cn("flex size-6 shrink-0 items-center justify-center rounded-full", item.done ? "bg-accent text-foreground" : "text-muted-foreground")}>
{item.done ? <Check className="size-4" /> : <Circle className="size-4" />}
</span>
<div className="min-w-0 pt-0.5">
<p className={cn("text-sm", item.done ? "font-medium" : "text-muted-foreground")}>
{item.title}<span className="sr-only">{item.done ? ": complete" : ": waiting"}</span>
</p>
{item.done && <p className="mt-1 text-xs leading-relaxed text-muted-foreground">{item.description}</p>}
</div>
</li>)}
</ol>
<p role="status" aria-live="polite" className="text-sm leading-relaxed text-muted-foreground">{message}</p>
</section>;
}
/** Shared presentation for the invitation flow. The caller owns invitations and watching. */
export function ExternalAgentInviteContent({
preset, prompt, companyName, connection, onSelect, onBack, onClose, onCopied, onRetry, onNewPrompt,
dotDisabledReason, grokDisabledReason, busy = false, error, approvalHref,
dotDisabledReason, grokDisabledReason, busy = false, error, approvalHref, museEnabled = false, museConnection, museDraft, onMuseDraftChange, onMuseContinue,
}: {
museEnabled?: boolean;
museConnection?: MuseConnectionState;
museDraft?: MuseInvitationDraft;
onMuseDraftChange?: (draft: MuseInvitationDraft) => void;
onMuseContinue?: () => void;
dotDisabledReason?: string;
grokDisabledReason?: string;
busy?: boolean;
@@ -99,9 +158,14 @@ export function ExternalAgentInviteContent({
onRetry: () => void;
onNewPrompt: () => void;
}) {
const detailsId = useId();
const muse = preset === "muse";
const museDetails = muse && !!museDraft;
const museAwaitingInvitation = muse && !museDetails && !museConnection && !approvalHref;
const museReady = muse && !!museConnection?.ready && museConnection.paired && museConnection.receiverDetected && museConnection.backgroundReplyVerified && !museConnection.problem && !museConnection.testing;
const provider = presets.find(item => item.id === preset);
const dot = preset === "dot";
const ready = dot && connection.phase === "ready" && !connection.problem;
const ready = museReady || (dot && connection.phase === "ready" && !connection.problem);
const connecting = dot && connection.phase !== "waiting";
const progressLabel = connection.problem === "agent_unavailable" ? "Agent unavailable"
: connection.problem === "offline" ? "Updates paused"
@@ -111,13 +175,19 @@ export function ExternalAgentInviteContent({
return <>
<div className="min-h-0 space-y-6 overflow-y-auto px-6 pb-6 pt-8 sm:px-8">
<div className="space-y-3 pr-5">
{muse && <Button variant="ghost" size="sm" onClick={onBack}><ArrowLeft className="size-4" />Back</Button>}
{provider && <AdapterMark type={provider.adapter} className="size-10" />}
<div className="space-y-2">
<DialogTitle className="text-xl font-semibold tracking-tight">
{!provider ? "Invite an external agent" : dot ? ready ? "Your Dot is connected" : "Connect your Dot" : preset === "hermes" ? "Invite your Hermes agent" : "Invite your agent"}
{!provider ? "Invite an external agent" : muse ? ready ? "Muse is connected" : museDetails || museAwaitingInvitation ? "Invite your Muse" : "Connect your Muse" : dot ? ready ? "Your Dot is connected" : "Connect your Dot" : preset === "hermes" ? "Invite your Hermes agent" : "Invite your agent"}
</DialogTitle>
<DialogDescription className="text-sm leading-relaxed">
{!provider ? `Bring an agent you already use into ${companyName}.`
: muse ? museDetails ? `Give your personal Muse a name and role in ${companyName}.`
: museAwaitingInvitation ? "Muse requires an authenticated Paperclip instance with a public HTTPS URL and a company operator account."
: ready ? `Muse can receive assignments and work with ${companyName}.`
: !prompt && !museConnection?.paired ? "We’re checking whether this instance can prepare Muse setup."
: "Paste the setup prompt into your current Muse conversation. Complete Connect Paperclip if Muse asks, then approve this Paperclip hostname. Muse keeps the connection credential in its secure vault. The hostname permission applies to future requests."
: ready ? `Your Dot can now receive assignments and work with ${companyName}.`
: connection.problem === "agent_unavailable" ? "Your Dot is connected. This agent must be available in Paperclip before it can receive assignments."
: connection.phase === "finishing" ? "Your Dot has connected and confirmed task updates. Paperclip is preparing it for assignments."
@@ -127,10 +197,33 @@ export function ExternalAgentInviteContent({
</DialogDescription>
</div>
</div>
{error && <div role="alert" className="space-y-3 text-sm text-destructive"><p>{error}</p><Button variant="outline" disabled={busy} onClick={onRetry}>Try again</Button></div>}
{!provider ? <><ExternalAgentPresetPicker onSelect={onSelect} dotDisabledReason={dotDisabledReason} grokDisabledReason={grokDisabledReason} />
{error && <div role="alert" className="space-y-3 text-sm text-destructive"><p>{error}</p>{!museAwaitingInvitation && <Button variant="outline" disabled={busy} onClick={onRetry}>Try again</Button>}</div>}
{!provider ? <><ExternalAgentPresetPicker onSelect={onSelect} dotDisabledReason={dotDisabledReason} grokDisabledReason={grokDisabledReason} museEnabled={museEnabled} />
{dotDisabledReason && <p className="text-sm text-muted-foreground">{dotDisabledReason}</p>}{grokDisabledReason && grokDisabledReason !== dotDisabledReason && <p className="text-sm text-muted-foreground">{grokDisabledReason}</p>}</>
: approvalHref ? <p className="text-sm text-muted-foreground">An organization admin needs to approve this agent before Dot can connect. This page will update after approval.</p>
: approvalHref ? <p className="text-sm text-muted-foreground">An organization admin needs to approve this agent before {muse ? "Muse" : "Dot"} can connect. This page will update after approval.</p>
: museAwaitingInvitation ? busy ? <p role="status" className="text-sm text-muted-foreground">Loading Muse invitation…</p> : null
: muse && busy && !museDetails && !prompt && !museConnection?.paired ? <p role="status" className="text-sm text-muted-foreground">Loading Muse setup…</p>
: museDetails ? <form id={detailsId} className="space-y-4" onSubmit={event => { event.preventDefault(); if (museDraft.name.trim() && !busy) onMuseContinue?.(); }}>
<div className="space-y-2">
<label htmlFor={`${detailsId}-name`} className="text-sm font-medium">Agent name</label>
<Input id={`${detailsId}-name`} disabled={busy} autoFocus maxLength={100} value={museDraft.name} placeholder="Muse" onChange={event => onMuseDraftChange?.({ ...museDraft, name: event.target.value })} />
</div>
<div className="space-y-2">
<label htmlFor={`${detailsId}-role`} className="text-sm font-medium">Role</label>
<select id={`${detailsId}-role`} disabled={busy} className="h-9 w-full rounded-md border border-input bg-background px-3 text-sm" value={museDraft.role} onChange={event => {
const role = AGENT_ROLES.find(value => value === event.target.value);
if (role) onMuseDraftChange?.({ ...museDraft, role });
}}>{AGENT_ROLES.map(role => <option key={role} value={role}>{AGENT_ROLE_LABELS[role]}</option>)}</select>
</div>
<p className="text-xs text-muted-foreground">Muse uses its own approved tools. Provider usage and cost are unavailable to Paperclip.</p>
</form>
: muse && museConnection ? <>
<MuseConnectionChecks state={museConnection} />
{museConnection.problem === "no_recent_response" && <Button variant="outline" disabled={busy} onClick={onRetry}>Test background reply</Button>}
{museConnection.problem === "offline" && <Button variant="outline" disabled={busy} onClick={onRetry}>Refresh connection</Button>}
{!museConnection.paired && <a href="https://muse.ai" target="_blank" rel="noopener noreferrer" className="inline-flex text-sm text-foreground underline underline-offset-4">Open Muse</a>}
<p className="text-xs text-muted-foreground">Receiver contact is persisted in batches and may lag. Setup never creates a task. Provider usage and cost are unavailable.</p>
</>
: busy && !prompt && connection.phase === "waiting" ? <p role="status" className="text-sm text-muted-foreground">Preparing your invitation…</p> : dot ? <>
<div className="border-t pt-6"><DotConnectionChecks state={connection} />
{connection.phase === "waiting" && <p className="mt-4 text-xs text-muted-foreground">Dot uses your OpenAI account. Provider usage and cost aren’t reported to Paperclip.</p>}</div>
@@ -139,11 +232,20 @@ export function ExternalAgentInviteContent({
</> : <p className="text-sm leading-relaxed text-muted-foreground">The invitation includes everything your agent needs to join {companyName}. You can review its request before granting access.</p>}
</div>
<div className="flex items-center justify-between gap-3 border-t px-6 py-4 sm:px-8">
<Button variant="ghost" onClick={provider ? onBack : onClose}>
{provider && <ArrowLeft className="size-4" />}{provider ? "Back" : "Cancel"}
</Button>
<div className="flex items-center gap-2">
<Button variant="ghost" onClick={muse ? onClose : provider ? onBack : onClose}>
{provider && !muse && <ArrowLeft className="size-4" />}{muse ? ready ? "Close" : "Save & exit" : provider ? "Back" : "Cancel"}
</Button>
</div>
{provider && (approvalHref ? <Button asChild><Link to={approvalHref} onClick={onClose}>Review approval</Link></Button>
: museDetails ? <Button type="submit" form={detailsId} disabled={busy || !museDraft.name.trim()}>{busy ? "Preparing…" : "Continue"}</Button>
: ready ? <Button onClick={onClose}>Done</Button>
: museAwaitingInvitation ? <Button disabled={busy} onClick={onRetry}>{busy ? "Loading setup…" : "Refresh setup"}</Button>
: muse ? museConnection?.problem === "disconnected" || museConnection?.problem === "connection_expired" ? <Button disabled={busy} onClick={onNewPrompt}>Reconnect Muse</Button>
: museConnection?.problem === "prompt_unavailable" ? <Button disabled={busy} onClick={onNewPrompt}>Create a new prompt</Button>
: !museConnection?.paired && prompt ? <AgentSetupPrompt key={prompt} prompt={prompt} label="Copy setup prompt" title="Connect your Muse" agent={{ name: "Muse", icon: <Bot className="size-5" /> }} description="Send this whole prompt to your current Muse conversation." align="end" className="min-w-0 shrink" onCopied={onCopied} />
: busy ? <Button disabled>Preparing…</Button>
: <Button asChild><a href="https://muse.ai" target="_blank" rel="noopener noreferrer">Open Muse</a></Button>
: connecting ? <Button disabled aria-live="polite">
{!connection.problem && <Loader2 className="size-4 animate-spin motion-reduce:animate-none" aria-hidden="true" />}
{progressLabel}
@@ -0,0 +1,258 @@
// @vitest-environment jsdom
import { act, createElement, type ReactNode } from "react";
import { createRoot, type Root } from "react-dom/client";
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
import { afterEach, beforeEach, expect, it, vi } from "vitest";
import { ApiError } from "@/api/client";
import { ExternalAgentInviteDialog } from "./ExternalAgentInviteDialog";
import { museBinding, museConnection, musePairing } from "../../../storybook/support/external-agent-invite/muse-fixtures";
import { readMuseInvitationDraft } from "@/lib/muse-invitation-draft";
const identity = vi.hoisted(() => ({ userId: "operator", settled: true, failed: false }));
const settings = vi.hoisted(() => ({ enableMuse: true, enableNativeRunner: true, enableOpenAiDot: true, enablePublicMcp: true }));
const museApi = vi.hoisted(() => ({ resume: vi.fn(), create: vi.fn(), connection: vi.fn(), pair: vi.fn(), verify: vi.fn(), revoke: vi.fn(), attestStop: vi.fn() }));
const copy = vi.hoisted(() => vi.fn());
vi.mock("@/api/museInvitations", () => ({ museInvitationsApi: museApi }));
vi.mock("@/api/companies-query", () => ({ useAccountIdentity: () => identity }));
vi.mock("@/api/instanceSettings", () => ({ instanceSettingsApi: { getExperimental: async () => settings } }));
vi.mock("@/context/CompanyContext", () => ({ useCompany: () => ({ selectedCompany: { name: "Paperclip" } }) }));
vi.mock("@/lib/router", () => ({ Link: ({ to, children, ...props }: { to: string; children: ReactNode }) => createElement("a", { href: to, ...props }, children) }));
vi.mock("@/lib/clipboard", () => ({ copyTextToClipboard: copy }));
Object.assign(globalThis, { IS_REACT_ACT_ENVIRONMENT: true });
let root: Root; let container: HTMLDivElement; let cache: QueryClient;
const close = vi.fn();
const agent = { id: "muse-agent", name: "Maia", status: "idle" };
const pendingBinding = { ...museBinding, status: "pairing" as const, paired: false, receiverDetected: false };
async function flush() { await act(async () => {
if (vi.isFakeTimers()) await vi.advanceTimersByTimeAsync(30);
else await new Promise(resolve => setTimeout(resolve, 30));
}); }
async function render(companyId = "company") {
await act(async () => root.render(<QueryClientProvider client={cache}><ExternalAgentInviteDialog companyId={companyId} onClose={close} onBack={vi.fn()} /></QueryClientProvider>)); await flush();
}
async function click(label: string) {
const b = [...document.querySelectorAll("button")].find(button => button.textContent?.trim() === label || button.getAttribute("aria-label") === label);
expect(b, label).toBeTruthy(); await act(async () => b!.click()); await flush();
}
beforeEach(() => {
vi.clearAllMocks(); Object.values(museApi).forEach(mock => mock.mockReset());
settings.enableMuse = true; settings.enableNativeRunner = true; identity.userId = "operator";
localStorage.clear(); copy.mockResolvedValue(undefined);
museApi.resume.mockResolvedValue(null);
museApi.create.mockResolvedValue({ agent, approvalId: null, binding: null });
museApi.connection.mockResolvedValue({ ...museConnection, binding: null });
museApi.pair.mockImplementation(async () => {
museApi.connection.mockResolvedValue({ ...museConnection, binding: pendingBinding });
return { ...musePairing, expiresAt: new Date(Date.now() + 10 * 60_000).toISOString() };
});
container = document.createElement("div"); document.body.append(container); root = createRoot(container);
cache = new QueryClient({ defaultOptions: { queries: { retry: false, gcTime: 0 } } });
});
afterEach(async () => { await act(async () => root.unmount()); cache.clear(); container.remove(); localStorage.clear(); vi.useRealTimers(); });
it("gates Muse on both flags without changing Dot availability", async () => {
settings.enableNativeRunner = false; await render();
expect(document.body.textContent).toContain("Your Dot in ChatGPT");
expect(document.body.textContent).not.toContain("Muse — Personal agent");
expect(museApi.resume).not.toHaveBeenCalled();
});
it("retains a nonsecret name/role draft and has one aligned footer", async () => {
await render(); await click("Muse — Personal agentYour personal Muse at muse.ai");
const name = document.querySelector<HTMLInputElement>('input[placeholder="Muse"]')!;
await act(async () => {
Object.getOwnPropertyDescriptor(HTMLInputElement.prototype, "value")!.set!.call(name, "Maia");
name.dispatchEvent(new Event("input", { bubbles: true }));
});
const select = document.querySelector<HTMLSelectElement>("select")!;
await act(async () => { select.value = "researcher"; select.dispatchEvent(new Event("change", { bubbles: true })); });
expect(readMuseInvitationDraft("company", "operator")).toEqual({ name: "Maia", role: "researcher" });
const footer = [...document.querySelectorAll("button")].find(b => b.textContent === "Continue")!.parentElement!;
expect(footer.textContent).toContain("Save & exit");
await click("Continue");
expect(museApi.create).toHaveBeenCalledWith("company", { name: "Maia", role: "researcher" });
expect(museApi.pair).toHaveBeenCalledWith("company", agent.id, {});
expect(JSON.stringify(cache.getMutationCache().getAll().map(mutation => mutation.state))).not.toContain(musePairing.ticket);
expect(JSON.stringify(cache.getQueryCache().getAll().map(query => query.state.data))).not.toContain(musePairing.ticket);
expect([...Array(localStorage.length)].map((_, i) => localStorage.getItem(localStorage.key(i)!)).join()).not.toContain(musePairing.ticket);
});
it("resumes the server agent and replaces a stale prompt with the exact binding revision", async () => {
museApi.resume.mockResolvedValue({ agent, approvalId: null, binding: pendingBinding });
museApi.connection.mockResolvedValue({ ...museConnection, binding: pendingBinding });
await render(); await click("Muse — Personal agentYour personal Muse at muse.ai");
expect(museApi.create).not.toHaveBeenCalled();
expect(museApi.pair).toHaveBeenCalledWith("company", agent.id, { replaceBindingId: museBinding.id, expectedRevision: museBinding.revision });
await click("Copy setup prompt");
expect(copy).toHaveBeenCalledWith(musePairing.setupInstruction);
// Closing/reopening has no recoverable browser ticket; resume the same agent instead.
await act(async () => root.render(null)); await render(); await click("Muse — Personal agentYour personal Muse at muse.ai");
expect(museApi.create).not.toHaveBeenCalled();
expect(museApi.pair).toHaveBeenCalledTimes(2);
});
it("keeps every connection milestone behind the real hire approval", async () => {
museApi.resume.mockResolvedValue({ agent: { ...agent, status: "pending_approval" }, approvalId: "hire", binding: null });
museApi.connection.mockResolvedValue({ ...museConnection, agentStatus: "pending_approval", agentLifecycleState: "pending_approval", canConfigureConnection: false, binding: null });
await render(); await click("Muse — Personal agentYour personal Muse at muse.ai");
expect(document.body.textContent).toContain("needs to approve this agent before Muse can connect");
expect(document.querySelector('a[href="/approvals/hire"]')).not.toBeNull();
expect(museApi.pair).not.toHaveBeenCalled();
expect([...document.querySelectorAll("button")].some(b => b.textContent === "Done")).toBe(false);
});
it("keeps a resumed hire when a readiness refetch omits completed invitations", async () => {
museApi.resume.mockResolvedValue({ agent, approvalId: null, binding: museBinding });
museApi.connection.mockResolvedValue(museConnection);
museApi.verify.mockResolvedValue({ status: "pending" });
await render(); await click("Muse — Personal agentYour personal Muse at muse.ai");
museApi.resume.mockResolvedValue(null);
museApi.connection.mockResolvedValue({ ...museConnection, agentLifecycleState: "ready",
binding: { ...museBinding, status: "ready", backgroundReplyVerified: true } });
await act(async () => {
await Promise.all([
cache.invalidateQueries({ queryKey: ["muse-invitation", "company", "operator"] }),
cache.invalidateQueries({ queryKey: ["muse-binding", "company", "operator", agent.id] }),
]);
}); await flush();
expect(document.querySelector('input[placeholder="Muse"]')).toBeNull();
await click("Done");
expect(close).toHaveBeenCalledTimes(1);
expect(museApi.create).not.toHaveBeenCalled();
});
it("starts one independent background challenge after fresh persisted pairing and receiver evidence", async () => {
museApi.resume.mockResolvedValue({ agent, approvalId: null, binding: museBinding });
museApi.connection.mockResolvedValue(museConnection);
museApi.verify.mockResolvedValue({ status: "pending" });
await render(); await click("Muse — Personal agentYour personal Muse at muse.ai");
expect(museApi.verify).toHaveBeenCalledWith("company", agent.id, { bindingId: museBinding.id, generation: museBinding.generation, expectedRevision: museBinding.revision });
await act(async () => cache.invalidateQueries({ queryKey: ["muse-binding", "company", "operator", agent.id] })); await flush();
expect(museApi.verify).toHaveBeenCalledTimes(1);
expect(museApi.pair).not.toHaveBeenCalled();
expect(document.body.textContent).toContain("Waiting for Muse to reply to the background test");
expect(document.body.textContent).not.toContain("Muse is ready for tasks");
});
it("shows an actionable authenticated-instance requirement before an invitation exists", async () => {
museApi.resume.mockRejectedValue(new ApiError("Sign in as a company operator to configure Muse.", 403, {}));
await render(); await click("Muse — Personal agentYour personal Muse at muse.ai");
expect(document.body.textContent).toContain("Local trusted access alone cannot connect Muse");
expect(document.body.textContent).not.toContain("Watching for Muse to pair");
expect(document.body.textContent).not.toContain("Copy the setup prompt");
expect(document.querySelector('[aria-label="Muse connection checks"]')).toBeNull();
expect(document.querySelector('a[href="https://muse.ai"]')).toBeNull();
expect(museApi.create).not.toHaveBeenCalled();
museApi.resume.mockResolvedValue(null); await click("Refresh setup");
expect(document.querySelector('input[placeholder="Muse"]')).not.toBeNull();
});
it("does not show waiting milestones while the initial invitation request is loading", async () => {
museApi.resume.mockReturnValue(new Promise(() => {}));
await render(); await click("Muse — Personal agentYour personal Muse at muse.ai");
expect(document.body.textContent).toContain("Loading Muse invitation");
expect(document.querySelector('[aria-label="Muse connection checks"]')).toBeNull();
expect(document.querySelector('a[href="https://muse.ai"]')).toBeNull();
});
it("explains a missing public HTTPS URL and refreshes without preparing a ticket or another agent", async () => {
museApi.resume.mockResolvedValue({ agent, approvalId: null, binding: null });
museApi.connection.mockResolvedValue({ ...museConnection, publicOrigin: null, binding: null });
await render(); await click("Muse — Personal agentYour personal Muse at muse.ai");
expect(document.body.textContent).toContain("This instance has no public HTTPS URL for Muse");
expect(document.body.textContent).toContain("configure a stable public HTTPS address, then refresh setup");
expect(document.body.textContent).not.toContain("Copy the setup prompt");
expect(document.querySelector('[aria-label="Muse connection checks"]')).toBeNull();
expect(document.querySelector('a[href="https://muse.ai"]')).toBeNull();
expect(museApi.pair).not.toHaveBeenCalled();
museApi.connection.mockResolvedValue({ ...museConnection, binding: null });
await click("Refresh setup");
expect(museApi.pair).toHaveBeenCalledWith("company", agent.id, {});
expect(museApi.pair).toHaveBeenCalledTimes(1);
expect(museApi.create).not.toHaveBeenCalled();
});
it("reconnects the resumed Muse agent without replacing its revoked binding", async () => {
const revoked = { ...museBinding, status: "revoked", revision: 8 };
museApi.resume.mockResolvedValue({ agent, approvalId: null, binding: revoked });
museApi.connection.mockResolvedValue({ ...museConnection, binding: revoked });
await render(); await click("Muse — Personal agentYour personal Muse at muse.ai");
expect(museApi.pair).not.toHaveBeenCalled();
await click("Reconnect Muse");
expect(museApi.pair).toHaveBeenCalledWith("company", agent.id, {});
expect(museApi.create).not.toHaveBeenCalled();
});
it("continues the same newly hired Muse after approval in another window", async () => {
vi.useFakeTimers();
const awaitingApproval = { agent: { ...agent, status: "pending_approval" }, approvalId: "hire", binding: null };
museApi.create.mockResolvedValue(awaitingApproval);
museApi.resume.mockResolvedValueOnce(null).mockResolvedValue(awaitingApproval);
museApi.connection.mockResolvedValue({ ...museConnection, agentStatus: "pending_approval",
agentLifecycleState: "pending_approval", canConfigureConnection: false, binding: null });
await render(); await click("Muse — Personal agentYour personal Muse at muse.ai");
const name = document.querySelector<HTMLInputElement>('input[placeholder="Muse"]')!;
await act(async () => {
Object.getOwnPropertyDescriptor(HTMLInputElement.prototype, "value")!.set!.call(name, "Maia");
name.dispatchEvent(new Event("input", { bubbles: true }));
});
await click("Continue");
expect(museApi.create).toHaveBeenCalledWith("company", { name: "Maia", role: "general" });
expect(document.querySelector('a[href="/approvals/hire"]')).not.toBeNull();
expect(museApi.pair).not.toHaveBeenCalled();
const readsBeforeApproval = museApi.resume.mock.calls.length;
await act(async () => { await vi.advanceTimersByTimeAsync(2500); }); await flush();
expect(museApi.resume.mock.calls.length).toBeGreaterThan(readsBeforeApproval);
// Approval is completed elsewhere; this mounted dialog must discover it without another hire.
museApi.resume.mockResolvedValue({ agent, approvalId: "hire", binding: null });
museApi.connection.mockResolvedValue({ ...museConnection, binding: null });
await act(async () => { await vi.advanceTimersByTimeAsync(5000); }); await flush();
expect(document.querySelector('a[href="/approvals/hire"]')).toBeNull();
expect(museApi.pair).toHaveBeenCalledWith("company", agent.id, {});
expect(museApi.pair).toHaveBeenCalledTimes(1);
expect(document.body.textContent).toContain("Copy setup prompt");
expect(museApi.create).toHaveBeenCalledTimes(1);
// The resume endpoint omits completed invitations, but this mounted hire must still finish.
museApi.resume.mockResolvedValue(null);
museApi.connection.mockResolvedValue({ ...museConnection, agentLifecycleState: "ready",
binding: { ...museBinding, status: "ready", backgroundReplyVerified: true } });
await act(async () => {
await Promise.all([
cache.invalidateQueries({ queryKey: ["muse-invitation", "company", "operator"] }),
cache.invalidateQueries({ queryKey: ["muse-binding", "company", "operator", agent.id] }),
]);
}); await flush();
expect([...document.querySelectorAll("button")].some(b => b.textContent === "Done")).toBe(true);
expect(document.querySelector('input[placeholder="Muse"]')).toBeNull();
expect(museApi.create).toHaveBeenCalledTimes(1);
});
it("reconnects an expired Vault connection on the existing agent without a background-test loop", async () => {
const expired = { ...museBinding, credentialState: "expired", refreshExpiresAt: null };
museApi.resume.mockResolvedValue({ agent, approvalId: null, binding: expired });
museApi.connection.mockResolvedValue({ ...museConnection, binding: expired });
await render(); await click("Muse — Personal agentYour personal Muse at muse.ai");
expect(document.body.textContent).toContain("This Muse connection expired.");
expect(museApi.verify).not.toHaveBeenCalled();
expect(museApi.pair).not.toHaveBeenCalled();
await click("Reconnect Muse");
expect(museApi.pair).toHaveBeenCalledWith("company", agent.id, {
replaceBindingId: museBinding.id, expectedRevision: museBinding.revision,
});
expect(museApi.create).not.toHaveBeenCalled();
});
it("shows a failed initial connection read without a binding and refreshes the same invitation", async () => {
museApi.resume.mockResolvedValue({ agent, approvalId: null, binding: null });
museApi.connection.mockRejectedValue(new Error("Synthetic Muse connection read failed"));
await render(); await click("Muse — Personal agentYour personal Muse at muse.ai");
expect(document.body.textContent).toContain("Synthetic Muse connection read failed");
expect(document.body.textContent).toContain("Connection updates paused. Refresh to check the latest evidence.");
expect(document.querySelector('[aria-label="Muse connection checks"]')).not.toBeNull();
expect(museApi.pair).not.toHaveBeenCalled();
expect(museApi.verify).not.toHaveBeenCalled();
expect(museApi.create).not.toHaveBeenCalled();
museApi.connection.mockResolvedValue({ ...museConnection, binding: null });
await click("Refresh connection");
expect(document.body.textContent).not.toContain("Synthetic Muse connection read failed");
expect(document.body.textContent).not.toContain("Connection updates paused");
expect(museApi.pair).toHaveBeenCalledExactlyOnceWith("company", agent.id, {});
expect(museApi.create).not.toHaveBeenCalled();
});
@@ -1,5 +1,11 @@
import { useEffect, useRef, useState } from "react";
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import type { MuseInvitation } from "@paperclipai/shared";
import { ApiError } from "@/api/client";
import { museInvitationsApi } from "@/api/museInvitations";
import { useAccountIdentity } from "@/api/companies-query";
import { useMuseConnection, useMuseConnectionState } from "@/hooks/useMuseConnection";
import { clearMuseInvitationDraft, readMuseInvitationDraft, saveMuseInvitationDraft, type MuseInvitationDraft } from "@/lib/muse-invitation-draft";
import { accessApi } from "@/api/access";
import { dotInvitationsApi, type DotInvitation, type DotPairing } from "@/api/dotInvitations";
import { instanceSettingsApi } from "@/api/instanceSettings";
@@ -13,14 +19,16 @@ import { Dialog } from "../ui/dialog";
import { ExternalAgentInviteContent, type DotConnectionState, type ExternalAgentPreset } from "./ExternalAgentInviteContent";
/** Pairing secrets live only in this mounted dialog. Reloads resume the agent, never duplicate it. */
export function ExternalAgentInviteDialog({ companyId, onClose, onBack }: {
export function ExternalAgentInviteDialog({ companyId, onClose, onBack, initialPreset = null, initialMuseName = "" }: {
companyId: string;
initialPreset?: ExternalAgentPreset | null;
initialMuseName?: string;
onClose: () => void;
onBack: () => void;
}) {
const cache = useQueryClient();
const { selectedCompany } = useCompany();
const [preset, setPreset] = useState<ExternalAgentPreset | null>(null);
const [preset, setPreset] = useState<ExternalAgentPreset | null>(initialPreset);
const [invitation, setInvitation] = useState<DotInvitation | null>(null);
const [pairing, setPairing] = useState<DotPairing | null>(null);
const [expiredPairingBindingId, setExpiredPairingBindingId] = useState<string | null>(null);
@@ -118,6 +126,7 @@ export function ExternalAgentInviteDialog({ companyId, onClose, onBack }: {
const busy = generate.isPending || pair.isPending || test.isPending || preparePairing
|| (preset === "dot" && !!invitation && (state.isPending || (!prompt && state.isFetching)));
const retry = () => {
if (preset === "muse") { void experimental.refetch(); return; }
if (unavailable) { void state.refetch(); return; }
if (state.error) { void state.refetch(); return; }
if (generate.error || !invitation) { if (preset) generate.mutate(preset); return; }
@@ -125,14 +134,106 @@ export function ExternalAgentInviteDialog({ companyId, onClose, onBack }: {
test.mutate();
};
return <Dialog open onOpenChange={open => { if (!open) onClose(); }}>
<AnimatedDialogContent className="sm:max-w-(--sz-560px)">
{preset === "grok_bot" ? <GrokBotSetup companyId={companyId} companyName={selectedCompany?.name ?? "your organization"} onClose={onClose} onBack={() => setPreset(null)} /> : <ExternalAgentInviteContent preset={preset} prompt={prompt} companyName={selectedCompany?.name ?? "your organization"}
connection={connection} dotDisabledReason={dotDisabledReason} grokDisabledReason={grokDisabledReason} busy={busy}
error={error?.message ?? (unavailable ? state.data?.agentLifecycleState === "terminated" ? "This agent was terminated. Invite another agent to connect Dot." : "Resume this agent before connecting Dot." : undefined)}
<AnimatedDialogContent className={preset === "grok_bot" ? "sm:max-w-(--sz-560px)" : "flex max-h-(--sz-calc-18) flex-col gap-0 overflow-hidden p-0 sm:max-w-(--sz-560px)"}>
{preset === "muse" && experimental.data?.enableMuse && experimental.data?.enableNativeRunner ? <MuseInvitationController initialName={initialMuseName} companyId={companyId} companyName={selectedCompany?.name ?? "your organization"} onClose={onClose} onBack={() => setPreset(null)} /> :
preset === "grok_bot" ? <GrokBotSetup companyId={companyId} companyName={selectedCompany?.name ?? "your organization"} onClose={onClose} onBack={() => setPreset(null)} /> :
<ExternalAgentInviteContent preset={preset === "muse" ? null : preset} prompt={prompt} companyName={selectedCompany?.name ?? "your organization"}
connection={connection} museEnabled={experimental.data?.enableMuse === true && experimental.data?.enableNativeRunner === true} dotDisabledReason={dotDisabledReason} grokDisabledReason={grokDisabledReason} busy={busy}
error={preset === "muse" ? experimental.isPending ? undefined : "Enable Muse and Paperclip Runner in experimental settings to connect your personal Muse." : error?.message ?? (unavailable ? state.data?.agentLifecycleState === "terminated" ? "This agent was terminated. Invite another agent to connect Dot." : "Resume this agent before connecting Dot." : undefined)}
approvalHref={pendingApproval && invitation?.approvalId ? `/approvals/${invitation.approvalId}` : undefined}
onSelect={kind => { setPreset(kind); if (kind !== "grok_bot" && (kind === "dot" ? !invitation : !genericPrompt)) generate.mutate(kind); }}
onSelect={kind => { setPreset(kind); if (kind !== "muse" && kind !== "grok_bot" && (kind === "dot" ? !invitation : !genericPrompt)) generate.mutate(kind); }}
onBack={() => setPreset(null)} onClose={preset ? onClose : onBack} onCopied={() => { if (preset === "dot") void state.refetch(); }}
onRetry={retry} onNewPrompt={() => pair.mutate(binding?.id)} />}
</AnimatedDialogContent>
</Dialog>;
}
function MuseInvitationController({ companyId, companyName, onClose, onBack, initialName }: {
companyId: string; companyName: string; initialName: string; onClose: () => void; onBack: () => void;
}) {
const cache = useQueryClient();
const identity = useAccountIdentity();
const scopeKey = `${companyId}:${identity.userId}`;
const loadDraft = () => { const value = readMuseInvitationDraft(companyId, identity.userId); return { ...value, name: value.name || initialName }; };
const [draftState, setDraftState] = useState(() => ({ scopeKey, value: loadDraft() }));
if (draftState.scopeKey !== scopeKey) setDraftState({ scopeKey, value: loadDraft() });
const draft = draftState.value;
const [created, setCreated] = useState<{ scopeKey: string; invitation: MuseInvitation } | null>(null);
const currentScope = useRef(scopeKey); currentScope.current = scopeKey;
const invitationKey = ["muse-invitation", companyId, identity.userId];
const resume = useQuery({ queryKey: invitationKey,
queryFn: ({ signal }) => museInvitationsApi.resume(companyId, signal), enabled: identity.settled,
retry: false, staleTime: 0, refetchOnMount: "always",
refetchInterval: query => query.state.data?.agent.status === "pending_approval" ? 2500 : false });
const resumedInvitation = resume.isFetchedAfterMount ? resume.data : null;
useEffect(() => {
if (resumedInvitation) setCreated(current => current?.scopeKey === scopeKey
? current : { scopeKey, invitation: resumedInvitation });
}, [scopeKey, resumedInvitation]);
const createdInvitation = created?.scopeKey === scopeKey ? created.invitation : null;
// Resume omits completed bindings; keep this mounted hire while accepting its fresh server status.
const invitation = createdInvitation
? resumedInvitation?.agent.id === createdInvitation.agent.id ? resumedInvitation : createdInvitation
: resumedInvitation;
const muse = useMuseConnection(companyId, invitation?.agent.id);
const attemptedAutomaticPairing = useRef(false);
const attemptedAutomaticVerification = useRef<string | null>(null);
useEffect(() => { attemptedAutomaticPairing.current = false; attemptedAutomaticVerification.current = null; }, [scopeKey, invitation?.agent.id]);
const create = useMutation({ mutationFn: async (input: MuseInvitationDraft) => {
const requestScope = scopeKey;
const result = await museInvitationsApi.create(companyId, { name: input.name.trim(), role: input.role });
if (currentScope.current === requestScope) {
setCreated({ scopeKey: requestScope, invitation: result });
cache.setQueryData(invitationKey, result);
void cache.invalidateQueries({ queryKey: invitationKey, exact: true });
}
void cache.invalidateQueries({ queryKey: queryKeys.agents.list(companyId) });
// The creation receipt contains no ticket; pairing happens in the mounted connection hook.
}, gcTime: 0 });
const b = muse.binding;
const canPrepare = !!invitation && !muse.state.isError && muse.state.data?.enabled && muse.state.data.canConfigureConnection
&& !!muse.state.data.publicOrigin && (!b || b.status === "pairing");
useEffect(() => {
if (canPrepare && muse.state.isFetchedAfterMount && !muse.state.isFetching && !muse.pair.isPending
&& !muse.pairing && !attemptedAutomaticPairing.current) {
attemptedAutomaticPairing.current = true;
muse.repair();
}
}, [canPrepare, muse.state.isFetchedAfterMount, muse.state.isFetching, muse.pair.isPending, muse.pairing, b?.id, b?.revision]);
useEffect(() => {
if (!muse.state.isError && b?.paired && b.credentialState === "active" && b.receiverDetected && !b.backgroundReplyVerified && !b.challengeExpiresAt
&& muse.state.data?.enabled && muse.state.data.canConfigureConnection
&& muse.state.isFetchedAfterMount && !muse.state.isFetching && !muse.verify.isPending && !muse.verify.isError) {
const identity = `${b.id}:${b.generation}`;
if (attemptedAutomaticVerification.current !== identity) {
attemptedAutomaticVerification.current = identity;
muse.verify.mutate(b);
}
}
}, [b, muse.state.data, muse.state.isError, muse.state.isFetchedAfterMount, muse.state.isFetching, muse.verify.isPending, muse.verify.isError]);
const pendingApproval = (muse.state.data?.agentStatus ?? invitation?.agent.status) === "pending_approval";
const busy = create.isPending || muse.pair.isPending || muse.verify.isPending || (!invitation && (resume.isPending && !identity.failed || !identity.settled && !identity.failed))
|| (!!invitation && muse.state.isPending);
const promptUnavailable = !!b && !b.paired && b.status === "pairing" && !muse.pairing
&& attemptedAutomaticPairing.current && !muse.pair.isPending && !muse.state.isFetching;
const connection = useMuseConnectionState(muse.state.data, { offline: muse.state.isError, promptUnavailable });
const verifyError = muse.verify.error instanceof ApiError && muse.verify.error.status === 409 && b?.challengeExpiresAt
&& Date.parse(b.challengeExpiresAt) > Date.now() ? null : muse.verify.error;
const error = create.error ?? resume.error ?? muse.state.error ?? muse.pair.error ?? verifyError;
const operatorRequired = error instanceof ApiError && (error.status === 401 || error.status === 403)
&& !invitation;
const missingPublicOrigin = !!invitation && muse.state.isSuccess && !muse.state.data.publicOrigin;
const errorMessage = missingPublicOrigin ? "This instance has no public HTTPS URL for Muse. Ask your instance administrator to configure a stable public HTTPS address, then refresh setup. Muse cannot connect through a local-only address." : operatorRequired ? "Use an authenticated Paperclip instance with a public HTTPS URL and a company operator account. Local trusted access alone cannot connect Muse." : error?.message;
return <ExternalAgentInviteContent preset="muse" companyName={companyName} prompt={muse.pairing?.setupInstruction ?? ""}
connection={{ phase: "waiting" }} museConnection={invitation && !missingPublicOrigin && (muse.state.data || muse.state.isError) ? connection : undefined} busy={busy} error={identity.failed ? "Unable to confirm your account. Refresh to resume Muse setup." : errorMessage}
approvalHref={pendingApproval && invitation?.approvalId ? `/approvals/${invitation.approvalId}` : undefined}
museDraft={!invitation && resume.isSuccess && identity.settled ? draft : undefined}
onMuseDraftChange={value => { setDraftState({ scopeKey, value }); saveMuseInvitationDraft(companyId, identity.userId, value); }}
onMuseContinue={() => create.mutate(draft)} onSelect={() => {}} onBack={onBack}
onClose={() => { if (connection.ready) { clearMuseInvitationDraft(companyId, identity.userId); void cache.invalidateQueries({ queryKey: queryKeys.agents.list(companyId) }); } onClose(); }}
onCopied={() => { void muse.state.refetch(); }}
onRetry={() => { if (identity.failed) { void cache.invalidateQueries({ queryKey: queryKeys.auth.session }); return; } if (resume.isError) { void resume.refetch(); return; } if (missingPublicOrigin) { void muse.state.refetch(); return; } if (create.isError && !invitation) { create.mutate(draft); return; }
if (muse.state.isError) { void muse.state.refetch(); return; } if (b?.paired) muse.verify.mutate(b); else muse.repair(); }}
onNewPrompt={() => muse.repair()} />;
}
@@ -1,3 +1,4 @@
import { ExternalAgentInviteDialog } from "./ExternalAgentInviteDialog";
import { useConnectionModels } from "../ai-connections/useConnectionModels";
import { AgentCharacter } from "../AgentCharacter";
import { useAgentAppearanceDraft } from "../../hooks/useAgentAppearanceDraft";
@@ -79,12 +80,14 @@ const blocking = (result: AdapterEnvironmentTestResult) =>
export function NewAgentSetup() {
const { selectedCompanyId } = useCompany();
const [params] = useSearchParams();
const navigate = useNavigate();
if (!selectedCompanyId)
return (
<p className="text-sm text-muted-foreground">
Select an organization to create an agent.
</p>
);
if (params.get("adapterType") === "muse" || params.get("runnerProvider") === "muse") return <ExternalAgentInviteDialog companyId={selectedCompanyId} initialPreset="muse" initialMuseName={params.get("name") ?? ""} onClose={() => navigate("/agents/all")} onBack={() => navigate("/agents/all")} />;
return (
<Setup
key={`${selectedCompanyId}:${params.get("name")}:${params.get("adapterType")}:${params.get("runnerProvider")}:${params.get("runner")}`}
@@ -8,6 +8,7 @@ import type { Agent } from "@paperclipai/shared";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { agentsApi } from "@/api/agents";
import { ComposerRunSettingsPicker } from "./ComposerRunSettingsPicker";
import type { ComposerRunSettings } from "./composer-run-settings";
import { getLastComposerEffort, rememberComposerEffort } from "@/lib/recent-composer-effort";
import { Dialog, DialogContent, DialogTitle } from "@/components/ui/dialog";
@@ -300,6 +301,55 @@ describe("composer assignee picker", () => {
expect(loadModels).not.toHaveBeenCalled();
});
it.each([false, true])("hides unsupported Muse settings without loading a catalog (mobile: %s)", async (mobile) => {
const loadModels = vi.spyOn(agentsApi, "adapterModels").mockResolvedValue([]);
const onSettingsChange = vi.fn();
const muse = { ...agent, adapterType: "paperclip_runner", adapterConfig: { provider: "muse", model: "old-model" } } as Agent;
rememberComposerEffort("company-1", "high");
render(vi.fn(), onSettingsChange, true, { mobile, settings: null, agents: new Map([[muse.id, muse]]) });
expect(container!.querySelector('[aria-label="Select model and effort"]')).toBeNull();
expect(container!.querySelector('[data-testid="task-chat-composer-model-label"]')).toBeNull();
await click("Select assignee");
expect(document.querySelector('[aria-label="Search assignees"]')).not.toBeNull();
expect(document.querySelector('[aria-label="Choose exact model"]')).toBeNull();
expect(document.querySelector('[aria-label="Effort"]')).toBeNull();
expect(loadModels).not.toHaveBeenCalled();
expect(onSettingsChange).not.toHaveBeenCalled();
});
it.each([false, true])("removes an open model menu when the same agent switches to Muse (mobile: %s)", async (mobile) => {
const onSettingsChange = vi.fn();
function ControlledPicker() {
const [muse, setMuse] = useState(false);
const [settings, setSettings] = useState<ComposerRunSettings | null>({ model: "gpt-6-sol", effort: "high", fast: true });
const selectedAgent = muse
? { ...agent, adapterType: "paperclip_runner", adapterConfig: { provider: "muse" } } as Agent
: agent;
return <>
<button type="button" aria-label="Switch to Muse" onClick={() => setMuse(true)}>Switch to Muse</button>
<ComposerRunSettingsPicker companyId="company-1" assigneeValue="agent:a1" currentAssigneeValue="agent:a1"
options={options} agents={new Map([[agent.id, selectedAgent]])} settings={settings}
onSettingsChange={(next) => { onSettingsChange(next); setSettings(next); }} onAssigneeChange={vi.fn()} mobile={mobile}
modelOptionsOverride={[{ id: "gpt-6-sol", label: "GPT-6 Sol" }]} />
</>;
}
container = document.createElement("div");
document.body.appendChild(container);
root = createRoot(container);
flushSync(() => root!.render(<QueryClientProvider client={new QueryClient()}><ControlledPicker /></QueryClientProvider>));
await click("Select model and effort");
await click("Choose exact model");
expect(document.querySelector('[aria-label="Search or paste a model ID"]')).not.toBeNull();
await click("Switch to Muse");
expect(container!.querySelector('[aria-label="Select model and effort"]')).toBeNull();
expect(document.querySelector('[aria-label="Search or paste a model ID"]')).toBeNull();
expect(document.querySelector('[aria-label="Choose exact model"]')).toBeNull();
expect(document.querySelector('[aria-label="Effort"]')).toBeNull();
expect(document.querySelector('[data-testid="model-unavailable"]')?.textContent).toContain("Muse chooses its model and effort");
expect(onSettingsChange).toHaveBeenCalledOnce();
expect(onSettingsChange).toHaveBeenCalledWith(null);
});
it("opens the assignee list directly on mobile", async () => {
render(vi.fn(), vi.fn(), false, { mobile: true });
await click("Select assignee");
@@ -60,6 +60,7 @@ function harnessLabel(agent: Agent | undefined): string {
function unavailableModelReason(agent: Agent | undefined): string {
if (!agent) return "Choose an agent to select its model and effort.";
if (agent.adapterType === "paperclip_runner" && agent.adapterConfig.provider === "muse") return "Muse chooses its model and effort. Paperclip has no per-task override.";
if (agent?.adapterType === "process") return "This agent runs a command. Its harness does not expose a model or effort setting.";
if (agent?.adapterType === "http") return "This agent calls an HTTP endpoint. The destination service chooses its model.";
return "This gateway chooses its model remotely; Paperclip has no per-task model setting for it.";
@@ -86,7 +87,7 @@ export function ComposerRunSettingsPicker({
initialOpen = false, initialView = "settings", initialModelSearch = "", initialAssigneeSearch = "",
}: Props) {
const [open, setOpen] = useState(initialOpen);
const [view, setView] = useState<"settings" | "agents" | "models">(initialView);
const [requestedView, setView] = useState<"settings" | "agents" | "models">(initialView);
const [modelSearch, setModelSearch] = useState(initialModelSearch);
const [assigneeSearch, setAssigneeSearch] = useState(initialAssigneeSearch);
const [highlightedAssignee, setHighlightedAssignee] = useState(0);
@@ -103,7 +104,12 @@ export function ComposerRunSettingsPicker({
const agentId = assigneeValue.startsWith("agent:") ? assigneeValue.slice(6) : "";
const agent = agents.get(agentId);
const modelSupported = supportsComposerModel(agent);
const view = requestedView === "models" && !modelSupported ? "settings" : requestedView;
const provider = composerCatalogProvider(agent);
const isMuseRunner = agent?.adapterType === "paperclip_runner" && provider === "muse";
useEffect(() => {
if (isMuseRunner && settings !== null) onSettingsChange(null);
}, [isMuseRunner, settings, onSettingsChange]);
const binding = aiRuntimeConnectionBindingSchema.safeParse(agent?.runtimeConfig?.aiConnection).data;
const poolId = binding?.mode === "router" ? binding.connectionId : undefined;
const { data: fetchedModels = [], isPending: modelsPending } = useQuery({
@@ -262,6 +268,6 @@ export function ComposerRunSettingsPicker({
const onOpenChange = (next: boolean) => { setOpen(next); if (!next) { setView("settings"); setModelSearch(""); setAssigneeSearch(""); } };
// The desktop portal needs its own scroll lock inside the new-task dialog.
return mobile ? <Dialog open={open} onOpenChange={onOpenChange}>{triggers}<DialogContent aria-describedby={undefined} showCloseButton={false} style={mobileViewportStyle} className="composer-mobile-dialog gap-0 overflow-hidden p-0" data-testid="composer-mobile-dialog"><DialogTitle className="sr-only">{view === "agents" ? "Select assignee" : "Select model and effort"}</DialogTitle><AnimatedBody>{body}</AnimatedBody></DialogContent></Dialog>
return mobile ? <Dialog open={open} onOpenChange={onOpenChange}>{triggers}<DialogContent aria-describedby={undefined} showCloseButton={false} style={mobileViewportStyle} className="composer-mobile-dialog gap-0 overflow-hidden p-0" data-testid="composer-mobile-dialog"><DialogTitle className="sr-only">{view === "agents" ? "Select assignee" : modelSupported ? "Select model and effort" : "Agent run settings"}</DialogTitle><AnimatedBody>{body}</AnimatedBody></DialogContent></Dialog>
: <Popover modal open={open} onOpenChange={onOpenChange}><PopoverAnchor asChild>{triggers}</PopoverAnchor><PopoverContent side="top" align="end" sideOffset={8} className="w-80 max-w-full p-0 shadow-sm" data-testid="composer-model-popover"><AnimatedBody>{body}</AnimatedBody></PopoverContent></Popover>;
}
@@ -164,6 +164,33 @@ describe("TaskChatRunnerTurn", () => {
},
);
it.each(["Muse", "Dot"])("shows Waiting for %s until native acceptance, without a Thinking claim", provider => {
const waiting = { phase: "waiting_for_provider", label: `Waiting for ${provider}` } as ExecutionProjection;
render([], "running", "run-1", undefined, false, false, waiting);
expect(container.querySelector('[data-testid="task-chat-turn-status-header"]')?.textContent)
.toContain(`Waiting for ${provider}`);
const tail = container.querySelector('[data-testid="task-chat-current-activity-label"]');
expect(tail?.textContent).toBe(`Waiting for ${provider}`);
expect(tail?.classList.contains("shimmer-text")).toBe(false);
expect(container.textContent).not.toContain("Working");
expect(container.textContent).not.toContain("Thinking");
// Re-render the same live turn after the native acceptance projection changes.
render([], "running", "run-1", undefined, false, false,
{ phase: "working", label: "Working" } as ExecutionProjection);
expect(container.querySelector('[data-testid="task-chat-turn-status-header"]')?.textContent)
.toContain("Working for");
expect(container.querySelector('[data-testid="task-chat-current-activity-label"]')?.textContent)
.toBe("Thinking");
// A stale waiting projection must never override a terminal run outcome.
render([], "failed", "run-1", undefined, false, false, waiting);
expect(container.querySelector('[data-testid="task-chat-turn-status-header"]')?.textContent)
.toContain("Stopped");
expect(container.querySelector('[data-testid="task-chat-current-activity"]')).toBeNull();
expect(container.textContent).not.toContain(`Waiting for ${provider}`);
});
it("labels the streaming tail as a continuation after steering", () => {
render([], "running", "run-1", undefined, false, true);
@@ -66,11 +66,13 @@ function RunnerTurnStatus({
startedAtMs,
finishedAtMs,
continuedAfterSteering = false,
waitingLabel,
}: {
status: string;
startedAtMs: number | null;
finishedAtMs?: number | null;
continuedAfterSteering?: boolean;
waitingLabel?: string | null;
}) {
const terminal = isTerminalRunStatus(status);
useSecondTick(!terminal && startedAtMs != null);
@@ -84,12 +86,14 @@ function RunnerTurnStatus({
const elapsed = formatCompactDuration(elapsedMs);
const failed = terminalStatusFailed(status);
const label = terminal ? (failed ? "Stopped" : "Worked") : "Working";
const label = terminal ? (failed ? "Stopped" : "Worked") : waitingLabel ?? "Working";
const semanticLabel = terminal
? elapsed
? `${label} ${failed ? "after" : "for"} ${elapsed}`
: label
: `${label} for ${elapsed ?? "0s"}`;
: waitingLabel
? `${label}${elapsed ? ` · ${elapsed}` : ""}`
: `${label} for ${elapsed ?? "0s"}`;
const visibleLabel = continuedAfterSteering
? `Continued after steering · ${semanticLabel}`
: semanticLabel;
@@ -107,10 +111,10 @@ function RunnerTurnStatus({
);
}
function RunnerCurrentActivityTail({ status }: { status: string }) {
function RunnerCurrentActivityTail({ status, waitingLabel }: { status: string; waitingLabel?: string | null }) {
if (isTerminalRunStatus(status)) return null;
return <div className="mt-2 flex min-h-8 min-w-0 items-center gap-2 px-1 py-1 text-xs text-muted-foreground" data-testid="task-chat-current-activity" data-turn-position="tail">
<span className="shimmer-text shimmer-text-muted" aria-live="polite" data-testid="task-chat-current-activity-label">Thinking</span>
<span className={waitingLabel ? undefined : "shimmer-text shimmer-text-muted"} aria-live="polite" data-testid="task-chat-current-activity-label">{waitingLabel ?? "Thinking"}</span>
</div>;
}
@@ -121,6 +125,7 @@ export function TaskChatRunnerTurn({
agent,
items,
status,
execution,
startedAtMs,
finishedAtMs,
activityUnavailable = false,
@@ -149,6 +154,7 @@ export function TaskChatRunnerTurn({
) => void | Promise<void>;
}) {
const terminal = isTerminalRunStatus(status);
const waitingLabel = !terminal && execution?.phase === "waiting_for_provider" ? execution.label : null;
const yielded = items.some(
(item) =>
item.kind === "protocol" &&
@@ -222,6 +228,7 @@ export function TaskChatRunnerTurn({
startedAtMs={startedAtMs}
finishedAtMs={finishedAtMs}
continuedAfterSteering={continuedAfterSteering}
waitingLabel={waitingLabel}
/>
</div>
{activityUnavailable ? (
@@ -280,7 +287,7 @@ export function TaskChatRunnerTurn({
/>
</div>
) : null}
{!final && currentActivityItems.length === 0 ? <RunnerCurrentActivityTail status={status} /> : null}
{!final && currentActivityItems.length === 0 ? <RunnerCurrentActivityTail status={status} waitingLabel={waitingLabel} /> : null}
</div>
);
}
@@ -23,7 +23,8 @@ const MODEL_ADAPTERS = new Set([
]);
export function supportsComposerModel(agent: Agent | undefined): boolean {
return Boolean(agent && MODEL_ADAPTERS.has(agent.adapterType));
return Boolean(agent && MODEL_ADAPTERS.has(agent.adapterType)
&& !(agent.adapterType === "paperclip_runner" && agent.adapterConfig.provider === "muse"));
}
/** Resolve visible defaults; host environment and a local CLI's own config are unknown. */
+91
View File
@@ -0,0 +1,91 @@
import { useEffect, useRef, useState } from "react";
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import type { MuseConnection, MuseBinding, MusePairing, MusePairingInput, MuseAttestStopInput, MuseDetectorCleanupAttestationInput } from "@paperclipai/shared";
import { museInvitationsApi } from "@/api/museInvitations";
import { useAccountIdentity } from "@/api/companies-query";
import type { MuseConnectionState } from "@/components/new-agent/ExternalAgentInviteContent";
export function museConnectionState(connection: MuseConnection | undefined,
{ offline = false, promptUnavailable = false }: { offline?: boolean; promptUnavailable?: boolean } = {}): MuseConnectionState {
const b = connection?.binding;
const passed = !!b?.paired && b.receiverDetected && b.backgroundReplyVerified;
const finishing = connection?.canConfigureConnection && ["preparing", "verifying"].includes(connection.agentLifecycleState);
const ready = passed && b.credentialState === "active" && b.status === "ready" && connection?.agentLifecycleState === "ready"
&& connection.canConfigureConnection && connection.enabled && connection.agentStatus !== "pending_approval";
const testing = !!b?.challengeExpiresAt && Date.parse(b.challengeExpiresAt) > Date.now();
const expiredTest = !!b?.challengeExpiresAt && Date.parse(b.challengeExpiresAt) <= Date.now();
return {
paired: b?.paired ?? false, receiverDetected: b?.receiverDetected ?? false,
backgroundReplyVerified: b?.backgroundReplyVerified ?? false, ready, finishing, testing,
problem: offline ? "offline" : b?.status === "revoked" ? "disconnected" : b?.credentialState === "expired" ? "connection_expired" : b?.credentialState === "revoked" ? "disconnected" : connection && !connection.enabled ? "disabled" : promptUnavailable && !b?.paired ? "prompt_unavailable"
: passed && !ready && !finishing ? "agent_unavailable" : expiredTest ? "no_recent_response" : undefined,
};
}
/** Expire the current test even when polling returns unchanged historical evidence. */
export function useMuseConnectionState(connection: MuseConnection | undefined,
options: { offline?: boolean; promptUnavailable?: boolean } = {}) {
const [, updateClock] = useState(0);
const expiresAt = connection?.binding?.challengeExpiresAt;
useEffect(() => {
if (!expiresAt) return;
const delay = Date.parse(expiresAt) - Date.now();
if (!Number.isFinite(delay) || delay <= 0) return;
const timer = window.setTimeout(() => updateClock(value => value + 1), delay);
return () => window.clearTimeout(timer);
}, [expiresAt]);
return museConnectionState(connection, options);
}
export function museBindingRevision(binding: MuseBinding) {
return { bindingId: binding.id, generation: binding.generation, expectedRevision: binding.revision };
}
/** Read queries contain only public evidence. Pairing results live in this mounted hook. */
export function useMuseConnection(companyId: string | undefined, agentId: string | undefined, enabled = true) {
const cache = useQueryClient();
const identity = useAccountIdentity();
const key = ["muse-binding", companyId, identity.userId, agentId];
const scope = `${companyId}:${agentId}:${identity.userId}`;
const currentScope = useRef(scope); currentScope.current = scope;
const [pairing, setPairing] = useState<MusePairing | null>(null);
const state = useQuery({ queryKey: key, enabled: enabled && identity.settled && !!companyId && !!agentId,
queryFn: ({ signal }) => museInvitationsApi.connection(companyId!, agentId!, signal), retry: false, staleTime: 0,
refetchInterval: query => query.state.error ? false
: query.state.data?.binding || query.state.data?.agentStatus === "pending_approval" ? 5000 : false });
const refresh = () => cache.invalidateQueries({ queryKey: key });
const pair = useMutation({ mutationFn: async (input: MusePairingInput) => {
const requestScope = scope;
const result = await museInvitationsApi.pair(companyId!, agentId!, input);
if (currentScope.current === requestScope) setPairing(result);
// Returning void prevents the transient capability entering the mutation cache.
}, onSuccess: () => { void refresh(); }, onError: () => { setPairing(null); void state.refetch(); }, gcTime: 0 });
const verify = useMutation({ mutationFn: async (binding: MuseBinding) => {
await museInvitationsApi.verify(companyId!, agentId!, museBindingRevision(binding));
}, onSuccess: () => { void refresh(); }, onError: () => { void state.refetch(); } });
const revoke = useMutation({ mutationFn: async (binding: MuseBinding) => {
await museInvitationsApi.revoke(companyId!, agentId!, museBindingRevision(binding));
}, onSuccess: () => { setPairing(null); void refresh(); } });
const attest = useMutation({ mutationFn: (input: MuseAttestStopInput) => museInvitationsApi.attestStop(companyId!, agentId!, input),
onSuccess: () => { void refresh(); }, onError: () => { void state.refetch(); } });
const attestDetector = useMutation({ mutationFn: async (input: MuseDetectorCleanupAttestationInput) => {
const requestScope = scope;
const current = await state.refetch({ throwOnError: true });
const b = current.data?.binding;
if (currentScope.current !== requestScope || !b || !b.cleanup.attestationEligible || b.id !== input.bindingId || b.generation !== input.generation || b.revision !== input.expectedRevision || (b.qualification?.id ?? null) !== input.qualificationId || b.cleanup.hookScript !== input.hookScript) throw new Error("Connection changed. Refresh before recording detector cleanup.");
await museInvitationsApi.attestDetectorCleanup(companyId!, agentId!, input);
await state.refetch({ throwOnError: true });
}, onError: () => { void state.refetch(); } });
useEffect(() => { setPairing(null); }, [companyId, agentId, identity.userId]);
useEffect(() => {
if (!pairing) return;
const timer = window.setTimeout(() => setPairing(null), Math.max(0, Date.parse(pairing.expiresAt) - Date.now()));
return () => window.clearTimeout(timer);
}, [pairing]);
const binding = state.data?.binding;
const currentPairing = pairing && pairing.bindingId === binding?.id && pairing.generation === binding.generation
&& pairing.revision === binding.revision && !binding.paired ? pairing : null;
return { state, binding, pairing: currentPairing, pair, verify, revoke, attest, attestDetector,
repair: () => pair.mutate(binding && binding.status !== "revoked" ? { replaceBindingId: binding.id, expectedRevision: binding.revision } : {}),
key, identity, refresh };
}
+21
View File
@@ -0,0 +1,21 @@
// @vitest-environment jsdom
import { afterEach, expect, it } from "vitest";
import { readMuseInvitationDraft, saveMuseInvitationDraft } from "./muse-invitation-draft";
afterEach(() => localStorage.clear());
it("retains only name and role in the exact company and operator scope", () => {
const draft = { name: "Maia", role: "researcher" as const, ticket: "secret-not-to-save", setupInstruction: "private setup" };
saveMuseInvitationDraft("company-a", "operator-a", draft);
expect(readMuseInvitationDraft("company-a", "operator-a")).toEqual({ name: "Maia", role: "researcher" });
expect(readMuseInvitationDraft("company-b", "operator-a").name).toBe("");
expect(readMuseInvitationDraft("company-a", "operator-b").name).toBe("");
expect(readMuseInvitationDraft("company-a", null).name).toBe("");
expect(localStorage.getItem(localStorage.key(0)!)).not.toContain("secret-not-to-save");
expect(localStorage.getItem(localStorage.key(0)!)).not.toContain("setupInstruction");
});
it("rejects a corrupt role and does not share a signed-out draft", () => {
saveMuseInvitationDraft("company", null, { name: "Anonymous", role: "general" });
expect(localStorage.length).toBe(0);
localStorage.setItem("paperclip.muse-invitation.company.operator", JSON.stringify({ name: "Maia", role: "invented" }));
expect(readMuseInvitationDraft("company", "operator")).toEqual({ name: "", role: "general" });
});
+33
View File
@@ -0,0 +1,33 @@
import { AGENT_ROLES, type AgentRole } from "@paperclipai/shared";
export interface MuseInvitationDraft { name: string; role: AgentRole }
export const emptyMuseInvitationDraft: MuseInvitationDraft = { name: "", role: "general" };
function key(companyId: string, operatorId: string) {
return `paperclip.muse-invitation.${encodeURIComponent(companyId)}.${encodeURIComponent(operatorId)}`;
}
/** Only the name and role survive navigation. Pairing capabilities never enter storage. */
export function readMuseInvitationDraft(companyId: string, operatorId: string | null): MuseInvitationDraft {
if (!operatorId) return { ...emptyMuseInvitationDraft };
try {
const value: unknown = JSON.parse(localStorage.getItem(key(companyId, operatorId)) ?? "null");
if (typeof value === "object" && value !== null && "name" in value && "role" in value
&& typeof value.name === "string" && value.name.length <= 100
&& AGENT_ROLES.some(role => role === value.role)) {
return { name: value.name, role: AGENT_ROLES.find(role => role === value.role)! };
}
} catch { /* Storage is optional; the mounted draft remains usable. */ }
return { ...emptyMuseInvitationDraft };
}
export function saveMuseInvitationDraft(companyId: string, operatorId: string | null, draft: MuseInvitationDraft) {
if (!operatorId) return;
try { localStorage.setItem(key(companyId, operatorId), JSON.stringify({ name: draft.name, role: draft.role })); }
catch { /* Storage is optional. */ }
}
export function clearMuseInvitationDraft(companyId: string, operatorId: string | null) {
if (!operatorId) return;
try { localStorage.removeItem(key(companyId, operatorId)); } catch { /* Storage is optional. */ }
}
+3 -1
View File
@@ -12,12 +12,14 @@ export function isNewAgentAdapterAllowed(
cloud,
nativeRunnerEnabled,
openAiDotEnabled = false,
museEnabled = false,
runnerProvider,
}: { cloud: boolean; nativeRunnerEnabled: boolean; openAiDotEnabled?: boolean; runnerProvider?: string },
}: { cloud: boolean; nativeRunnerEnabled: boolean; openAiDotEnabled?: boolean; museEnabled?: boolean; runnerProvider?: string },
) {
if (type === "openai_dot" || (type === "paperclip_runner" && runnerProvider === "openai_dot")) {
return openAiDotEnabled;
}
if (type === "muse" || (type === "paperclip_runner" && runnerProvider === "muse")) return museEnabled && nativeRunnerEnabled;
if (type === "paperclip_runner") return nativeRunnerEnabled;
if (cloud) return CLOUD_ADAPTERS.has(type);
return true;
+11
View File
@@ -213,6 +213,17 @@ export function visibleRunTokenTotal(usage: Record<string, unknown> | null | und
return input + output + (inputExcludesCached ? cached : 0);
}
/** Muse exposes Paperclip bridge events, not raw private provider traffic. */
export function supportsRawProviderTrace(adapterType: string, adapterConfig: Record<string, unknown> | null | undefined): boolean {
return adapterType !== "paperclip_runner" || adapterConfig?.provider !== "muse";
}
/** Persisted accounting facts belong to this run, even after its agent changes provider. */
export function hasUnavailableProviderAccounting(result: Record<string, unknown> | null | undefined): boolean {
const accounting = asObject(result?.providerAccounting);
return accounting.externallyBilled === true && accounting.usage === null && accounting.cost === null;
}
export function visibleRunCostUsd(
usage: Record<string, unknown> | null,
result: Record<string, unknown> | null = null,
@@ -0,0 +1,73 @@
// @vitest-environment jsdom
import { renderToStaticMarkup } from "react-dom/server";
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
import { AgentActionButtons } from "../components/AgentActionButtons";
import type { Agent, HeartbeatRun } from "@paperclipai/shared";
import { describe, expect, it, vi } from "vitest";
import { RunAccountingMetrics } from "./AgentDetail";
import { hasUnavailableProviderAccounting, supportsRawProviderTrace } from "../lib/utils";
vi.mock("@/lib/router", () => ({ useNavigate: () => vi.fn() }));
vi.mock("../context/DialogContext", () => ({ useDialogActions: () => ({ openNewIssue: vi.fn() }) }));
vi.mock("../context/ToastContext", () => ({ useToastActions: () => ({ pushToast: vi.fn() }) }));
const unavailable = { providerAccounting: { usage: null, cost: null, externallyBilled: true } };
const run = (usageJson: HeartbeatRun["usageJson"], resultJson: HeartbeatRun["resultJson"] = null) => ({ usageJson, resultJson } as HeartbeatRun);
describe("run accounting provenance", () => {
it("renders persisted unavailable accounting without zero usage or zero dollars", () => {
const markup = renderToStaticMarkup(<RunAccountingMetrics run={run({ inputTokens: 0, outputTokens: 0, costUsd: 0 }, unavailable)} />);
expect(markup.match(/Unavailable/g)).toHaveLength(4);
expect(markup).not.toContain("$0");
expect(markup).not.toContain(">0<");
});
it("retains reported historical usage independently of the agent's current provider", () => {
const markup = renderToStaticMarkup(<RunAccountingMetrics run={run({ inputTokens: 120, outputTokens: 20, cachedInputTokens: 40, costUsd: 0.12 })} />);
expect(markup).toContain("120");
expect(markup).toContain("20");
expect(markup).toContain("40");
expect(markup).toContain("$0.1200");
expect(markup).not.toContain("Unavailable");
});
it("requires the saved externally billed and unavailable facts", () => {
expect(hasUnavailableProviderAccounting(unavailable)).toBe(true);
expect(hasUnavailableProviderAccounting(null)).toBe(false);
expect(hasUnavailableProviderAccounting({ providerAccounting: { usage: null, cost: null } })).toBe(false);
expect(hasUnavailableProviderAccounting({ providerAccounting: { usage: { inputTokens: 2 }, cost: 1, externallyBilled: true } })).toBe(false);
});
});
describe("provider trace support", () => {
const renderActions = (provider: string, isAdmin: boolean) => {
const agent: Agent = {
id: "agent-1", companyId: "company-1", name: "Researcher", urlKey: "researcher", role: "researcher",
title: null, icon: null, status: "active", reportsTo: null, capabilities: null,
adapterType: "paperclip_runner", adapterConfig: { provider }, runtimeConfig: {},
budgetMonthlyCents: 0, spentMonthlyCents: 0, permissions: { canCreateAgents: false },
pauseReason: null, pausedAt: null, lastHeartbeatAt: null, metadata: null,
createdAt: new Date("2026-10-10T00:00:00Z"), updatedAt: new Date("2026-10-10T00:00:00Z"),
};
return renderToStaticMarkup(
<QueryClientProvider client={new QueryClient()}>
<AgentActionButtons agent={agent} canRunWithProviderTrace={isAdmin && supportsRawProviderTrace(agent.adapterType, agent.adapterConfig)} />
</QueryClientProvider>,
);
};
it("keeps normal Muse actions but removes the unsupported raw traffic promise", () => {
const markup = renderActions("muse", true);
expect(markup).toContain("Run now");
expect(markup).toContain("Pause");
expect(markup).not.toContain("Run with provider trace");
expect(markup).not.toContain("Capture exact provider traffic");
});
it("retains other provider tracing only for authorized administrators", () => {
expect(renderActions("codex", true)).toContain("Run with provider trace");
expect(renderActions("openai_dot", true)).toContain("Run with provider trace");
expect(renderActions("codex", false)).not.toContain("Run with provider trace");
});
});
+42 -36
View File
@@ -68,7 +68,7 @@ import { SourceResolvedFoldBadge } from "../components/SourceResolvedFoldBadge";
import { readSourceResolvedWatchdogFold } from "../lib/source-resolved-watchdog-fold";
import { buildSameOriginWebSocketUrl } from "../lib/websocket-url";
import { tryCreateWebSocket } from "../lib/websocket";
import { formatDate, relativeTime, formatTokens, visibleRunCostUsd, visibleRunTokenTotal } from "../lib/utils";
import { formatDate, relativeTime, formatTokens, visibleRunCostUsd, visibleRunTokenTotal, hasUnavailableProviderAccounting, supportsRawProviderTrace } from "../lib/utils";
import { cn } from "../lib/utils";
import { RunRetryDetails } from "../components/RunRetryDetails";
import { Button } from "@/components/ui/button";
@@ -372,6 +372,7 @@ function setsEqual<T>(left: Set<T>, right: Set<T>) {
function runMetrics(run: HeartbeatRun) {
const usage = (run.usageJson ?? null) as Record<string, unknown> | null;
const result = (run.resultJson ?? null) as Record<string, unknown> | null;
const usageUnavailable = hasUnavailableProviderAccounting(result);
const input = usageNumber(usage, "inputTokens", "input_tokens");
const output = usageNumber(usage, "outputTokens", "output_tokens");
const cached = usageNumber(
@@ -385,11 +386,12 @@ function runMetrics(run: HeartbeatRun) {
const provider = asNonEmptyString(usage?.provider) ?? null;
const model = asNonEmptyString(usage?.model) ?? null;
return {
input,
output,
cached,
cost,
totalTokens: visibleRunTokenTotal(usage),
usageUnavailable,
input: usageUnavailable ? null : input,
output: usageUnavailable ? null : output,
cached: usageUnavailable ? null : cached,
cost: usageUnavailable ? null : cost,
totalTokens: usageUnavailable ? null : visibleRunTokenTotal(usage),
provider,
model,
};
@@ -1290,7 +1292,7 @@ export function AgentDetail() {
companyId={resolvedCompanyId}
assignLabel="Assign Task"
showStatus={false}
canRunWithProviderTrace={canUseProviderTrace}
canRunWithProviderTrace={canUseProviderTrace && supportsRawProviderTrace(currentAgent.adapterType, currentAgent.adapterConfig)}
actionsDisabled={agentAction.isPending}
workActionsDisabled={hasInvalidOrgChain}
workActionsDisabledReason="Repair this agent's reporting chain before assigning tasks or starting runs"
@@ -1738,7 +1740,9 @@ export function AgentOverview({
for (const issue of assignedIssues) map.set(issue.id, issue);
return map;
}, [assignedIssues]);
const configuredModel = asNonEmptyString(agent.adapterConfig?.model)
const configuredModel = agent.adapterType === "paperclip_runner" && agent.adapterConfig.provider === "muse"
? "Managed by Muse"
: asNonEmptyString(agent.adapterConfig?.model)
?? asNonEmptyString(agent.adapterConfig?.modelName)
?? asNonEmptyString(agent.runtimeConfig?.model)
?? "Adapter default";
@@ -2021,7 +2025,7 @@ export function ConfigurationTab({
? queryKeys.agents.adapterModels(companyId, agent.adapterType, null, catalogProvider)
: ["agents", "none", "adapter-models", agent.adapterType],
queryFn: () => agentsApi.adapterModels(companyId!, agent.adapterType, { provider: catalogProvider }),
enabled: Boolean(companyId) && content === "runtime" && !["openai_dot", "grok_bot"].includes(catalogProvider ?? ""),
enabled: Boolean(companyId) && content === "runtime" && !["openai_dot", "grok_bot", "muse"].includes(catalogProvider ?? ""),
});
const lowTrustSelected = getTrustPreset(agent.permissions) === "low_trust_review";
@@ -3306,10 +3310,11 @@ function RunListItem({ run, isSelected, agentId }: { run: HeartbeatRun; isSelect
{summary.slice(0, 60)}
</span>
)}
{(metrics.totalTokens > 0 || metrics.cost > 0) && (
{(metrics.usageUnavailable || (metrics.totalTokens ?? 0) > 0 || (metrics.cost ?? 0) > 0) && (
<div className="flex items-center gap-2 pl-5.5 text-(length:--text-micro) text-muted-foreground tabular-nums">
{metrics.totalTokens > 0 && <span>{formatTokens(metrics.totalTokens)} tok</span>}
{metrics.cost > 0 && <span>${metrics.cost.toFixed(3)}</span>}
{metrics.usageUnavailable && <span>Usage and cost unavailable</span>}
{metrics.totalTokens !== null && metrics.totalTokens > 0 && <span>{formatTokens(metrics.totalTokens)} tok</span>}
{metrics.cost !== null && metrics.cost > 0 && <span>${metrics.cost.toFixed(3)}</span>}
</div>
)}
</Link>
@@ -3419,6 +3424,27 @@ export function AgentFileRunNotice({ resultJson }: { resultJson: HeartbeatRun["r
</>;
}
export function RunAccountingMetrics({ run }: { run: HeartbeatRun }) {
const metrics = runMetrics(run);
if (!metrics.usageUnavailable && ![metrics.input, metrics.output, metrics.cached, metrics.cost].some(value => (value ?? 0) > 0)) return null;
const values = [
["Input", metrics.input === null ? "Unavailable" : formatTokens(metrics.input)],
["Output", metrics.output === null ? "Unavailable" : formatTokens(metrics.output)],
["Cached", metrics.cached === null ? "Unavailable" : formatTokens(metrics.cached)],
["Cost", metrics.cost === null ? "Unavailable" : metrics.cost > 0 ? `$${metrics.cost.toFixed(4)}` : "-"],
];
return (
<div className="border-t sm:border-t-0 sm:border-l border-border p-4 grid grid-cols-2 gap-x-4 sm:gap-x-8 gap-y-3 content-center tabular-nums">
{values.map(([label, value]) => (
<div key={label}>
<div className="text-xs text-muted-foreground">{label}</div>
<div className="text-sm font-medium font-mono">{value}</div>
</div>
))}
</div>
);
}
function RunDetail({ run: initialRun, agentRouteId, adapterType, adapterConfig }: { run: HeartbeatRun; agentRouteId: string; adapterType: string; adapterConfig: Record<string, unknown> }) {
const queryClient = useQueryClient();
const navigate = useNavigate();
@@ -3445,6 +3471,7 @@ function RunDetail({ run: initialRun, agentRouteId, adapterType, adapterConfig }
});
const paperclipDeveloperMode =
experimentalSettings?.enablePaperclipDeveloperMode === true;
const canRerunWithProviderTrace = canUseProviderTrace && supportsRawProviderTrace(adapterType, adapterConfig);
const { data: providerTraceRows } = useQuery({
queryKey: queryKeys.providerTraceMetadata(run.companyId, [run.id]),
queryFn: () => heartbeatsApi.providerTraceMetadata(run.companyId, [run.id]),
@@ -3619,7 +3646,6 @@ function RunDetail({ run: initialRun, agentRouteId, adapterType, adapterConfig }
? Math.round((new Date(run.finishedAt).getTime() - new Date(run.startedAt).getTime()) / 1000)
: null;
const displayDurationSec = durationSec ?? (isRunning ? elapsedSec : null);
const hasMetrics = metrics.input > 0 || metrics.output > 0 || metrics.cached > 0 || metrics.cost > 0;
const hasSession = !!(run.sessionIdBefore || run.sessionIdAfter);
const sessionChanged = run.sessionIdBefore && run.sessionIdAfter && run.sessionIdBefore !== run.sessionIdAfter;
const sessionId = run.sessionIdAfter || run.sessionIdBefore;
@@ -3693,7 +3719,7 @@ function RunDetail({ run: initialRun, agentRouteId, adapterType, adapterConfig }
runId={run.id}
enabled={paperclipDeveloperMode && canUseProviderTrace}
/>
{canUseProviderTrace && !["queued", "running"].includes(run.status) ? (
{canRerunWithProviderTrace && !["queued", "running"].includes(run.status) ? (
<Button
variant="ghost"
size="sm"
@@ -3858,27 +3884,7 @@ function RunDetail({ run: initialRun, agentRouteId, adapterType, adapterConfig }
<RunRetryDetails run={run} agentRouteId={agentRouteId} />
</div>
{/* Right column: metrics */}
{hasMetrics && (
<div className="border-t sm:border-t-0 sm:border-l border-border p-4 grid grid-cols-2 gap-x-4 sm:gap-x-8 gap-y-3 content-center tabular-nums">
<div>
<div className="text-xs text-muted-foreground">Input</div>
<div className="text-sm font-medium font-mono">{formatTokens(metrics.input)}</div>
</div>
<div>
<div className="text-xs text-muted-foreground">Output</div>
<div className="text-sm font-medium font-mono">{formatTokens(metrics.output)}</div>
</div>
<div>
<div className="text-xs text-muted-foreground">Cached</div>
<div className="text-sm font-medium font-mono">{formatTokens(metrics.cached)}</div>
</div>
<div>
<div className="text-xs text-muted-foreground">Cost</div>
<div className="text-sm font-medium font-mono">{metrics.cost > 0 ? `$${metrics.cost.toFixed(4)}` : "-"}</div>
</div>
</div>
)}
<RunAccountingMetrics run={run} />
</div>
{/* Collapsible session row */}
@@ -3994,7 +4000,7 @@ function RunDetail({ run: initialRun, agentRouteId, adapterType, adapterConfig }
open={inspectorOpen}
onOpenChange={setInspectorOpen}
onRerunWithTrace={
canUseProviderTrace && !["queued", "running"].includes(run.status)
canRerunWithProviderTrace && !["queued", "running"].includes(run.status)
? () => rerunWithTrace.mutate()
: undefined
}
+18
View File
@@ -22,6 +22,24 @@ vi.mock("../components/MarkdownBody", () => ({
}));
describe("AgentOverview", () => {
it("identifies Muse and shows its provider-managed model even when old model fields remain", () => {
const agent = {
id: "muse-agent", companyId: "company-1", name: "Muse Researcher", role: "researcher", status: "active",
adapterType: "paperclip_runner", adapterConfig: { provider: "muse", model: "old-model" },
runtimeConfig: { model: "old-runtime-model" },
} as unknown as AgentDetail;
const markup = renderToStaticMarkup(
<QueryClientProvider client={new QueryClient()}>
<AgentOverview agent={agent} runs={[]} assignedIssues={[]} directReportCount={0} skillNames={[]} agentRouteId="muse-agent" />
</QueryClientProvider>,
);
expect(markup).toContain("Muse — Personal agent");
expect(markup).toContain("Managed by Muse");
expect(markup).not.toContain("Adapter default");
expect(markup).not.toContain("old-model");
expect(markup).not.toContain("old-runtime-model");
});
it("prioritizes identity, capability, runtime, skills, tasks, and scoped Audit entry points", () => {
const agent = {
id: "agent-1",
+16 -11
View File
@@ -566,6 +566,7 @@ const IMPORT_ADAPTER_OPTIONS: { value: string; label: string }[] = [
label: adapterLabels[adapter.type] ?? getAdapterLabel(adapter.type),
})),
{ value: "openai_dot", label: "OpenAI Dot (experimental)" },
{ value: "muse", label: "Muse — Personal agent (experimental)" },
];
// Dot is a picker choice backed by the shared Runner API adapter.
@@ -580,7 +581,7 @@ function importAdapterValues(
?? (nativeCodex ? undefined : sourceConfig?.dangerouslyBypassSandbox);
return {
...defaultCreateValues,
adapterType: choice === "openai_dot" ? "paperclip_runner" : choice,
adapterType: choice === "openai_dot" || choice === "muse" ? "paperclip_runner" : choice,
...(runner !== undefined ? { runner } : nativeCodex ? { runner: agentRunner("paperclip_runner") } : {}),
...(sourceConfig && choice === "codex_local" ? {
model: typeof sourceConfig.model === "string" ? sourceConfig.model : "",
@@ -612,7 +613,7 @@ function importAdapterValues(
dotAttachmentAccess: sourceConfig?.dotAttachmentAccess === true,
dotWorkspaceAccess: sourceConfig?.dotWorkspaceAccess === true,
},
} : {}),
} : choice === "muse" ? { adapterSchemaValues: { provider: "muse", allowUnmeteredProvider: sourceConfig?.allowUnmeteredProvider === true } } : {}),
};
}
@@ -722,7 +723,7 @@ function AdapterPickerList({
{agent.fallbackAdapterType && (
<div className="mx-4 mb-2.5 rounded-md border border-amber-500/30 bg-amber-500/5 px-3 py-2">
<p className="text-xs text-amber-500">
source adapter {agent.adapterChoice === "openai_dot" ? "OpenAI Dot" : agent.adapterType} is not installed here — this agent
source adapter {agent.adapterChoice === "openai_dot" ? "OpenAI Dot" : agent.adapterChoice === "muse" ? "Muse — Personal agent" : agent.adapterType} is not installed here — this agent
will use {adapterLabels[selectedType] ?? getAdapterLabel(selectedType)}
</p>
</div>
@@ -1102,12 +1103,13 @@ export function CompanyImport() {
const runnerInstalled = availableAdapterTypes?.has("paperclip_runner") === true;
const nativeRunnerAvailable = runnerInstalled && experimentalSettings?.enableNativeRunner === true;
const dotAvailable = runnerInstalled && experimentalSettings?.enableOpenAiDot === true;
const museAvailable = nativeRunnerAvailable && experimentalSettings?.enableMuse === true;
const importAdapterOptions = useMemo(
() => IMPORT_ADAPTER_OPTIONS.filter((option) =>
option.value === "paperclip_runner" ? nativeRunnerAvailable
: option.value === "openai_dot" ? dotAvailable : true,
: option.value === "openai_dot" ? dotAvailable : option.value === "muse" ? museAvailable : true,
),
[nativeRunnerAvailable, dotAvailable],
[nativeRunnerAvailable, dotAvailable, museAvailable],
);
const localZipHelpText =
@@ -1594,14 +1596,14 @@ export function CompanyImport() {
// Reset config values when adapter type changes
setAdapterConfigValues((prev) => {
const next = { ...prev };
if (adapterType === "openai_dot" || adapterType === "paperclip_runner") {
if (adapterType === "openai_dot" || adapterType === "muse" || adapterType === "paperclip_runner") {
next[slug] = importAdapterValues(adapterType);
} else {
delete next[slug];
}
return next;
});
if (adapterType === "openai_dot") {
if (adapterType === "openai_dot" || adapterType === "muse") {
setAdapterExpandedSlugs((prev) => new Set([...prev, slug]));
}
}
@@ -1678,11 +1680,11 @@ export function CompanyImport() {
const nativeCodex = a.adapterType === "paperclip_runner"
&& (a.adapterConfig?.provider === "codex" || a.adapterConfig?.provider === undefined);
const adapterChoice = a.adapterType === "paperclip_runner" && a.adapterConfig?.provider === "openai_dot"
? "openai_dot" : nativeCodex ? agentHarnessType(a.adapterType, a.adapterConfig) : a.adapterType;
? "openai_dot" : a.adapterType === "paperclip_runner" && a.adapterConfig?.provider === "muse" ? "muse" : nativeCodex ? agentHarnessType(a.adapterType, a.adapterConfig) : a.adapterType;
let fallbackAdapterType: string | null = null;
let availabilityWarning: string | undefined;
if (a.adapterType === "paperclip_runner") {
const available = nativeCodex ? runnerInstalled : adapterChoice === "openai_dot" ? dotAvailable : nativeRunnerAvailable;
const available = nativeCodex ? runnerInstalled : adapterChoice === "openai_dot" ? dotAvailable : adapterChoice === "muse" ? museAvailable : nativeRunnerAvailable;
if (!available && (availableAdapterTypes || !nativeCodex)) {
availabilityWarning = nativeCodex
? "Paperclip Runner is unavailable here. Choose a runner or another harness explicitly."
@@ -1711,7 +1713,7 @@ export function CompanyImport() {
fallbackAdapterType,
};
});
}, [importPreview, availableAdapterTypes, ceoAdapterType, runnerInstalled, nativeRunnerAvailable, dotAvailable]);
}, [importPreview, availableAdapterTypes, ceoAdapterType, runnerInstalled, nativeRunnerAvailable, dotAvailable, museAvailable]);
/** The adapter type an imported agent will actually use: an explicit user pick, else the availability fallback, else the manifest adapter. */
function effectiveAdapterType(agent: AdapterPickerItem): string {
@@ -1728,7 +1730,7 @@ export function CompanyImport() {
const selectedType = effectiveAdapterType(agent);
const configVals = adapterConfigValues[agent.slug];
if (selectedType === agent.adapterChoice && !configVals) continue;
const apiType = selectedType === "openai_dot" ? "paperclip_runner" : selectedType;
const apiType = selectedType === "openai_dot" || selectedType === "muse" ? "paperclip_runner" : selectedType;
const override: CompanyPortabilityAdapterOverride = { adapterType: apiType };
if (configVals?.runner !== undefined) override.runner = configVals.runner;
if (configVals && (selectedType !== agent.adapterChoice || adapterConfigEditedSlugs.has(agent.slug))) {
@@ -1739,6 +1741,9 @@ export function CompanyImport() {
if (selectedType === "openai_dot" && configVals.adapterSchemaValues?.allowUnmeteredProvider !== true) {
throw new Error("OpenAI Dot requires acknowledgement of external billing. Configure the Dot adapter and enable Allow externally billed provider.");
}
if (selectedType === "muse" && configVals.adapterSchemaValues?.allowUnmeteredProvider !== true) {
throw new Error("Muse requires acknowledgement of external billing. Configure Muse and enable Allow externally billed provider.");
}
const build = getUIAdapter(configVals.adapterType).buildAdapterConfig;
const edited = build(configVals);
if (agent.adapterType === "paperclip_runner" && selectedType === agent.adapterChoice
+30
View File
@@ -8,6 +8,9 @@ import { Costs } from "./Costs";
import { CostByUserTable } from "../components/CostByUserTable";
import { MemoryRouter } from "react-router-dom";
const scopedAgentMock = vi.hoisted(() => vi.fn());
vi.mock("../api/agents", () => ({ agentsApi: { get: scopedAgentMock } }));
const resolveIncidentMock = vi.hoisted(() => vi.fn());
const upsertPolicyMock = vi.hoisted(() => vi.fn());
const budgetOverviewMock = vi.hoisted(() => vi.fn());
@@ -69,6 +72,7 @@ describe("Shared Costs surfaces", () => {
let root: ReturnType<typeof createRoot>;
beforeEach(() => {
scopedAgentMock.mockReset();
const usage = { inputTokens: 0, cachedInputTokens: 0, outputTokens: 0, costCents: "0", eventCount: 0, estimatedEventCount: 0, unpricedEventCount: 0 };
subscriptionsMocks.report.mockResolvedValue({ canRefresh: false, asOf: "2026-10-08T00:00:00Z", accounts: [], monthlyTotals: [], activeCount: 0, unknownPriceCount: 0, unidentifiedAccountCount: 0, api: usage, subscription: usage, unknown: usage, unattributedSubscription: usage });
decisionHistoryMock.mockResolvedValue([]);
@@ -90,6 +94,32 @@ describe("Shared Costs surfaces", () => {
vi.clearAllMocks();
});
it.each(["overview", "budgets"] as const)("explains unavailable Muse accounting on its scoped %s page", async (tab) => {
scopedAgentMock.mockResolvedValue({ id: "muse-agent", name: "Researcher", adapterType: "paperclip_runner", adapterConfig: { provider: "muse" } });
for (const mock of Object.values(costsApiMocks)) mock.mockResolvedValue([]);
costsApiMocks.byUser.mockResolvedValue({ activeUserCount: 1, rows: [] });
costsApiMocks.summary.mockResolvedValue({ spendCents: 0, budgetCents: 0, pricingComplete: true });
costsApiMocks.financeSummary.mockResolvedValue({ netCents: 0, debitCents: 0, creditCents: 0, estimatedDebitCents: 0, eventCount: 0 });
const queryClient = new QueryClient({ defaultOptions: { queries: { retry: false } } });
root = createRoot(container);
await act(async () => root.render(<MemoryRouter initialEntries={["/?agentId=muse-agent"]}><QueryClientProvider client={queryClient}><Costs embedded initialTab={tab} lockTab /></QueryClientProvider></MemoryRouter>));
await act(async () => { await vi.waitFor(() => expect(container.textContent).toContain("Researcher's Muse activity has no reported usage or cost")); });
expect(scopedAgentMock).toHaveBeenCalledWith("muse-agent", "company-1");
expect(container.textContent).toContain("Totals below show recorded organization charges only");
expect(container.textContent).toContain("Paperclip cannot enforce a Muse spend ceiling");
queryClient.clear();
});
it("explains when scoped provider details cannot be loaded", async () => {
scopedAgentMock.mockRejectedValue(new Error("unavailable"));
const queryClient = new QueryClient({ defaultOptions: { queries: { retry: false } } });
root = createRoot(container);
await act(async () => root.render(<MemoryRouter initialEntries={["/?agentId=muse-agent"]}><QueryClientProvider client={queryClient}><Costs embedded initialTab="budgets" lockTab /></QueryClientProvider></MemoryRouter>));
await act(async () => { await vi.waitFor(() => expect(container.textContent).toContain("Could not load agent details. Totals below show recorded organization costs.")); });
expect(container.textContent).not.toContain("Muse activity has no reported");
queryClient.clear();
});
it.each([0, 125])("shows $%s of known user spend while retaining an unpriced warning", async (costCents) => {
root = createRoot(container);
await act(async () => root.render(<CostByUserTable report={{ activeUserCount: 1, rows: [{
+16
View File
@@ -23,6 +23,7 @@ import type {
import { ArrowDownLeft, ArrowUpRight, ChevronDown, ChevronRight, Coins, DollarSign, ReceiptText } from "lucide-react";
import { budgetsApi } from "../api/budgets";
import { costsApi } from "../api/costs";
import { agentsApi } from "../api/agents";
import { BillerSpendCard } from "../components/BillerSpendCard";
import { BudgetIncidentCard } from "../components/BudgetIncidentCard";
import { BudgetPolicyCard } from "../components/BudgetPolicyCard";
@@ -180,6 +181,13 @@ export function Costs({
const queryClient = useQueryClient();
const [searchParams, setSearchParams] = useSearchParams();
const scopedAgentId = searchParams.get("agentId");
const { data: scopedAgent, error: scopedAgentError } = useQuery({
queryKey: [...queryKeys.agents.detail(scopedAgentId ?? NO_COMPANY), selectedCompanyId],
queryFn: () => agentsApi.get(scopedAgentId!, selectedCompanyId!),
enabled: Boolean(selectedCompanyId && scopedAgentId),
});
const museUsageUnavailable = scopedAgent?.adapterType === "paperclip_runner" && scopedAgent.adapterConfig.provider === "muse";
const [mainTab, setMainTab] = useState<CostsMainTab>(initialTab);
const [activeProvider, setActiveProvider] = useState("all");
const [activeBiller, setActiveBiller] = useState("all");
@@ -598,6 +606,14 @@ export function Costs({
const overviewError = !spendData && spendError;
return (
<div className="space-y-6">
{scopedAgentError && (
<p role="alert" className="text-sm text-destructive">Could not load agent details. Totals below show recorded organization costs.</p>
)}
{museUsageUnavailable && (
<p role="status" className="text-sm text-muted-foreground">
{scopedAgent.name}'s Muse activity has no reported usage or cost. Totals below show recorded organization charges only. Paperclip cannot enforce a Muse spend ceiling.
</p>
)}
{showSummaryChrome ? (
<div className="space-y-5">
<div className="flex flex-col gap-4 lg:flex-row lg:items-start lg:justify-between">
+7 -1
View File
@@ -6,7 +6,8 @@ import { SpekoProviderSetup } from "@/components/voice/SpekoProviderSetup";
import { VoiceControls } from "@/components/voice/VoiceControls";
import { VoiceTranscript } from "@/components/voice/VoiceTranscript";
import { AnimatedDialogContent } from "@/components/AnimatedDialogContent";
import { ExternalAgentPresetPicker, DotConnectionChecks } from "@/components/new-agent/ExternalAgentInviteContent";
import { MuseConnectionDetails } from "@/components/MuseRunnerConnection";
import { ExternalAgentPresetPicker, DotConnectionChecks, MuseConnectionChecks } from "@/components/new-agent/ExternalAgentInviteContent";
import { AgentMailApiKeyField } from "@/features/connections/AgentMailApiKeyField";
import { TaskBrowserFooter } from "@/components/task-side-panel/TaskBrowserFooter";
import { TaskBrowserActivity } from "@/components/task-side-panel/TaskBrowserActivity";
@@ -2338,6 +2339,11 @@ export function DesignGuide() {
<p className="text-sm text-muted-foreground">Match the harness picker with provider cards. Connection checks reflect confirmed server evidence. AnimatedDialogContent resizes to its content using motion tokens and respects reduced motion.</p>
<ExternalAgentPresetPicker onSelect={() => {}} />
<DotConnectionChecks state={{ phase: "testing" }} />
<ExternalAgentPresetPicker museEnabled onSelect={() => {}} />
<MuseConnectionChecks state={{ paired: true, receiverDetected: true, backgroundReplyVerified: false, ready: false }} />
<MuseConnectionChecks state={{ paired: true, receiverDetected: true, backgroundReplyVerified: true, ready: false, problem: "agent_unavailable" }} />
<MuseConnectionChecks state={{ paired: true, receiverDetected: true, backgroundReplyVerified: true, ready: true }} />
<MuseConnectionDetails connection={{ enabled: false, publicOrigin: null, agentStatus: "idle", agentLifecycleState: "ready", canConfigureConnection: true, binding: null, usage: null, cost: null }} onTest={() => {}} onRepair={() => {}} onPause={() => {}} onDisconnect={() => {}} onRefresh={() => {}} onAttest={() => {}} />
<Dialog><DialogTrigger asChild><Button variant="outline">Preview invitation modal</Button></DialogTrigger>
<AnimatedDialogContent><div className="space-y-4 p-6"><DialogTitle>Invite an external agent</DialogTitle><DialogDescription>Choose an agent you already use.</DialogDescription><ExternalAgentPresetPicker onSelect={() => {}} /></div></AnimatedDialogContent>
</Dialog>
@@ -274,6 +274,17 @@ describe("InstanceExperimentalSettings — Conference Room Chat card (PAP-11233)
}
});
it("saves the personal Muse opt-in separately and names independent readiness evidence", async () => {
await renderPage();
expect(container.textContent).toContain("Muse — Personal agent");
expect(container.textContent).toContain("independent background reply");
expect(container.textContent).toContain("Provider usage and cost are unavailable");
const toggle = container.querySelector<HTMLButtonElement>('button[aria-label="Toggle Muse experimental setting"]')!;
await act(() => toggle.click());
await flushReact();
expect(mockInstanceSettingsApi.updateExperimental).toHaveBeenLastCalledWith({ enableMuse: true });
});
it("names Dot prerequisites and saves its opt-in independently in both directions", async () => {
await renderPage();
const selector = 'button[aria-label="Toggle OpenAI Dot experimental setting"]';
@@ -504,6 +504,18 @@ export function InstanceExperimentalSettings() {
ariaLabel="Toggle memory connectors experimental setting"
/>
<ExperimentalToggleCard
title="Muse — Personal agent"
description="Connect your personal Meta Muse. Pair it, detect its receiver, and verify an independent background reply before assigning tasks."
footnote="Requires Paperclip Runner and an authenticated instance with a public HTTPS URL. Turning this off blocks new work; cleanup and stop evidence remain available. Provider usage and cost are unavailable."
checked={experimentalQuery.data?.enableMuse === true}
onCheckedChange={(checked) => toggleMutation.mutate({ enableMuse: checked })}
disabled={toggleMutation.isPending}
settingKey="enableMuse"
managed={managedKeys.enableMuse}
ariaLabel="Toggle Muse experimental setting"
/>
<ExperimentalToggleCard
title="OpenAI Dot"
description="Add OpenAI Dot as a standalone agent choice. Pair your Dot and verify event delivery before assigning work."
@@ -0,0 +1,64 @@
import type { Meta, StoryObj } from "@storybook/react-vite";
import { MUSE_ASSET_VERSION } from "@paperclipai/shared";
import { expect, userEvent, within, waitFor, fn } from "storybook/test";
import { ExternalAgentInvitePreview } from "../../../support/external-agent-invite/ExternalAgentInvitePreview";
import { MuseConnectionDetails } from "@/components/MuseRunnerConnection";
import { createMuseCopyFeedbackGate, museConnection, stoppedMuseConnection } from "../../../support/external-agent-invite/muse-fixtures";
const meta = {
id: "onboarding-external-agent-invitation-muse-personal-agent",
title: "Agents and reusable capabilities/Hiring, configuration, and organization/Onboarding/External agent invitation/Muse personal agent",
component: ExternalAgentInvitePreview,
parameters: { layout: "fullscreen", initialEntries: ["/PAP/agents"],
docs: { description: { component: `Uses the production agent entry point, ExternalAgentInviteContent, app shell, and MuseConnectionDetails with non-routable, nonsecret asset ${MUSE_ASSET_VERSION} illustrations. The supported production flow keeps OAuth credentials and refresh in SecureVault/authd and records standing delegation for the exact company, agent and binding. That delegates validating and accepting in-scope work; task effects still require the native accepted lease and Muse permission/safety gates. Local simulation separates public untrusted receiver contact (up to 30 seconds persistence lag), independent authenticated background reply, and lifecycle readiness. Disconnect revokes OAuth authority immediately; there is no authenticated post-revocation cleanup lane or confirmed detector removal/worker stop in these fixtures. Unknown native effects retain their exact stop boundary. No live installation, work acceptance, provider behavior, private tools or cost is established.` } },
paperclip: {
capabilities: ["agents/hire", "agents/lifecycle", "access/invitations"],
implementation: "production",
boundary: "fixtures",
notes: "Uses the shipped invitation and connection settings with simulated API state. Covers separate pairing, receiver and authenticated reply milestones, reload/error recovery, expired credentials and exact worker stop attestation. Does not establish live provider setup, native task execution, remote interruption or reliability.",
},
},
args: { initialPreset: "muse", museEnabled: true },
render: args => <ExternalAgentInvitePreview key={JSON.stringify(args)} {...args} />,
} satisfies Meta<typeof ExternalAgentInvitePreview>;
export default meta;
type Story = StoryObj<typeof meta>;
export const StartHere: Story = { name: "01 · Invite a personal Muse", args: { initialScreen: "picker" } };
export const NameAndRole: Story = { name: "02 · Name and role", args: { initialScreen: "setup", museDetails: true, simulate: false } };
export const CopySetup: Story = { name: "03 · Copy setup and approve hostname", args: { initialScreen: "setup", simulate: false } };
export const Paired: Story = { name: "04 · Paired, receiver not yet detected", args: { initialScreen: "setup", simulate: false, initialMuseConnection: { paired: true, receiverDetected: false, backgroundReplyVerified: false, ready: false } } };
export const ReceiverDetected: Story = { name: "05 · Receiver detected, waiting for background reply", args: { initialScreen: "setup", simulate: false, initialMuseConnection: { paired: true, receiverDetected: true, backgroundReplyVerified: false, ready: false } } };
export const FinishingSetup: Story = { name: "06 · All checks passed, lifecycle preparing", args: { initialScreen: "setup", simulate: false, initialMuseConnection: { paired: true, receiverDetected: true, backgroundReplyVerified: true, ready: false, finishing: true } } };
export const Ready: Story = { name: "07 · Ready for assignments", args: { initialScreen: "setup", simulate: false, initialMuseConnection: { paired: true, receiverDetected: true, backgroundReplyVerified: true, ready: true } } };
export const Approval: Story = { name: "Approval required", args: { initialScreen: "setup", pendingApproval: true, simulate: false } };
export const LoadingInvitation: Story = { name: "Recovery · Loading invitation", args: { initialScreen: "setup", invitationUnavailable: true, preparing: true, simulate: false } };
export const AuthenticatedInstanceRequired: Story = { name: "Recovery · Authenticated instance required", args: { initialScreen: "setup", invitationUnavailable: true, error: "Use an authenticated Paperclip instance with a public HTTPS URL and a company operator account. Local trusted access alone cannot connect Muse.", simulate: false } };
export const MissingPublicHost: Story = { name: "Recovery · Configure public HTTPS address", args: { initialScreen: "setup", invitationUnavailable: true, error: "This instance has no public HTTPS URL for Muse. Ask your instance administrator to configure a stable public HTTPS address, then refresh setup. Muse cannot connect through a local-only address.", simulate: false } };
export const NoRecentResponse: Story = { name: "Recovery · No recent response", args: { initialScreen: "setup", simulate: false, initialMuseConnection: { paired: true, receiverDetected: true, backgroundReplyVerified: false, ready: false, problem: "no_recent_response" } } };
export const ReplacedPrompt: Story = { name: "Recovery · Replaced or expired setup", args: { initialScreen: "setup", simulate: false, initialMuseConnection: { paired: false, receiverDetected: false, backgroundReplyVerified: false, ready: false, problem: "prompt_unavailable" } } };
export const Mobile: Story = { name: "Mobile · Copy setup", args: { initialScreen: "setup", simulate: false }, globals: { viewport: { value: "mobile1", isRotated: false } } };
export const MobileDetails: Story = { name: "Mobile · Name and role", args: { initialScreen: "setup", museDetails: true, simulate: false }, globals: { viewport: { value: "mobile1", isRotated: false } } };
export const Light: Story = { name: "Light · Name and role", args: { initialScreen: "setup", museDetails: true, simulate: false }, globals: { theme: "light" } };
export const CopyAndVerify: Story = { name: "Test · Copy, three milestones, then ready", args: { initialScreen: "setup", stepDelayMs: 300 },
loaders: [() => ({ copyFeedbackGate: createMuseCopyFeedbackGate() })],
render: (args, { loaded }) => <ExternalAgentInvitePreview key={JSON.stringify(args)} {...args} beforeMuseSimulation={loaded.copyFeedbackGate.waitForFeedback} />,
play: async ({ canvasElement, loaded }) => {
const page = within(canvasElement.ownerDocument.body);
await userEvent.click(await page.findByRole("button", { name: "Copy setup prompt" }));
const copiedFeedback = await page.findByText("Copied to clipboard");
await waitFor(() => expect(copiedFeedback).toBeVisible());
loaded.copyFeedbackGate.feedbackObserved();
await waitFor(() => expect(page.getByText("Receiver detected", { exact: true })).toHaveTextContent("complete"));
await expect(page.queryByRole("button", { name: "Done" })).not.toBeInTheDocument();
await waitFor(() => expect(page.getByRole("heading", { name: "Muse is connected" })).toBeVisible());
await userEvent.click(page.getByRole("button", { name: "Done" }));
},
};
export const Settings: Story = { name: "Settings · Persisted evidence and controls", render: () => <div className="max-w-2xl p-6"><MuseConnectionDetails connection={museConnection} onTest={fn()} onRepair={fn()} onPause={fn()} onDisconnect={fn()} onRefresh={fn()} onAttest={fn()} /></div> };
export const StopBoundary: Story = { name: "Settings · Disabled, cleanup, uncertain work, exact stop boundary", render: () => <div className="max-w-2xl p-6"><MuseConnectionDetails connection={stoppedMuseConnection} onTest={fn()} onRepair={fn()} onPause={fn()} onDisconnect={fn()} onRefresh={fn()} onAttest={fn()} /></div> };
export const ExpiredConnection: Story = { name: "Recovery · Expired Vault connection", args: {
initialScreen: "setup", simulate: false, initialMuseConnection: {
paired: true, receiverDetected: true, backgroundReplyVerified: true, ready: false, problem: "connection_expired",
},
} };
@@ -4,37 +4,63 @@ import { Layout } from "@/components/Layout";
import { Agents } from "@/pages/Agents";
import { PluginLauncherProvider } from "@/plugins/launchers";
import { AgentBasicsDialog } from "@/components/new-agent/AgentBasicsDialog";
import { ExternalAgentInviteContent, type DotConnectionState, type ExternalAgentPreset } from "@/components/new-agent/ExternalAgentInviteContent";
import { ExternalAgentInviteContent, type DotConnectionState, type ExternalAgentPreset, type MuseConnectionState } from "@/components/new-agent/ExternalAgentInviteContent";
import { Dialog } from "@/components/ui/dialog";
import { AnimatedDialogContent } from "@/components/AnimatedDialogContent";
import { Button } from "@/components/ui/button";
import { musePairing } from "./muse-fixtures";
import type { MuseInvitationDraft } from "@/lib/muse-invitation-draft";
import { dotInvitePrompt, externalInvitePrompt } from "./fixtures";
export interface ExternalAgentInvitePreviewProps {
initialScreen?: "entry" | "picker" | "setup";
initialPreset?: ExternalAgentPreset;
initialConnection?: DotConnectionState;
initialMuseConnection?: MuseConnectionState;
museDetails?: boolean;
museEnabled?: boolean;
pendingApproval?: boolean;
companyName?: string;
simulate?: boolean;
failTest?: boolean;
stepDelayMs?: number;
/** The copy play releases simulated Muse progress after asserting real visible feedback. */
beforeMuseSimulation?: () => Promise<void>;
shell?: boolean;
preparing?: boolean;
invitationUnavailable?: boolean;
error?: string;
}
/** Storybook owns simulation. No timer, fixture code, or fake status ships in the UI component. */
/**
* Offline versioned Muse presentation only: copying this fixture simulates milestones;
* it never installs credentials, records operator delegation, or accepts work.
* Public contact, authenticated background reply, and lifecycle readiness stay separate.
*/
export function ExternalAgentInvitePreview({
initialScreen = "entry", initialPreset = "dot", initialConnection = { phase: "waiting" },
companyName = "Paperclip", simulate = true, failTest = false, stepDelayMs = 2000, shell = true, preparing = false,
initialMuseConnection = { paired: false, receiverDetected: false, backgroundReplyVerified: false, ready: false },
museDetails = false, museEnabled = false, pendingApproval = false,
companyName = "Paperclip", simulate = true, failTest = false, stepDelayMs = 2000, beforeMuseSimulation, shell = true, preparing = false, invitationUnavailable = false, error,
}: ExternalAgentInvitePreviewProps) {
const [screen, setScreen] = useState<"entry" | "picker" | "setup" | "closed">(initialScreen);
const [preset, setPreset] = useState<ExternalAgentPreset>(initialPreset);
const [connection, setConnection] = useState(initialConnection);
const [museConnection, setMuseConnection] = useState(initialMuseConnection);
const [museDraft, setMuseDraft] = useState<MuseInvitationDraft | undefined>(museDetails ? { name: "Maia", role: "researcher" } : undefined);
const [watching, setWatching] = useState(false);
const [retrying, setRetrying] = useState(false);
const [generation, setGeneration] = useState(1);
useEffect(() => {
if (!simulate || !watching || connection.problem || connection.phase === "ready") return;
if (preset !== "muse" || !simulate || !watching || museConnection.problem || museConnection.ready) return;
const timer = window.setTimeout(() => setMuseConnection(previous => !previous.paired ? { ...previous, paired: true }
: !previous.receiverDetected ? { ...previous, receiverDetected: true }
: !previous.backgroundReplyVerified ? { ...previous, backgroundReplyVerified: true, finishing: true }
: { ...previous, finishing: false, ready: true }), stepDelayMs);
return () => window.clearTimeout(timer);
}, [preset, simulate, watching, museConnection, stepDelayMs]);
useEffect(() => {
if (preset === "muse" || !simulate || !watching || connection.problem || connection.phase === "ready") return;
const phase = connection.phase;
const next = { waiting: "connected", connected: "subscribed", subscribed: "testing", testing: "finishing", finishing: "ready" } as const;
const timer = window.setTimeout(() => {
@@ -54,14 +80,24 @@ export function ExternalAgentInvitePreview({
preset={screen === "picker" ? null : preset}
companyName={companyName}
busy={preparing}
prompt={preparing ? "" : preset === "dot" ? dotInvitePrompt(generation) : externalInvitePrompt}
error={error}
prompt={preparing || invitationUnavailable ? "" : preset === "muse" ? musePairing.setupInstruction : preset === "dot" ? dotInvitePrompt(generation) : externalInvitePrompt}
museEnabled={museEnabled}
museConnection={invitationUnavailable ? undefined : museConnection}
museDraft={museDraft}
onMuseDraftChange={setMuseDraft}
onMuseContinue={() => setMuseDraft(undefined)}
approvalHref={pendingApproval ? "/approvals/example-muse-hire" : undefined}
connection={connection}
onSelect={value => { setPreset(value); setScreen("setup"); }}
onSelect={value => { setPreset(value); if (value === "muse") setMuseDraft({ name: "", role: "general" }); setScreen("setup"); }}
onBack={() => setScreen("picker")}
onClose={() => setScreen("closed")}
onCopied={() => { if (preset === "dot") setWatching(true); }}
onRetry={() => { setConnection(previous => ({ phase: previous.phase })); setRetrying(true); setWatching(true); }}
onNewPrompt={() => { setGeneration(value => value + 1); setConnection({ phase: "waiting" }); setWatching(false); }}
onCopied={() => {
if (preset === "muse" && beforeMuseSimulation) void beforeMuseSimulation().then(() => setWatching(true));
else if (preset === "dot" || preset === "muse") setWatching(true);
}}
onRetry={() => { setMuseConnection(previous => ({ ...previous, problem: undefined })); setConnection(previous => ({ phase: previous.phase })); setRetrying(true); setWatching(true); }}
onNewPrompt={() => { setMuseConnection({ paired: false, receiverDetected: false, backgroundReplyVerified: false, ready: false }); setGeneration(value => value + 1); setConnection({ phase: "waiting" }); setWatching(false); }}
/>
</AnimatedDialogContent>
</Dialog>
@@ -0,0 +1,47 @@
import { MUSE_ASSET_VERSION, MUSE_CLIENT_VERSION, type MuseBinding, type MuseConnection, type MusePairing, type MuseStopBoundary } from "@paperclipai/shared";
/** Public, nonsecret fixture values only; the .example origin cannot connect a real Muse. */
export const museBinding: MuseBinding = {
id: "11111111-1111-4111-8111-111111111111", generation: 2, revision: 7, status: "connected",
paired: true, receiverDetected: true, backgroundReplyVerified: false,
pairingExpiresAt: null, challengeExpiresAt: null,
lastReceiverContactAt: "2026-10-13T14:00:00Z", lastWorkerActivityAt: "2026-10-13T13:59:40Z", lastVerifiedReplyAt: null,
credentialState: "active", refreshExpiresAt: "2026-11-12T14:00:00Z",
receiverContactTrust: "public_untrusted", contactPersistenceLagMs: 30000, clientVersion: MUSE_CLIENT_VERSION, qualification: null,
liveAssignments: 0, uncertainOperations: 0, pendingInputs: 0,
cleanup: { authenticatedAfterRevocation: false, pending: false, detectorRemovalRequested: false, detectorRemoved: false, workerQuiescenceReported: false, expiresAt: null },
stop: { status: "none", nativeEffectsUnknown: false, boundary: null, bindingRevision: null },
};
export const museConnection: MuseConnection = {
enabled: true, publicOrigin: "https://paperclip.example", agentStatus: "idle", agentLifecycleState: "verifying",
canConfigureConnection: true, binding: museBinding, usage: null, cost: null,
};
export const musePairing: MusePairing = {
bindingId: museBinding.id, generation: museBinding.generation, revision: museBinding.revision,
ticket: "STORYBOOK-NOT-A-REAL-TICKET", expiresAt: "2099-10-10T14:10:00Z", assetVersion: MUSE_ASSET_VERSION,
setupInstruction: `Storybook illustration only. The .example hostname, scope IDs, and ticket are public fixture values, not credentials. This example cannot connect a real agent or grant task authority.
Production setup connects the exact company, agent, and binding through Muse's supported SecureVault/authd OAuth consent. credentials.request_api_access uses oauth2_code, public-client registration and S256 PKCE; the single-use setup ticket is carried only into the OAuth authorization URL. Authd stores credentials and refreshes them automatically. The asset ${MUSE_ASSET_VERSION} client uses the supported dynamic_credentials surrogate helper. Never copy access/refresh tokens into files or environment variables.
After consent, install the immutable asset ${MUSE_ASSET_VERSION} client, detector and instructions using their pinned production manifest. Configure only the operator-approved nonsecret company/agent/binding scope. Complete Muse's actual site and tool permission prompts; public preflight grants no task authority. Verify authenticated scoped status before registering the five-second public detector and the discoverable standing-delegation skill.
In production, the operator's scoped installation delegates receiving, validating and autonomously accepting ordinary tasks for that exact company, agent and binding, including later detector wakes and fresh conversations. Task text cannot expand the delegation. Observe the exact native accepted lease before any task effect; Muse's private tool, site, safety and approval gates remain in force.
Public receiver contact is untrusted and may take up to 30 seconds to persist. Only an independent authenticated background reply and the agent lifecycle gate establish readiness. Disconnect immediately revokes OAuth authority; post-revocation authenticated cleanup is unavailable, and detector removal, worker stopping and unknown effects remain unconfirmed.`,
};
export const museStopBoundary: MuseStopBoundary = {
bindingId: museBinding.id, generation: museBinding.generation,
assignmentId: "22222222-2222-4222-8222-222222222222", runId: "33333333-3333-4333-8333-333333333333",
turnId: "turn-muse-example", assignmentRevision: 3, stopNonce: "44444444-4444-4444-8444-444444444444", operationBoundary: "task-document-save",
};
export const stoppedMuseConnection: MuseConnection = { ...museConnection, enabled: false, agentStatus: "paused", agentLifecycleState: "paused",
binding: { ...museBinding, status: "revoked", credentialState: "revoked", refreshExpiresAt: null, liveAssignments: 0, uncertainOperations: 1, pendingInputs: 1,
cleanup: { authenticatedAfterRevocation: false, pending: true, detectorRemovalRequested: false, detectorRemoved: false, workerQuiescenceReported: false, expiresAt: null },
stop: { status: "cannot_confirm", nativeEffectsUnknown: true, boundary: museStopBoundary, bindingRevision: museBinding.revision } } };
/** Each play/render gets its own gate; simulated progress follows observed copy feedback. */
export function createMuseCopyFeedbackGate() {
let feedbackObserved!: () => void;
const feedback = new Promise<void>(resolve => { feedbackObserved = resolve; });
return { waitForFeedback: () => feedback, feedbackObserved };
}