SaylorTwift HF Staff commited on
Commit
fd2c364
·
verified ·
1 Parent(s): 3201ca6

Add files using upload-large-folder tool

Browse files
This view is limited to 50 files because it contains too many changes.   See raw diff
Files changed (50) hide show
  1. .agents/skills/custom-codereview-guide.md +201 -0
  2. .agents/skills/pr-design-doc/SKILL.md +168 -0
  3. .agents/skills/pr-design-doc/references/html-craft.md +322 -0
  4. .agents/skills/release.md +110 -0
  5. .gitattributes +8 -0
  6. .github/pr-assets/pr-16268-02-lightbox-open.png +3 -0
  7. .github/pr-assets/pr-16605-test-results.png +3 -0
  8. .github/pr-assets/pr-16676-01-list.png +3 -0
  9. .github/pr-assets/pr-16676-02-templates.png +3 -0
  10. .github/pr-assets/pr-16676-04-edit-modal.png +3 -0
  11. .github/pr-assets/pr-16860-01-skills-default.png +3 -0
  12. .github/pr-assets/pr-16860-02-recommended-facet.png +3 -0
  13. __tests__/api/acp-service/acp-service.api.test.ts +130 -0
  14. __tests__/api/agent-profiles-service/profile-field-support.test.ts +59 -0
  15. __tests__/api/backend-registry/active-store.test.ts +263 -0
  16. __tests__/api/backend-registry/health-store.test.ts +111 -0
  17. __tests__/api/backend-registry/last-conversation-store.test.ts +67 -0
  18. __tests__/api/backend-registry/storage.test.ts +365 -0
  19. __tests__/api/backend-registry/url-selection.test.ts +166 -0
  20. __tests__/api/cloud/conversation-create.test.ts +128 -0
  21. __tests__/api/cloud/conversation-delete.test.ts +51 -0
  22. __tests__/api/cloud/conversation-download.test.ts +63 -0
  23. __tests__/api/cloud/conversation-pause.test.ts +93 -0
  24. __tests__/api/cloud/conversation-public-flag.test.ts +73 -0
  25. __tests__/api/cloud/conversation-runtime-info.test.ts +135 -0
  26. __tests__/api/cloud/conversation-title.test.ts +84 -0
  27. __tests__/api/cloud/fetch-test-utils.ts +33 -0
  28. __tests__/api/cloud/git-service.test.ts +69 -0
  29. __tests__/api/cloud/organization-me.test.ts +88 -0
  30. __tests__/api/cloud/organization-service.test.ts +144 -0
  31. __tests__/api/cloud/profiles-service.test.ts +217 -0
  32. __tests__/api/cloud/provider-connections-service.test.ts +140 -0
  33. __tests__/api/cloud/proxy.test.ts +272 -0
  34. __tests__/api/cloud/sandbox-service.test.ts +59 -0
  35. __tests__/api/cloud/secrets-service.test.ts +179 -0
  36. __tests__/api/cloud/settings-service.test.ts +200 -0
  37. __tests__/api/cloud/skills-service.test.ts +136 -0
  38. __tests__/api/mcp-health/mcp-health-store.test.ts +64 -0
  39. __tests__/api/mcp-health/probe-mcp-server-health.test.ts +179 -0
  40. __tests__/api/mcp-service/mcp-service.api.test.ts +395 -0
  41. __tests__/api/runtime-service/agent-server-runtime-service.test.ts +307 -0
  42. __tests__/components/analytics/telemetry-consent-banner.test.tsx +198 -0
  43. __tests__/components/automations/add-automation-modal.test.tsx +107 -0
  44. __tests__/components/automations/automation-card.test.tsx +368 -0
  45. __tests__/components/automations/automation-list-row.permissions.test.tsx +145 -0
  46. __tests__/components/automations/automation-list-row.test.tsx +225 -0
  47. __tests__/components/automations/automation-view-toggle.test.tsx +46 -0
  48. __tests__/components/automations/backend-not-configured.test.tsx +49 -0
  49. __tests__/components/automations/build-automation-pills.test.tsx +82 -0
  50. __tests__/components/automations/create-instructions.test.tsx +123 -0
.agents/skills/custom-codereview-guide.md ADDED
@@ -0,0 +1,201 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ ---
2
+ name: custom-codereview-guide
3
+ description: Repository-specific review rules for the OpenHands Agent Canvas frontend.
4
+ triggers:
5
+ - /codereview
6
+ ---
7
+
8
+ # OpenHands Agent Canvas Code Review Guidelines
9
+
10
+ This guide supplements the public `code-review` skill with rules specific to
11
+ `OpenHands/OpenHands`, the Agent Canvas frontend. Read `AGENTS.md` first; it is
12
+ the detailed source of truth for current architecture and test conventions.
13
+
14
+ Be direct and constructive. Review correctness and architecture, not formatting
15
+ that lint or the compiler already checks.
16
+
17
+ ## Review Decision
18
+
19
+ - Submit exactly one review: **APPROVE** or **COMMENT**. Never use
20
+ **REQUEST_CHANGES**.
21
+ - Default to **APPROVE** when there are no important findings. Nitpicks and
22
+ optional cleanup are not reasons to withhold approval.
23
+ - Use **COMMENT** for correctness, security, architecture, missing evidence, or
24
+ unmet acceptance criteria. Let a human maintainer make the blocking decision.
25
+ - Do not approve changes that can affect agent or benchmark behavior—prompts,
26
+ tool selection, conversation payloads, terminal behavior, planning, memory,
27
+ or evaluation paths—without human review and appropriate lightweight evals.
28
+ - Read the linked issue and include a compact checklist covering each acceptance
29
+ criterion. Meeting the checklist is necessary but does not replace review for
30
+ regressions, security, or maintainability.
31
+
32
+ ## Repository Ownership
33
+
34
+ Put behavior in the repository that owns it:
35
+
36
+ | Repository | Owns |
37
+ | ------------------------------ | --------------------------------------------------------------------------------------------------------------- |
38
+ | `OpenHands/OpenHands` | Agent Canvas UI, frontend state, backend selection, frontend service integration, and local-stack orchestration |
39
+ | `OpenHands/software-agent-sdk` | Agent Server, agents, tools, conversations, events, workspaces, and the canonical server API |
40
+ | `OpenHands/typescript-client` | Browser-compatible typed access to the Agent Server API |
41
+ | `OpenHands/extensions` | Reusable skills, plugins, and integrations |
42
+ | `OpenHands/automation` | Scheduling, webhooks, run history, and automation dispatch |
43
+
44
+ The normal dependency direction is Agent Server contract → TypeScript client →
45
+ Canvas. Flag raw endpoint reimplementations, Canvas-local copies of server
46
+ contracts, and changes opened in the wrong repository.
47
+
48
+ ## Architecture That Guides Agents
49
+
50
+ Agents tend to copy the nearest pattern and choose the shortest compiling path.
51
+ Review the codebase as part of the product surface that guides those choices:
52
+
53
+ 1. **Make the conventional path cheapest.** New work should naturally reuse a
54
+ named hook, service, store, or feature module instead of adding another branch
55
+ to a shared root.
56
+ 2. **Fail forbidden dependencies mechanically.** Repeated review guidance should
57
+ become a lint rule, compiler boundary, or architecture test. Do not grow this
58
+ document when a small executable guard would be clearer.
59
+ 3. **Give durable state one obvious writer.** A backend setting, consent value,
60
+ conversation cache entry, or persisted browser value should have one named
61
+ owner. Flag second writers and component-local mirrors of authoritative state.
62
+ 4. **Prefer owned feature files over shared switches.** Product work should
63
+ usually extend a feature-owned module. Shared registries and root conditionals
64
+ need a concrete reason.
65
+ 5. **Keep exceptions narrow and visible.** Exceptions belong in a small allowlist
66
+ next to the guard that enforces the rule and should be reviewed as architecture
67
+ changes.
68
+
69
+ Treat “deep module” as a design heuristic, not a line-count target. A good module
70
+ has a narrow, stable interface and hides cohesive complexity. Do not split a file
71
+ merely because it is long, and do not create layers that only rename or forward
72
+ arguments. Prefer a small pure seam when it removes duplicated decisions, makes
73
+ ownership explicit, or enables focused tests.
74
+
75
+ ### React effects
76
+
77
+ `useEffect` is for synchronizing React with an external system. Flag effects used
78
+ to:
79
+
80
+ - derive render data from props or state;
81
+ - respond to a user action that can run in the event handler;
82
+ - initialize a value that belongs in a lazy state initializer;
83
+ - mirror one store or cache into another component state value; or
84
+ - repair ordering created by competing writers.
85
+
86
+ An effect is not automatically wrong. Subscription, browser API, timer, and
87
+ network synchronization still belong in effects when cleanup and dependency
88
+ semantics are explicit.
89
+
90
+ ## Blocking Architecture Checkpoints
91
+
92
+ ### Agent Server and Cloud API access
93
+
94
+ `src/api/no-direct-agent-server-calls.test.ts` is the executable source of truth.
95
+ Do not approve new raw `fetch`, `axios`, shared `openHands`, or low-level HTTP
96
+ client access to Agent Server endpoints. Use `@openhands/typescript-client` with
97
+ the options from `src/api/agent-server-client-options.ts`.
98
+
99
+ Cloud and runtime-sandbox requests must go through `callCloudProxy`; runtime
100
+ requests must provide the correct `hostOverride` and authentication mode. Review
101
+ changes to the guard's allowlist as architecture changes. Do not copy its current
102
+ entries into this guide—the test should remain the one authoritative list.
103
+
104
+ ### Event wire contracts
105
+
106
+ The SDK event model is the wire authority, the TypeScript client mirrors it, and
107
+ Canvas consumes the published client type. Do not approve Canvas-local
108
+ redeclarations, partial intersections, module augmentation, or presentation
109
+ fields added to wire-event interfaces.
110
+
111
+ A contract change should land in this order:
112
+
113
+ 1. SDK model/schema and serialization coverage.
114
+ 2. TypeScript-client mirror derived from the SDK payload.
115
+ 3. Published client release.
116
+ 4. Canvas consumption and rendering/telemetry coverage.
117
+
118
+ Canvas-only presentation state belongs in a separate view model keyed by event
119
+ identity.
120
+
121
+ ### Telemetry and durable frontend state
122
+
123
+ - `src/services/telemetry.ts` is the only owner of the Canvas PostHog client.
124
+ - React events go through typed functions in `src/hooks/use-tracking.ts`; components
125
+ must not call PostHog directly.
126
+ - Consent rendering uses the telemetry consent external store, not mirrored local
127
+ state. `setTelemetryConsent` remains the single consent controller.
128
+ - A business milestone has one canonical capture. Flag duplicate conditional
129
+ captures.
130
+ - For other durable values, prefer the existing named service/store/hook and flag
131
+ new storage writes from arbitrary components.
132
+
133
+ ## Dependencies and Releases
134
+
135
+ - Direct dependencies are exact-pinned. Keep `package.json` and
136
+ `package-lock.json` synchronized through npm; do not hand-edit one side only.
137
+ - Treat changes to dependency exemptions, git pins, and security overrides as
138
+ reviewable policy changes. `__tests__/package-library.test.ts` is the executable
139
+ source of truth for allowed specs.
140
+ - Scrutinize newly published third-party dependency versions for supply-chain
141
+ risk. First-party OpenHands packages are exempt from a waiting period but not
142
+ from contract and release-order review.
143
+ - Package version changes belong in explicit release PRs and must match the
144
+ release workflow expectations.
145
+
146
+ ## Testing and Evidence
147
+
148
+ - Require evidence proportional to the behavior changed. For UI behavior, use a
149
+ screenshot or video from the real app. For CLI, API, or scripts, require the
150
+ exact runtime command and observed result.
151
+ - Runtime and user-visible bug fixes require before-and-after evidence through
152
+ the real production-facing path. The before evidence must reproduce the bug on
153
+ the base branch or released version; the after evidence must repeat the same
154
+ setup on the PR head and show the corrected behavior. Include exact commands,
155
+ relevant output, and screenshots or video when the behavior is visual.
156
+ - For lifecycle fixes, evidence must also verify the resulting process or resource
157
+ state—for example, the parent exit code and whether child services or listening
158
+ ports remain after shutdown.
159
+ - Unit and integration tests are regression proof, not a substitute for live
160
+ evidence. If required live evidence is missing, submit **COMMENT**, not
161
+ **APPROVE**, and identify the exact production-facing verification still needed.
162
+ - Prefer tests that exercise real logic and observable state. Do not reward mocks
163
+ that only prove another mock was called.
164
+ - Keep tests focused: one meaningful assertion path per behavior, no duplicated
165
+ coverage of library behavior, and no brittle presentation-only snapshots.
166
+ - Follow the test routing in `AGENTS.md`. The mock-LLM, Docker mock-LLM, and
167
+ live LLM-backed E2E suites run after changes reach `main`, not from PR labels.
168
+ If a risky PR needs pre-merge E2E evidence, recommend manually dispatching the
169
+ relevant workflow against the PR branch.
170
+ - Never broaden live E2E triggers or secret exposure for convenience.
171
+
172
+ ## Review Context Integrity
173
+
174
+ Before submitting the review, compare its summary and every finding against the
175
+ current PR title, changed-file manifest, linked issues, and acceptance criteria.
176
+ If the review describes files, behavior, issues, or release paths that are not in
177
+ that context, stop and re-read the PR rather than submitting stale or mismatched
178
+ feedback. Do not approve until this final context check passes.
179
+
180
+ ## What Not to Comment On
181
+
182
+ Do not leave review comments for:
183
+
184
+ - formatting or minor style that tooling handles;
185
+ - optional “nice to have” refactors unrelated to the change;
186
+ - praise-only observations—approve instead;
187
+ - extra tests for straightforward data/config changes when existing checks cover
188
+ the risk; or
189
+ - temporary `.pr/` artifacts, which are cleaned up by repository automation.
190
+
191
+ When raising a finding, trace the relevant call or data flow far enough to show
192
+ the concrete failure mode. Prefer one high-signal comment over several symptoms
193
+ of the same ownership problem.
194
+
195
+ ## Communication Style
196
+
197
+ - Be concise, specific, and friendly.
198
+ - Explain the user-visible or architectural consequence.
199
+ - Suggest the smallest viable correction.
200
+ - Use GitHub suggestion syntax for local fixes.
201
+ - If the PR is sound, approve it without manufacturing feedback.
.agents/skills/pr-design-doc/SKILL.md ADDED
@@ -0,0 +1,168 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ ---
2
+ name: pr-design-doc
3
+ description: >
4
+ For a non-trivial pull request, write a self-contained HTML design doc under the
5
+ temporary `.pr/` directory and link a visibility-appropriate preview in the PR
6
+ description, so maintainers grasp the proposal at a glance - code/API design, and the
7
+ before/after of the change, grounded to real code. Use when opening or updating a
8
+ non-trivial PR, or when the user says "add a design doc", "document this PR for
9
+ reviewers", "show the before/after", "make the design reviewable", or "write the .pr/
10
+ page".
11
+ triggers:
12
+ - /pr-design-doc
13
+ - /design-doc
14
+ license: MIT
15
+ metadata:
16
+ tags: pull-request, design-doc, html, review, before-after, htmlpreview
17
+ ---
18
+
19
+ # pr-design-doc - a reviewable design doc for a non-trivial PR
20
+
21
+ A diff shows *what changed line by line*. It does not show *the design*: the shape of the
22
+ change, the API before and after, and why this approach. Reviewers reconstruct that by
23
+ hand, slowly. The scarce resource is the maintainer's attention and trust budget - not the
24
+ agent's effort. Spend extra effort to hand them **one self-contained HTML page** that
25
+ conveys the **big picture** and the **before → after core difference**, with every claim
26
+ **clickable back to the real code**, then link it from the PR description.
27
+
28
+ This is the same craft as a "show me this change" explainer, aimed at one job: making a
29
+ non-trivial PR easy to review.
30
+
31
+ ## When to use it
32
+
33
+ - Opening or updating a **non-trivial** PR: new/changed public API, a new module or
34
+ subsystem, a behavior change in core logic, a migration, or anything a reviewer can't
35
+ fully judge from the diff in a couple of minutes.
36
+ - **Skip it** for trivial PRs - a typo, a one-line guard, a dependency bump, a docs tweak, a simple bug fix.
37
+ A design doc adds more to review. Use judgment; if the diff *is* the explanation, don't add a
38
+ page.
39
+
40
+ ## The `.pr/` workflow
41
+
42
+ Use the temporary **`.pr/`** directory for PR-only artifacts. Before relying on automatic
43
+ cleanup, verify that the target repository has an enabled
44
+ `.github/workflows/pr-artifacts.yml` workflow that removes `.pr/` after approval.
45
+
46
+ - Same-repository PR with verified cleanup workflow: the workflow removes `.pr/` after
47
+ approval.
48
+ - Fork PR, or repository without a verified cleanup workflow: remove `.pr/` manually before
49
+ merge.
50
+
51
+ The design doc is a review aid that lives with the branch while the PR is open. It must not
52
+ ship in the merged tree.
53
+
54
+ ## Workflow
55
+
56
+ 1. **Check out and verify the PR head.** Do not write or commit the design doc from the base
57
+ branch or an unrelated checkout. Start with a clean worktree, then inspect and check out
58
+ the PR:
59
+ ```bash
60
+ gh pr view <n> --json title,body,url,baseRefName,baseRefOid,headRefName,headRefOid,headRepository,headRepositoryOwner,isCrossRepository,files,additions,deletions
61
+ gh pr checkout <n>
62
+ git rev-parse HEAD
63
+ gh pr view <n> --json headRefOid --jq .headRefOid
64
+ ```
65
+ The final two SHAs must match before you continue. If they do not, stop and fix the
66
+ checkout. Compute the merge-base SHA with
67
+ `git merge-base <baseRefOid> <headRefOid>`. Group changed files by area and keep both the
68
+ merge-base SHA and head SHA for source links.
69
+
70
+ 2. **Read both sides of each logical file.** Compare
71
+ `git show <merge-base-sha>:<path>` with the verified head. Capture the
72
+ **function-level** behavioral difference - what the code *did* vs *does now*.
73
+ - new file → no "before"; one "after" diagram + a line on the role it adds.
74
+ - deleted file → "before" diagram + who/what takes over.
75
+ - edited file → a before/after pair, with the delta highlighted.
76
+
77
+ 3. **Classify each file.** *Logic* change (behavior moved) → draw before/after. *Mechanical*
78
+ change (rename, constant, config, import move) → a one-line `before → after` row, no
79
+ diagram. Don't dilute the signal by drawing mechanical edits.
80
+
81
+ 4. **If the change is an API change, lead with the API.** Show the signature/schema/type
82
+ **before and after** side by side (function signature, endpoint + payload, config field,
83
+ event shape). Name the compatibility impact plainly: additive, breaking, or behind a flag.
84
+
85
+ 5. **Find the cross-file story.** If one call chain threads several files, draw a single
86
+ **overview** before/after at the top; per-file cards drill in.
87
+
88
+ 6. **Build the page** per [`references/html-craft.md`](references/html-craft.md) - one
89
+ self-contained, offline, editorial HTML file with hand-drawn SVG figures. Save it to
90
+ the repo's `.pr/` directory, e.g. `.pr/design.html` (or `.pr/<topic>.html`). Before
91
+ writing, reject a symlink at `.pr` or at the exact output path; never follow a
92
+ branch-controlled symlink outside the worktree.
93
+ ```bash
94
+ test ! -L .pr && test ! -L .pr/design.html
95
+ mkdir -p .pr
96
+ ```
97
+
98
+ 7. **Commit under `.pr/`, push to the verified PR head, and link it.** Confirm that the push
99
+ remote resolves to `headRepository.nameWithOwner`; never push the artifact to the base
100
+ repository's default branch.
101
+ ```bash
102
+ git add .pr/design.html
103
+ git commit -m "docs(.pr): design doc for <PR topic>"
104
+ git push <head-repo-remote> HEAD:<headRefName>
105
+ ```
106
+ Query the base repository's visibility before choosing the link:
107
+ ```bash
108
+ gh repo view <base-owner>/<base-repo> --json visibility,url
109
+ ```
110
+ - **Public repository:** add an htmlpreview link near the top of the PR description,
111
+ pointing at the **fork and branch the PR is opened from** (it renders before merge):
112
+ ```
113
+ 📄 Design doc: https://htmlpreview.github.io/?https://github.com/<fork-owner>/<repo>/blob/<pr-branch>/.pr/design.html
114
+ ```
115
+ - **Private or internal repository:** link the access-controlled GitHub blob and include
116
+ local download/open instructions, or use an existing access-controlled artifact
117
+ service. Never send the document through htmlpreview or another public host.
118
+
119
+ ## What the page contains
120
+
121
+ 1. **What changed (decision first)** - one paragraph: the intent, net effect, and why the
122
+ reviewer should care. Put the highest-impact conclusion, risk, or API-compat note in a
123
+ `★` callout, with the most important changed `path:line` nearby. Stats (`N files ·
124
+ +A / −D`) are context, not the lead. If there's a cross-file flow, the **overview
125
+ before/after SVG** goes here.
126
+ 2. **API before → after** (when the PR changes an interface) - signatures/schemas/types side
127
+ by side, with the compatibility verdict stated.
128
+ 3. **Left rail / index** - changed files grouped by area, each tagged (🟢 added · 🔴 removed ·
129
+ ✏️ changed · ⚙️ mechanical) with +/− counts; click to jump.
130
+ 4. **Per-file cards** - for each logical file: a claim-carrying title, a one-line summary of
131
+ how its behavior changed, **before/after** diagrams with real symbol names + `file:line`
132
+ (changed nodes in orange), and the diff in a collapsed `<details>`. Mechanical files get a
133
+ small `before → after` table, no diagram.
134
+ 5. **(optional) Risk / follow-ups** - only if grounded in what you read.
135
+
136
+ ## Non-negotiable principles
137
+
138
+ 1. **Optimize for scarce reviewer attention.** The first screen answers, in ~15 seconds:
139
+ what this PR does, whether it's risky, where to look first, and what evidence backs the
140
+ claim. Lead with the conclusion, not your process.
141
+ 2. **Show the difference, not just the after.** For any logic or API change, draw **before**
142
+ and **after** and make the *delta* visually loud (color + line style). The contrast is
143
+ the product.
144
+ 3. **Ground everything to code, beside the claim.** Every box, node, and sentence names a
145
+ real symbol + `path:line`, and links to the correct source revision where possible: the
146
+ merge-base SHA for before-state evidence and the verified head SHA for after-state
147
+ evidence. One click from "this changed" to the exact code.
148
+ 4. **Hand-draw the carrying diagrams.** Prefer bespoke inline SVG for the before/after that
149
+ makes the argument; Mermaid is fine only for quick auxiliary graphs.
150
+ 5. **Self-contained & offline.** One HTML file, inline CSS/SVG, no external scripts or
151
+ assets, opens by double-click, and survives being copied to another machine.
152
+ 6. **`.pr/` only, and temporary.** The doc is a review aid, not project docs. Keep it in
153
+ `.pr/` and ensure it is removed before merge. Rely on automatic cleanup only when the
154
+ repository's workflow has been verified; otherwise remove it manually. Do not move design
155
+ HTML into `docs/` or ship it in the merged tree.
156
+
157
+ ## Anti-patterns
158
+
159
+ - ❌ Dumping the raw diff / file tree and calling it a "design doc" - adds nothing over the
160
+ PR page.
161
+ - ❌ Empty nodes ("process data", "handle request") - every node is a real symbol +
162
+ location.
163
+ - ❌ Only the after-state when something changed - reviewers want the *contrast*.
164
+ - ❌ A design doc on a trivial PR - noise. Skip it.
165
+ - ❌ Committing the HTML outside `.pr/` (e.g. `docs/`), where it would merge into `main`.
166
+ - ❌ Publishing a private-repository design doc through htmlpreview, GitHub Pages, or another
167
+ public host. Use the private/local preview path in the craft reference. Use GitHub Pages
168
+ only with explicit user authorization after verifying private Pages access control.
.agents/skills/pr-design-doc/references/html-craft.md ADDED
@@ -0,0 +1,322 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # html-craft - how to build the page
2
+
3
+ Shared craft for every show-me dimension. One self-contained, offline, editorial HTML
4
+ page with hand-drawn SVG figures and code-grounded claims.
5
+
6
+ ## The look (editorial document, light theme)
7
+
8
+ Calm, readable, document-like - not a dark dashboard. Skeleton:
9
+
10
+ ```html
11
+ <!doctype html><html lang="en"><head><meta charset="utf-8">
12
+ <meta name="viewport" content="width=device-width, initial-scale=1">
13
+ <title>{{Title}}</title>
14
+ <style>
15
+ :root{
16
+ /* single source of truth - prose AND inline-SVG fills both read these (see SVG section) */
17
+ --fg:#1a1a1a; --muted:#5a5a5a; --subtle:#8a8a82; /* 3 text weights: title · detail · sublabel */
18
+ --bg:#fdfdfb; --card:#ffffff; --elevated:#f7f6f1; /* page · node fill · raised band/pill */
19
+ --accent:#2b4eaa; --accent-soft:#e6ecfa;
20
+ --rule:#e2e2dc; --rule-strong:#bdbcb4; /* hairline · emphasized border */
21
+ --code-bg:#f3f1ec; --warn:#b25b0e; --ok:#2f7a3a; --crit:#a02020;
22
+ --mono:ui-monospace,SFMono-Regular,Menlo,Consolas,monospace;
23
+ --sans:-apple-system,BlinkMacSystemFont,"Segoe UI",Inter,Helvetica,Arial,sans-serif;
24
+ }
25
+ *{box-sizing:border-box} html,body{margin:0;background:var(--bg);color:var(--fg);font-family:var(--sans)}
26
+ body{line-height:1.6;font-size:16px}
27
+ .wrap{display:grid;grid-template-columns:240px minmax(0,1fr);gap:48px;max-width:1180px;margin:0 auto;padding:32px 28px 96px}
28
+ nav.toc{position:sticky;top:24px;align-self:start;font-size:13px}
29
+ nav.toc h2{font-size:11px;font-weight:700;letter-spacing:.12em;text-transform:uppercase;color:var(--muted);margin:0 0 10px}
30
+ nav.toc ol{list-style:none;padding:0;margin:0;counter-reset:toc}
31
+ nav.toc li{counter-increment:toc;margin:4px 0}
32
+ nav.toc li::before{content:counter(toc) ". ";color:var(--muted)}
33
+ nav.toc a{color:var(--fg);text-decoration:none} nav.toc a:hover{color:var(--accent)}
34
+ header.title{border-bottom:1px solid var(--rule);padding-bottom:22px;margin-bottom:28px}
35
+ header.title .eyebrow{font-size:11px;letter-spacing:.14em;text-transform:uppercase;color:var(--accent);font-weight:700}
36
+ header.title h1{font-size:34px;margin:6px 0 4px;letter-spacing:-.01em}
37
+ header.title .sub{color:var(--muted);max-width:740px}
38
+ h2{font-size:22px;margin:44px 0 10px} h2 .num{color:var(--muted);font-weight:500;margin-right:8px}
39
+ h3{font-size:16px;margin:22px 0 8px}
40
+ code{font-family:var(--mono);font-size:.88em;background:var(--code-bg);padding:1px 5px;border-radius:3px}
41
+ pre{font-family:var(--mono);font-size:13px;background:var(--code-bg);padding:14px 16px;border-radius:6px;overflow-x:auto;border:1px solid var(--rule)}
42
+ pre code{background:transparent;padding:0}
43
+ .kw{color:#7048a8}.str{color:var(--ok)}.com{color:var(--subtle);font-style:italic}
44
+ table{border-collapse:collapse;width:100%;font-size:14px;margin:12px 0}
45
+ th,td{text-align:left;padding:8px 10px;border-bottom:1px solid var(--rule);vertical-align:top}
46
+ th{font-weight:600;background:var(--code-bg)}
47
+ .callout{border-left:3px solid var(--accent);background:var(--accent-soft);padding:10px 14px;margin:14px 0;border-radius:0 4px 4px 0;font-size:14.5px}
48
+ .callout.warn{border-color:var(--warn);background:#fbf1e6} .callout.note{border-color:var(--muted);background:#f3f1ec}
49
+ .fig{margin:18px 0 22px}
50
+ .fig svg{display:block;max-width:100%;height:auto;background:#fff;border:1px solid var(--rule);border-radius:6px}
51
+ .fig figcaption{font-size:13px;color:var(--muted);margin-top:6px;text-align:center}
52
+ a.src{font-family:var(--mono);font-size:12px;color:var(--accent);text-decoration:none;border-bottom:1px dotted var(--accent)}
53
+ details{border:1px solid var(--rule);border-radius:6px;margin:12px 0}
54
+ details>summary{cursor:pointer;padding:8px 12px;color:var(--muted);font-size:13px;list-style:none}
55
+ details>summary::-webkit-details-marker{display:none}
56
+ details>summary::before{content:"▸ "} details[open]>summary::before{content:"▾ "}
57
+ .ba{display:grid;grid-template-columns:1fr 1fr;gap:16px;align-items:start}
58
+ @media(max-width:760px){.wrap{grid-template-columns:1fr}.ba{grid-template-columns:1fr}}
59
+ </style></head><body>
60
+ <div class="wrap">
61
+ <nav class="toc"><h2>Contents</h2><ol>
62
+ <li><a href="#sec1">…</a></li>
63
+ </ol></nav>
64
+ <main>
65
+ <header class="title"><div class="eyebrow">{{kind}}</div><h1>{{Title}}</h1>
66
+ <p class="sub">{{one-paragraph mental model - the big picture in 2-3 sentences}}</p></header>
67
+ <section id="sec1"><h2><span class="num">1</span>…</h2> … </section>
68
+ </main>
69
+ </div></body></html>
70
+ ```
71
+
72
+ Numbered sticky TOC + one-paragraph mental model up top + sectioned body. No JS needed
73
+ for this shell.
74
+
75
+ ## First screen: 15-second orientation
76
+
77
+ The reader's attention is the budget. The first viewport should answer four questions
78
+ without requiring a full read:
79
+
80
+ 1. **What is this?** A one-paragraph mental model in the title subtitle.
81
+ 2. **Why does it matter?** The highest-impact conclusion, risk, or action in a `★`
82
+ callout near the top.
83
+ 3. **Where should I jump?** A numbered sticky TOC whose labels carry information, not
84
+ just categories.
85
+ 4. **Why should I trust it?** Nearby source links (`path:line`, test, command, commit,
86
+ or fixture) for the first substantive claim.
87
+
88
+ Do not open with "I read these files" or a chronological work log. Start with the
89
+ reader's decision: what changed, how the system works, what path matters, or where to
90
+ look next. Put methods, command output, and raw diffs behind `<details>` unless they
91
+ are the point of the report.
92
+
93
+ ## Skimmable structure
94
+
95
+ Design the page so a busy reader can scan headings, captions, callouts, and tables
96
+ before choosing where to dive:
97
+
98
+ - **Headings make claims.** Prefer "Writes only cross the queue" over "Architecture",
99
+ when the section has a specific finding. Generic labels are acceptable only when the
100
+ title/subtitle already carries the finding.
101
+ - **One paragraph, one judgment.** Keep paragraphs short; split when a sentence starts
102
+ proving a different point.
103
+ - **Number parallel points.** If you say "three rules" or "two risks", number them so
104
+ the reader can reconcile the claim with the list.
105
+ - **Use tables only for stable comparison axes.** A table should reduce cognitive
106
+ work, not force subtle judgments into neat boxes.
107
+ - **Make captions do work.** A caption states the takeaway of the figure, not merely
108
+ its type.
109
+
110
+ ## Callouts (give them semantic types)
111
+
112
+ The `.callout` / `.callout.warn` / `.callout.note` styles aren't interchangeable - assign
113
+ each a fixed job and the reader learns to skim by them:
114
+
115
+ - **`★` key takeaway** (accent) - the one load-bearing sentence of a section. At most one per
116
+ section; it's what the reader should remember if they read nothing else.
117
+ - **`ⓘ` note** (`.note`, muted) - a reading hint for a figure, an aside, a "why we did it this
118
+ way" that isn't on the critical path.
119
+ - **`⚠` warning** (`.warn`) - a boundary, a risk, a gotcha, a guardrail: trust boundaries,
120
+ "this does NOT do X", "never commit the secret". Reserve it for things that bite.
121
+
122
+ Don't let callouts become wallpaper - if every paragraph is a colored box, none of them carry
123
+ weight. A glyph prefix (`★ ⓘ ⚠`) makes the type legible before the reader parses the text.
124
+
125
+ ## Hand-drawn SVG figures (the diagrams that carry the argument)
126
+
127
+ Draw bespoke inline `<svg viewBox="0 0 W H">` with a `<defs>` for arrow markers and a
128
+ scoped `<style>`. You place every box → before/after stays aligned, legible, and the
129
+ delta is unmistakable. Semantic class system:
130
+
131
+ ```html
132
+ <svg viewBox="0 0 960 460" role="img" aria-labelledby="f1-t f1-d">
133
+ <title id="f1-t">moduleA call path - before vs after</title>
134
+ <desc id="f1-d">Old direct call is replaced by a routed path through the new resolver.</desc>
135
+ <defs>
136
+ <!-- one marker per semantic color; marker fills read the page palette -->
137
+ <marker id="arr" viewBox="0 0 10 10" refX="9" refY="5" markerWidth="8" markerHeight="8" orient="auto-start-reverse">
138
+ <path d="M0,0 L10,5 L0,10 Z" fill="var(--subtle)"/></marker>
139
+ <marker id="arr-chg" viewBox="0 0 10 10" refX="9" refY="5" markerWidth="8" markerHeight="8" orient="auto-start-reverse">
140
+ <path d="M0,0 L10,5 L0,10 Z" fill="var(--warn)"/></marker>
141
+ <!-- subtle "elevated" wash for the box that is the figure's subject -->
142
+ <linearGradient id="subj" x1="0" y1="0" x2="0" y2="1">
143
+ <stop offset="0" stop-color="var(--accent)" stop-opacity=".10"/>
144
+ <stop offset="1" stop-color="var(--accent)" stop-opacity=".02"/></linearGradient>
145
+ <style>
146
+ /* every fill/stroke is a --token: the palette lives once, in :root - prose & figures can't drift */
147
+ .existing{fill:var(--code-bg);stroke:var(--subtle);stroke-width:1.4;stroke-dasharray:5 3} /* before */
148
+ .ex-title{font:700 13px var(--sans);fill:var(--muted)} .ex-detail{font:11.5px var(--mono);fill:var(--subtle)}
149
+ .new{fill:var(--accent-soft);stroke:var(--accent);stroke-width:2} /* after / added */
150
+ .new-title{font:800 13px var(--sans);fill:var(--accent)} .detail{font:11.5px var(--mono);fill:var(--fg)}
151
+ .edge{stroke:var(--subtle);stroke-width:1.5;stroke-dasharray:5 3;fill:none}
152
+ .edge-chg{stroke:var(--warn);stroke-width:1.8;fill:none;stroke-dasharray:5 3} /* the delta */
153
+ .lbl{font:600 10.5px var(--mono);fill:var(--accent)} .gap{font:700 11.5px var(--mono);fill:var(--warn)}
154
+ </style>
155
+ </defs>
156
+ <!-- ===== BEFORE: direct call ===== -->
157
+ <g>
158
+ <rect class="existing" x="40" y="60" width="180" height="74" rx="6"/>
159
+ <text class="ex-title" x="130" y="90" text-anchor="middle">moduleA.fn()</text>
160
+ <text class="ex-detail" x="130" y="110" text-anchor="middle">old behavior · a.py:42</text>
161
+ </g>
162
+ <!-- ===== AFTER: routed call (the change) ===== -->
163
+ <g>
164
+ <rect class="new" x="300" y="60" width="180" height="74" rx="6"/>
165
+ <text class="new-title" x="390" y="90" text-anchor="middle">moduleA.fn()</text>
166
+ <path class="edge-chg" d="M220,97 L300,97" marker-end="url(#arr-chg)"/>
167
+ <!-- pill behind an on-edge label so it stays legible over the line -->
168
+ <rect x="236" y="79" width="48" height="16" rx="5" fill="var(--elevated)" stroke="var(--rule)"/>
169
+ <text class="gap" x="260" y="91" text-anchor="middle">new path</text>
170
+ </g>
171
+ </svg>
172
+ ```
173
+
174
+ **Five craft moves in that skeleton - they're what make a figure read like a designed
175
+ diagram instead of a sketch:**
176
+ - **Drive every fill/stroke from `var(--token)`.** Define the palette once in `:root`; the
177
+ SVG references it. One source of truth - prose and figures stay in lockstep, and a palette
178
+ tweak reflows every diagram. Never paste a raw hex into a figure.
179
+ - **Three text weights, always the same three.** `--fg` for the node title (the real symbol),
180
+ `--muted` for the detail line, `--subtle` for sublabels/`path:line`. The eye sorts the
181
+ hierarchy without reading. Mixing weights ad-hoc is what makes a diagram look noisy.
182
+ - **`<title>` + `<desc>` with `aria-labelledby`.** Not decoration: it's the figure's own
183
+ caption-of-record (screen readers, and a note-to-self of what the figure claims).
184
+ - **A gradient wash marks the subject.** The one container the figure is *about* gets the
185
+ `#subj` wash (accent 10%→2%); everything else is flat. The reader's eye lands on the
186
+ protagonist before reading a word.
187
+ - **`<g>` groups + `<!-- section -->` comments + on-edge label pills.** Author the SVG like
188
+ code: one `<g>` per logical unit, a comment naming it, and a small `rect` pill behind any
189
+ label that sits on a line (a bare label over an edge turns to mush). It stays editable when
190
+ you revise the layout three times.
191
+
192
+ **The before/after encoding (use consistently):**
193
+ - **dashed gray** (`.existing`, `.edge` muted) = what was there before / unchanged context.
194
+ - **solid accent** (`.new`) = what this change adds / the after-state.
195
+ - **orange dashed** (`.edge-chg`, `.gap`) = the *delta* - the new/changed flow, the gap being filled. This is what the eye should land on.
196
+
197
+ Node boxes carry a **title (real symbol) + a detail line (behavior + `file:line`)**, so the
198
+ diagram is already half-grounded to code.
199
+
200
+ Two ways to show before/after:
201
+ 1. **Side-by-side figures** in a `.ba` grid - `<figure>` "Before" | `<figure>` "After". Best when layouts differ a lot.
202
+ 2. **One overlaid figure** - existing nodes dashed-gray, new nodes/edges solid-accent + orange delta, in the same coordinate space. Best when the change is *additive* to an existing structure (most legible "what's new" read).
203
+
204
+ ## Caption every figure, and hint how to read the hard ones
205
+
206
+ A figure with no caption makes the reader reverse-engineer your intent. Two cheap habits
207
+ fix it:
208
+
209
+ - **The `<figcaption>` states the takeaway, not a label.** Not "Architecture diagram" -
210
+ *"Requests never touch the DB directly; every write goes through the queue."* The caption
211
+ is the one sentence you'd say pointing at the figure. Number them (`Fig 3 ·`) and
212
+ **cross-reference between figures** (`state machine in Fig 2`, `evolve timing in Fig 5`) so
213
+ a multi-figure report reads as one system at different altitudes, not five loose pictures.
214
+ - **For a figure with a non-obvious convention, add a one-line reading hint** in a `.callout.note`
215
+ right under it, naming the single thing that unlocks it: *"Every arrow enters or leaves the
216
+ bus - there are no agent-to-agent arrows, because the protocol is 'read/write the bus'."*
217
+ One sentence that says *how to look* saves the reader a minute of squinting. Put the hint in
218
+ the figure too when you can - an in-diagram `→ §3.2 / Fig 2` link (accent mono) turns the
219
+ picture into a table of contents.
220
+
221
+ ## Ground every claim to code (clickable)
222
+
223
+ A picture the human can't verify is a liability. Make claims droppable to source:
224
+
225
+ - Resolve both repository web bases explicitly. For a fork PR, before-state evidence belongs
226
+ to the base repository and merge-base SHA; after-state evidence belongs to the head
227
+ repository and verified head SHA. Query each with
228
+ `gh repo view <owner>/<repo> --json url`, then build links as
229
+ `https://github.com/<o>/<r>/blob/<sha>/<path>#L<n>`. A deleted file must link to the base
230
+ repository at the merge-base SHA; a newly added file has no before-state link.
231
+ - Render locations as `<a class="src" href="{{blobURL}}">path:line</a>` in node detail lines, section text, and a per-component "source" link.
232
+ - Rule: if a box can't be tied to a symbol+location, it's a *concept* box - style it differently and say so; don't fake a link.
233
+ - Put evidence beside the claim it supports. The reader should not have to scroll to a
234
+ bibliography to verify a behavior statement, edge, risk, metric, or test result.
235
+ - Commands count as evidence when behavior must be observed: show the command and the
236
+ relevant result near the claim, with full logs collapsed if noisy.
237
+
238
+ ### Grounding discipline (borrowed from DeepWiki)
239
+
240
+ DeepWiki-style wikis enforce grounding hard - worth copying:
241
+
242
+ - **Source manifest per section.** Open each major section / component card with a small
243
+ collapsed list of the exact files it's built from:
244
+ `<details><summary>Sources</summary> path/a.py · path/b.py …</details>`. The reader sees
245
+ what the claim rests on before trusting it.
246
+ - **Cite after every substantive claim, diagram, table, and snippet.** Inline format:
247
+ `Sources: [path:start-end]` (range) or `[path:line]` (single), multiple allowed. A
248
+ section with no citations is a smell - either ground it or cut it.
249
+ - **Solely from the code. Do not invent.** Every statement, box, edge, and number must be
250
+ derived from files you actually read - not from how "similar systems usually work." If
251
+ something important isn't in the code, say so explicitly rather than guessing. Mark genuine
252
+ inferences as inferences.
253
+ - **Breadth check.** Decide if necessary, depending on PR. A "comprehensive" page that cites only 1-2 files is probably shallow -
254
+ pull in the related files (callers, callees, config, tests) until the picture is real.
255
+
256
+ ## Code excerpts (when shown)
257
+
258
+ Keep them short and hand-highlight with spans (no JS highlighter): wrap keywords
259
+ `<span class="kw">`, strings `<span class="str">`, comments `<span class="com">`.
260
+ **Escape for the insertion context before wrapping** - source text is untrusted. In text
261
+ nodes, escape `&`, `<`, and `>`. In quoted HTML or SVG attributes, additionally escape `"`
262
+ as `&quot;` and `'` as `&#39;`; always quote attributes. For source URLs, percent-encode the
263
+ dynamic owner, repository, and ref as URL path segments; encode each source-path segment
264
+ separately and rejoin with `/`. Then attribute-escape the completed URL. Put long diffs/code
265
+ inside `<details>` (collapsed). Escape every source-derived value
266
+ inserted into HTML or SVG, including titles, symbol names, paths, labels, link text, and
267
+ attribute values. Never treat source-derived text as markup.
268
+
269
+ ## Self-contained
270
+
271
+ - One `.html` file. Inline all CSS and SVG. No build, no framework.
272
+ - No external scripts, styles, fonts, images, or other assets. Besides breaking offline use,
273
+ third-party active content can read and disclose a private design document opened locally.
274
+
275
+ ## Mermaid (quick / auxiliary only)
276
+
277
+ For drafting a throwaway or simple auxiliary graph, locally installed Mermaid can be faster
278
+ than hand-SVG. Render it locally to static SVG, inspect and sanitize the output, then inline
279
+ that SVG. Never ship a Mermaid runtime or CDN script in the page. For the **carrying**
280
+ before/after diagram, hand-SVG wins - auto-layout drifts and breaks the side-by-side alignment
281
+ that makes the delta readable.
282
+
283
+ ### Public repositories: commit under `.pr/` and use an htmlpreview link
284
+
285
+ Because the page is **self-contained**, GitHub can render it through `htmlpreview.github.io`
286
+ (it fetches the raw HTML and serves it with the right content-type - plain `raw.githubusercontent.com`
287
+ won't, it returns `text/plain`). Commit the file to the PR branch under the temporary `.pr/`
288
+ directory, then build the link and paste it in the PR description:
289
+
290
+ ```bash
291
+ # commit the html under .pr/ on your PR branch (this dir is temporary, see the skill)
292
+ git add .pr/design.html && git commit -m "docs(.pr): design doc" && git push <your-fork> <branch>
293
+ # link template - use YOUR fork + PR branch, so it renders before the PR is merged:
294
+ # https://htmlpreview.github.io/?https://github.com/<fork-owner>/<repo>/blob/<pr-branch>/.pr/design.html
295
+ ```
296
+
297
+ Example shape:
298
+ `https://htmlpreview.github.io/?https://github.com/FORK_OWNER/REPO/blob/PR_BRANCH/.pr/design.html`
299
+
300
+ `<pr-branch>` can be any branch or commit - `raw.githubusercontent.com` serves it regardless
301
+ of merge state, so the doc renders while the PR is still open. Point the URL at the **fork
302
+ and branch the PR is opened from**, not `main`.
303
+
304
+ For public repositories, anyone can open the rendered page without a download or local server.
305
+ This works with self-contained pages containing only inline CSS and SVG.
306
+
307
+ ### Private repositories: keep the preview private
308
+
309
+ `htmlpreview` cannot fetch private repository content. Do not work around that by publishing
310
+ the design doc to GitHub Pages or another public host. Keep the document free of external
311
+ scripts and assets: opening a local file does not make third-party active content private.
312
+
313
+ - Link authorized reviewers to the committed GitHub blob and ask them to download and open the
314
+ self-contained HTML file locally; or use an existing access-controlled artifact service.
315
+ - For a local browser preview, open `.pr/design.html` directly. If the browser needs HTTP,
316
+ serve only on loopback:
317
+ ```bash
318
+ python -m http.server 8000 --bind 127.0.0.1 --directory .pr
319
+ # open http://127.0.0.1:8000/design.html
320
+ ```
321
+ - Use GitHub Pages only when the user explicitly authorizes publication and an administrator
322
+ confirms that private Pages access control is enabled for this repository.
.agents/skills/release.md ADDED
@@ -0,0 +1,110 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ ---
2
+ name: release
3
+ description: Guide the release process for @openhands/agent-canvas — review the release-please draft PR, mark it ready, merge it; the tag push publishes to npm and Docker.
4
+ triggers:
5
+ - release
6
+ - new release
7
+ - cut a release
8
+ - publish release
9
+ - bump version
10
+ ---
11
+
12
+ # Release Process for @openhands/agent-canvas
13
+
14
+ ## Overview
15
+
16
+ Releases are **trunk-based and automated by release-please**, via the shared reusable
17
+ workflows in [`OpenHands/release-actions`](https://github.com/OpenHands/release-actions):
18
+
19
+ 1. PRs merge to `main` with **Conventional Commit titles** (`feat`, `fix`, `perf`, `docs`, `chore`, `build`, `ci`, `refactor`, `style`, `test`, `revert`). `.github/workflows/pr.yml` lints the title and applies the matching `type:` label; squash merge uses the PR title as the commit message.
20
+ 2. On every push to `main`, `.github/workflows/release.yml` runs release-please, which maintains a **draft release PR** titled `chore(main): release X.Y.Z` accumulating everything merged since the last release.
21
+ 3. The release PR pre-stages **every version bump**: `package.json`, `package-lock.json`, `config/defaults.json` (`versions.agentCanvas`), and the Docker image pins in `README.md` / `README.windows.md` (lines annotated with `x-release-please-version`).
22
+ 4. Marking the release PR **Ready for review** is the explicit cut-a-release signal: `.github/workflows/release-ready.yml` notifies `#proj-agent-canvas` on Slack and labels the PR `release: ready`.
23
+ 5. Merging the release PR makes release-please push the `vX.Y.Z` tag (using the org release App token) and create the GitHub Release. The same tag push triggers `npm-publish.yml` (npm) and `docker.yml` (multi-arch GHCR images).
24
+
25
+ The next version is derived from the conventional-commit types merged since the last release: `fix` → patch, `feat` → minor, any `!` suffix or `BREAKING CHANGE` footer → major. Other types (`docs`, `chore`, `refactor`, …) appear in the release notes but do not by themselves produce a release PR. Release notes are grouped by the `type:` labels via `.github/release.yml`; there is no `CHANGELOG.md` file — GitHub Releases are the changelog.
26
+
27
+ Configuration lives in `release-please-config.json` (version surfaces, draft PR) and `.release-please-manifest.json` (current released version). Maintenance releases for an older line use `release/**` branches (`release.yml` triggers there too).
28
+
29
+ **Never commit to the release PR's branch by hand** — release-please owns it and force-pushes it on every push to `main`.
30
+
31
+ ---
32
+
33
+ ## Step 1: Find the Release PR
34
+
35
+ ```bash
36
+ gh pr list --state open --label "autorelease: pending"
37
+ ```
38
+
39
+ If no release PR is open, nothing releasable (`feat`/`fix`/breaking) has merged since the last release — or `release.yml` is failing on `main`:
40
+
41
+ ```bash
42
+ gh run list --workflow=release.yml --limit=3
43
+ ```
44
+
45
+ ---
46
+
47
+ ## Step 2: Review It
48
+
49
+ Open the release PR and confirm:
50
+
51
+ - **The version** in the title matches expectations. It is computed from the merged commit types — if it looks wrong, check the conventional types of the PR titles merged since the last release. To force a specific version, merge a commit to `main` whose message contains a `Release-As: X.Y.Z` footer.
52
+ - **The staged bumps** cover all version surfaces (`package.json`, `package-lock.json`, `config/defaults.json`, `README.md`, `README.windows.md`) and the notes list the expected changes.
53
+
54
+ ---
55
+
56
+ ## Step 3: Cut the Release
57
+
58
+ **STOP HERE and confirm with the user before proceeding.** Marking the PR ready and merging it publishes to npm and GHCR.
59
+
60
+ ```bash
61
+ gh pr ready <release-pr-number>
62
+ ```
63
+
64
+ This fires the release-ready gate: a Slack notification lands in `#proj-agent-canvas` and the PR is labeled `release: ready`. Then merge the release PR (squash, like any other PR).
65
+
66
+ ---
67
+
68
+ ## Step 4: Watch the Pipeline
69
+
70
+ Merging the release PR triggers `release.yml` on `main`, which pushes the `vX.Y.Z` tag and creates the GitHub Release; the tag push then fires the publish workflows:
71
+
72
+ ```bash
73
+ gh run list --workflow=release.yml --limit=3
74
+ gh run list --workflow=npm-publish.yml --limit=3
75
+ gh run list --workflow=docker.yml --limit=3
76
+ ```
77
+
78
+ ---
79
+
80
+ ## Step 5: Verify the Release
81
+
82
+ ```bash
83
+ # GitHub release
84
+ gh release view v<version>
85
+
86
+ # npm (allow ~2 min for publish to propagate)
87
+ npm view @openhands/agent-canvas@<version>
88
+ npm view @openhands/agent-canvas dist-tags # stable releases get `latest`
89
+
90
+ # Docker
91
+ docker pull ghcr.io/openhands/agent-canvas:<version>
92
+ ```
93
+
94
+ External install docs on docs.openhands.dev are maintained separately; update them there when closing #1073.
95
+
96
+ ---
97
+
98
+ ## Troubleshooting
99
+
100
+ ### No release PR appears after merging to main
101
+ Only `feat`, `fix`, and breaking changes produce a release PR. Also check `release.yml` runs on `main` — the workflow fails by design if the org secrets `RELEASE_APP_ID` / `RELEASE_APP_PRIVATE_KEY` are unavailable (a `GITHUB_TOKEN` fallback would create a tag that never triggers the publish workflows).
102
+
103
+ ### The proposed version is wrong
104
+ The version comes from the conventional-commit history since the last release. Fix forward: merge a commit to `main` with a `Release-As: X.Y.Z` footer to pin the next version.
105
+
106
+ ### No Slack message when the PR was marked ready
107
+ `SLACK_BOT_TOKEN` is optional by design — the gate still applies the `release: ready` label and the release proceeds normally.
108
+
109
+ ### package.json version doesn't match the tag
110
+ This cannot happen in the normal flow: release-please bumps `package.json` in the release PR and tags the resulting merge commit, and `npm-publish.yml` validates they match. If it ever fails, someone pushed a tag by hand — delete the tag and let release-please own tagging.
.gitattributes CHANGED
@@ -4,3 +4,11 @@
4
  *.png binary
5
  *.ico binary
6
  *.icns binary
 
 
 
 
 
 
 
 
 
4
  *.png binary
5
  *.ico binary
6
  *.icns binary
7
+ electron/build-resources/icon.icns filter=lfs diff=lfs merge=lfs -text
8
+ .github/pr-assets/pr-16268-02-lightbox-open.png filter=lfs diff=lfs merge=lfs -text
9
+ .github/pr-assets/pr-16860-01-skills-default.png filter=lfs diff=lfs merge=lfs -text
10
+ .github/pr-assets/pr-16676-01-list.png filter=lfs diff=lfs merge=lfs -text
11
+ .github/pr-assets/pr-16605-test-results.png filter=lfs diff=lfs merge=lfs -text
12
+ .github/pr-assets/pr-16676-02-templates.png filter=lfs diff=lfs merge=lfs -text
13
+ .github/pr-assets/pr-16860-02-recommended-facet.png filter=lfs diff=lfs merge=lfs -text
14
+ .github/pr-assets/pr-16676-04-edit-modal.png filter=lfs diff=lfs merge=lfs -text
.github/pr-assets/pr-16268-02-lightbox-open.png ADDED

Git LFS Details

  • SHA256: 290462f47a19b9068b50dd239d134728acadf719356f8822ff6cece05c7d16f7
  • Pointer size: 131 Bytes
  • Size of remote file: 323 kB
.github/pr-assets/pr-16605-test-results.png ADDED

Git LFS Details

  • SHA256: 21d841229bb786493fcc05019f5c80d30d8e159df437691c0010699e7a9d4335
  • Pointer size: 131 Bytes
  • Size of remote file: 223 kB
.github/pr-assets/pr-16676-01-list.png ADDED

Git LFS Details

  • SHA256: eb03768fbc1ae545cbbd9729b3cc2b47bac81ac060a42a3a45fe1e3859529766
  • Pointer size: 131 Bytes
  • Size of remote file: 132 kB
.github/pr-assets/pr-16676-02-templates.png ADDED

Git LFS Details

  • SHA256: 944b74529e6354b31e1f11a603b27bec1a5b3dd9d4ef3c426844b630159aa681
  • Pointer size: 131 Bytes
  • Size of remote file: 141 kB
.github/pr-assets/pr-16676-04-edit-modal.png ADDED

Git LFS Details

  • SHA256: 9cd4a0f47d9e1160f6af61c085a2aa7c8ba3e95bdd5aa28005a45917936c091e
  • Pointer size: 131 Bytes
  • Size of remote file: 103 kB
.github/pr-assets/pr-16860-01-skills-default.png ADDED

Git LFS Details

  • SHA256: dd2806c5c395b80901182cbbaceeb72ff0c50dba4ed1b7c5883f20f71c891c62
  • Pointer size: 131 Bytes
  • Size of remote file: 202 kB
.github/pr-assets/pr-16860-02-recommended-facet.png ADDED

Git LFS Details

  • SHA256: fb6cb1e3c785167c74e21d4585fb4905b09c285f48a3dec65fc435548f043731
  • Pointer size: 131 Bytes
  • Size of remote file: 204 kB
__tests__/api/acp-service/acp-service.api.test.ts ADDED
@@ -0,0 +1,130 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import { beforeEach, describe, expect, it, vi } from "vitest";
2
+ import type { BashOutput } from "@openhands/typescript-client";
3
+ import AcpService from "#/api/acp-service/acp-service.api";
4
+
5
+ // Capture the command the service runs and control the BashOutput it sees.
6
+ const executeCommand = vi.hoisted(() => vi.fn());
7
+ vi.mock("@openhands/typescript-client/clients", () => ({
8
+ BashClient: class {
9
+ executeCommand = executeCommand;
10
+ },
11
+ }));
12
+ vi.mock("#/api/agent-server-client-options", () => ({
13
+ getAgentServerClientOptions: () => ({
14
+ host: "http://localhost",
15
+ workingDir: "/",
16
+ }),
17
+ }));
18
+
19
+ function bashOutput(partial: Partial<BashOutput>): BashOutput {
20
+ return {
21
+ id: "1",
22
+ timestamp: "2026-01-01T00:00:00Z",
23
+ command_id: "c1",
24
+ order: 0,
25
+ exit_code: 0,
26
+ stdout: null,
27
+ stderr: null,
28
+ kind: "BashOutput",
29
+ ...partial,
30
+ } as BashOutput;
31
+ }
32
+
33
+ beforeEach(() => vi.clearAllMocks());
34
+
35
+ describe("AcpService.getAuthStatus", () => {
36
+ describe("claude-code (claude auth status --json)", () => {
37
+ it("runs the right command and maps loggedIn:true → authenticated", async () => {
38
+ executeCommand.mockResolvedValue(
39
+ bashOutput({
40
+ stdout: JSON.stringify({ loggedIn: true, authMethod: "claude.ai" }),
41
+ }),
42
+ );
43
+ await expect(AcpService.getAuthStatus("claude-code")).resolves.toBe(
44
+ "authenticated",
45
+ );
46
+ expect(executeCommand).toHaveBeenCalledWith(
47
+ "claude auth status --json",
48
+ undefined,
49
+ expect.any(Number),
50
+ );
51
+ });
52
+
53
+ it("maps loggedIn:false (even with a non-zero exit) → unauthenticated", async () => {
54
+ executeCommand.mockResolvedValue(
55
+ bashOutput({
56
+ stdout: JSON.stringify({ loggedIn: false }),
57
+ exit_code: 1,
58
+ }),
59
+ );
60
+ await expect(AcpService.getAuthStatus("claude-code")).resolves.toBe(
61
+ "unauthenticated",
62
+ );
63
+ });
64
+
65
+ it("→ unknown when the CLI is missing (exit 127, no JSON on stdout)", async () => {
66
+ // The "no available ACP process / CLI not installed" path.
67
+ executeCommand.mockResolvedValue(
68
+ bashOutput({
69
+ exit_code: 127,
70
+ stderr: "env: claude: No such file or directory",
71
+ }),
72
+ );
73
+ await expect(AcpService.getAuthStatus("claude-code")).resolves.toBe(
74
+ "unknown",
75
+ );
76
+ });
77
+ });
78
+
79
+ describe("codex (codex login status)", () => {
80
+ it("→ authenticated even though the CLI writes to stderr", async () => {
81
+ executeCommand.mockResolvedValue(
82
+ bashOutput({ stderr: "Logged in using ChatGPT\n" }),
83
+ );
84
+ await expect(AcpService.getAuthStatus("codex")).resolves.toBe(
85
+ "authenticated",
86
+ );
87
+ });
88
+
89
+ it("→ unauthenticated on 'Not logged in'", async () => {
90
+ executeCommand.mockResolvedValue(
91
+ bashOutput({ stderr: "Not logged in\n" }),
92
+ );
93
+ await expect(AcpService.getAuthStatus("codex")).resolves.toBe(
94
+ "unauthenticated",
95
+ );
96
+ });
97
+
98
+ it("→ unknown when the CLI is missing", async () => {
99
+ executeCommand.mockResolvedValue(
100
+ bashOutput({ exit_code: 127, stderr: "codex: command not found" }),
101
+ );
102
+ await expect(AcpService.getAuthStatus("codex")).resolves.toBe("unknown");
103
+ });
104
+ });
105
+
106
+ describe("gemini-cli (credentials file check)", () => {
107
+ it("→ authenticated when the creds file is present", async () => {
108
+ executeCommand.mockResolvedValue(bashOutput({ stdout: "present\n" }));
109
+ await expect(AcpService.getAuthStatus("gemini-cli")).resolves.toBe(
110
+ "authenticated",
111
+ );
112
+ // Probes the OAuth creds file, not a (nonexistent) gemini status command.
113
+ expect(executeCommand.mock.calls[0][0]).toContain("oauth_creds.json");
114
+ });
115
+
116
+ it("→ unauthenticated when the creds file is absent", async () => {
117
+ executeCommand.mockResolvedValue(bashOutput({ stdout: "absent\n" }));
118
+ await expect(AcpService.getAuthStatus("gemini-cli")).resolves.toBe(
119
+ "unauthenticated",
120
+ );
121
+ });
122
+ });
123
+
124
+ it("→ unknown for an unprobeable provider, without running any command", async () => {
125
+ await expect(AcpService.getAuthStatus("openhands")).resolves.toBe(
126
+ "unknown",
127
+ );
128
+ expect(executeCommand).not.toHaveBeenCalled();
129
+ });
130
+ });
__tests__/api/agent-profiles-service/profile-field-support.test.ts ADDED
@@ -0,0 +1,59 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import { beforeEach, describe, expect, it, vi } from "vitest";
2
+ import {
3
+ MIN_AGENT_SERVER_VERSION_FOR_PROFILE_SWITCH_LLM_TOOL,
4
+ agentProfileSupportsSwitchLlmTool,
5
+ } from "#/api/agent-profiles-service/profile-field-support";
6
+
7
+ const mockGetCachedAgentServerVersion = vi.fn<() => string | null>();
8
+
9
+ // Only the version *lookup* is mocked — the comparison stays the real one so
10
+ // these cases exercise the same parser the boot-time compatibility check uses.
11
+ vi.mock("#/api/agent-server-compatibility", async (importOriginal) => {
12
+ const actual =
13
+ await importOriginal<typeof import("#/api/agent-server-compatibility")>();
14
+ return {
15
+ ...actual,
16
+ getCachedAgentServerVersion: () => mockGetCachedAgentServerVersion(),
17
+ };
18
+ });
19
+
20
+ describe("agentProfileSupportsSwitchLlmTool", () => {
21
+ beforeEach(() => {
22
+ mockGetCachedAgentServerVersion.mockReset();
23
+ });
24
+
25
+ it("pins the gate to the release that added the profile field", () => {
26
+ expect(MIN_AGENT_SERVER_VERSION_FOR_PROFILE_SWITCH_LLM_TOOL).toBe("1.31.0");
27
+ });
28
+
29
+ it.each(["1.29.0", "1.29.3", "1.30.0"])(
30
+ "reports no support on %s, where agent profiles exist but the field does not",
31
+ (version) => {
32
+ mockGetCachedAgentServerVersion.mockReturnValue(version);
33
+ expect(agentProfileSupportsSwitchLlmTool()).toBe(false);
34
+ },
35
+ );
36
+
37
+ it.each(["1.31.0", "1.31.2", "1.36.1", "2.0.0"])(
38
+ "reports support on %s",
39
+ (version) => {
40
+ mockGetCachedAgentServerVersion.mockReturnValue(version);
41
+ expect(agentProfileSupportsSwitchLlmTool()).toBe(true);
42
+ },
43
+ );
44
+
45
+ it("treats a prerelease of the gating version as older", () => {
46
+ mockGetCachedAgentServerVersion.mockReturnValue("1.31.0-rc.1");
47
+ expect(agentProfileSupportsSwitchLlmTool()).toBe(false);
48
+ });
49
+
50
+ it("assumes support when no version is cached (cloud backends)", () => {
51
+ mockGetCachedAgentServerVersion.mockReturnValue(null);
52
+ expect(agentProfileSupportsSwitchLlmTool()).toBe(true);
53
+ });
54
+
55
+ it("assumes support when the reported version does not parse", () => {
56
+ mockGetCachedAgentServerVersion.mockReturnValue("main");
57
+ expect(agentProfileSupportsSwitchLlmTool()).toBe(true);
58
+ });
59
+ });
__tests__/api/backend-registry/active-store.test.ts ADDED
@@ -0,0 +1,263 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
2
+ import {
3
+ __resetActiveStoreForTests,
4
+ getActiveBackend,
5
+ getEffectiveLocalBackend,
6
+ NO_BACKEND_ID,
7
+ setActiveSelection,
8
+ setRegisteredBackends,
9
+ subscribeActiveBackend,
10
+ } from "#/api/backend-registry/active-store";
11
+ import { SEEDED_DEFAULT_BACKEND_ID } from "#/api/backend-registry/default-backend";
12
+ import { MAX_CONSECUTIVE_FAILURES } from "#/api/backend-registry/health-storage";
13
+ import {
14
+ __resetHealthStoreForTests,
15
+ recordBackendFailure,
16
+ } from "#/api/backend-registry/health-store";
17
+ import {
18
+ ACTIVE_BACKEND_STORAGE_KEY,
19
+ BACKENDS_STORAGE_KEY,
20
+ } from "#/api/backend-registry/storage";
21
+ import {
22
+ BACKEND_QUERY_PARAM,
23
+ ORG_QUERY_PARAM,
24
+ } from "#/api/backend-registry/url-selection";
25
+ import type { Backend } from "#/api/backend-registry/types";
26
+
27
+ beforeEach(() => {
28
+ window.localStorage.clear();
29
+ window.sessionStorage.clear();
30
+ __resetHealthStoreForTests();
31
+ __resetActiveStoreForTests();
32
+ });
33
+
34
+ afterEach(() => {
35
+ window.localStorage.clear();
36
+ window.sessionStorage.clear();
37
+ vi.unstubAllEnvs();
38
+ __resetHealthStoreForTests();
39
+ __resetActiveStoreForTests();
40
+ });
41
+
42
+ const cloudBackend: Backend = {
43
+ id: "prod",
44
+ name: "Production",
45
+ host: "https://app.all-hands.dev",
46
+ apiKey: "bearer-key",
47
+ kind: "cloud",
48
+ };
49
+
50
+ const localBackend: Backend = {
51
+ id: "local-1",
52
+ name: "Local 1",
53
+ host: "http://localhost:9000",
54
+ apiKey: "k",
55
+ kind: "local",
56
+ };
57
+
58
+ const secondLocalBackend: Backend = {
59
+ id: "local-2",
60
+ name: "Local 2",
61
+ host: "http://localhost:9001",
62
+ apiKey: "k2",
63
+ kind: "local",
64
+ };
65
+
66
+ function markBackendUnhealthy(id: string): void {
67
+ for (let i = 0; i < MAX_CONSECUTIVE_FAILURES; i += 1) {
68
+ recordBackendFailure(id, new Error("connection failed"));
69
+ }
70
+ }
71
+
72
+ describe("active-store", () => {
73
+ it("uses the no-backend sentinel when no backend details are available", () => {
74
+ window.localStorage.clear();
75
+ window.sessionStorage.clear();
76
+ vi.stubEnv("VITE_SESSION_API_KEY", "");
77
+ __resetActiveStoreForTests();
78
+
79
+ const { backend, orgId } = getActiveBackend();
80
+ expect(backend.id).toBe(NO_BACKEND_ID);
81
+ expect(orgId).toBeNull();
82
+ });
83
+
84
+ it("seeds the registry with a default local backend when host and API key are available", () => {
85
+ vi.stubEnv("VITE_BACKEND_BASE_URL", "http://localhost:9000");
86
+ vi.stubEnv("VITE_SESSION_API_KEY", "session-key");
87
+ __resetActiveStoreForTests();
88
+
89
+ const { backend, orgId } = getActiveBackend();
90
+ expect(backend.id).toBe(SEEDED_DEFAULT_BACKEND_ID);
91
+ expect(backend.kind).toBe("local");
92
+ expect(orgId).toBeNull();
93
+ });
94
+
95
+ it("returns the registered backend matching the active selection", () => {
96
+ setRegisteredBackends([cloudBackend]);
97
+ setActiveSelection({ backendId: cloudBackend.id, orgId: "org-2" });
98
+
99
+ const { backend, orgId } = getActiveBackend();
100
+ expect(backend).toEqual(cloudBackend);
101
+ expect(orgId).toBe("org-2");
102
+ });
103
+
104
+ it("falls back to the first local backend when the active selection points at a removed entry", () => {
105
+ setRegisteredBackends([cloudBackend, localBackend]);
106
+ setActiveSelection({ backendId: cloudBackend.id, orgId: null });
107
+ setRegisteredBackends([localBackend]);
108
+
109
+ expect(getActiveBackend().backend).toEqual(localBackend);
110
+ expect(getActiveBackend().orgId).toBeNull();
111
+ });
112
+
113
+ it("falls back to a healthy local backend when a cloud backend is registered first", () => {
114
+ setRegisteredBackends([cloudBackend, localBackend]);
115
+ setActiveSelection(null);
116
+
117
+ expect(getActiveBackend().backend).toEqual(localBackend);
118
+ });
119
+
120
+ it("skips an unhealthy local backend in favor of a healthy one further down", () => {
121
+ markBackendUnhealthy(localBackend.id);
122
+ setRegisteredBackends([localBackend, secondLocalBackend]);
123
+ setActiveSelection(null);
124
+
125
+ expect(getActiveBackend().backend).toEqual(secondLocalBackend);
126
+ });
127
+
128
+ it("still selects a local backend when every local backend is unhealthy", () => {
129
+ markBackendUnhealthy(localBackend.id);
130
+ markBackendUnhealthy(secondLocalBackend.id);
131
+ setRegisteredBackends([cloudBackend, localBackend, secondLocalBackend]);
132
+ setActiveSelection(null);
133
+
134
+ const { backend } = getActiveBackend();
135
+ expect(backend.kind).toBe("local");
136
+ expect(backend.id).not.toBe(NO_BACKEND_ID);
137
+ // Deterministic: first local backend in insertion order.
138
+ expect(backend).toEqual(localBackend);
139
+ });
140
+
141
+ it("selects a single healthy local backend at the first registry position", () => {
142
+ setRegisteredBackends([localBackend]);
143
+ setActiveSelection(null);
144
+
145
+ expect(getActiveBackend().backend).toEqual(localBackend);
146
+ });
147
+
148
+ it("falls back to the first registered backend when the registry has no local entry", () => {
149
+ setRegisteredBackends([cloudBackend]);
150
+ setActiveSelection(null);
151
+
152
+ expect(getActiveBackend().backend).toEqual(cloudBackend);
153
+ });
154
+
155
+ it("uses the active local backend as the effective local backend", () => {
156
+ setRegisteredBackends([localBackend, cloudBackend]);
157
+ setActiveSelection({ backendId: localBackend.id });
158
+
159
+ expect(getEffectiveLocalBackend()).toEqual(localBackend);
160
+ });
161
+
162
+ it("does not borrow a registered local backend when the active backend is cloud", () => {
163
+ setRegisteredBackends([localBackend, cloudBackend]);
164
+ setActiveSelection({ backendId: cloudBackend.id });
165
+
166
+ expect(getEffectiveLocalBackend()).toBeNull();
167
+ });
168
+
169
+ it("keeps an explicit cloud selection even when a healthy local backend exists", () => {
170
+ setRegisteredBackends([localBackend, cloudBackend]);
171
+ setActiveSelection({ backendId: cloudBackend.id, orgId: "org-2" });
172
+
173
+ const { backend, orgId } = getActiveBackend();
174
+ expect(backend).toEqual(cloudBackend);
175
+ expect(orgId).toBe("org-2");
176
+ });
177
+
178
+ it("notifies subscribers when selection changes", () => {
179
+ const listener = vi.fn();
180
+ const unsubscribe = subscribeActiveBackend(listener);
181
+
182
+ setRegisteredBackends([cloudBackend]);
183
+ setActiveSelection({ backendId: cloudBackend.id });
184
+
185
+ expect(listener).toHaveBeenCalled();
186
+
187
+ unsubscribe();
188
+ listener.mockClear();
189
+ setActiveSelection(null);
190
+ expect(listener).not.toHaveBeenCalled();
191
+ });
192
+ });
193
+
194
+ describe("backend pinned in the URL", () => {
195
+ function seedRegistry(backends: Backend[], selection: string) {
196
+ window.localStorage.setItem(BACKENDS_STORAGE_KEY, JSON.stringify(backends));
197
+ window.localStorage.setItem(
198
+ ACTIVE_BACKEND_STORAGE_KEY,
199
+ JSON.stringify({ backendId: selection, orgId: null }),
200
+ );
201
+ }
202
+
203
+ function bootAt(search: string) {
204
+ window.history.replaceState({}, "", `/conversations/abc${search}`);
205
+ __resetActiveStoreForTests();
206
+ }
207
+
208
+ afterEach(() => {
209
+ window.history.replaceState({}, "", "/");
210
+ });
211
+
212
+ it("boots on the backend named in the URL instead of the stored one", () => {
213
+ seedRegistry([localBackend, secondLocalBackend], localBackend.id);
214
+
215
+ bootAt(`?${BACKEND_QUERY_PARAM}=${secondLocalBackend.id}`);
216
+
217
+ expect(getActiveBackend().backend).toEqual(secondLocalBackend);
218
+ });
219
+
220
+ it("persists the pinned selection so later in-tab navigation keeps it", () => {
221
+ seedRegistry([localBackend, secondLocalBackend], localBackend.id);
222
+
223
+ bootAt(`?${BACKEND_QUERY_PARAM}=${secondLocalBackend.id}`);
224
+
225
+ expect(
226
+ JSON.parse(
227
+ window.sessionStorage.getItem(ACTIVE_BACKEND_STORAGE_KEY) ?? "null",
228
+ ),
229
+ ).toEqual({ backendId: secondLocalBackend.id, orgId: null });
230
+ });
231
+
232
+ it("carries the org id for a cloud backend", () => {
233
+ seedRegistry([localBackend, cloudBackend], localBackend.id);
234
+
235
+ bootAt(
236
+ `?${BACKEND_QUERY_PARAM}=${cloudBackend.id}&${ORG_QUERY_PARAM}=org-9`,
237
+ );
238
+
239
+ const { backend, orgId } = getActiveBackend();
240
+ expect(backend).toEqual(cloudBackend);
241
+ expect(orgId).toBe("org-9");
242
+ });
243
+
244
+ it("falls back to the stored selection when the URL names an unknown backend", () => {
245
+ seedRegistry([localBackend, secondLocalBackend], secondLocalBackend.id);
246
+
247
+ bootAt(`?${BACKEND_QUERY_PARAM}=removed-backend`);
248
+
249
+ expect(getActiveBackend().backend).toEqual(secondLocalBackend);
250
+ });
251
+
252
+ it("prefers the URL over a tab-scoped sessionStorage selection", () => {
253
+ seedRegistry([localBackend, secondLocalBackend], localBackend.id);
254
+ window.sessionStorage.setItem(
255
+ ACTIVE_BACKEND_STORAGE_KEY,
256
+ JSON.stringify({ backendId: localBackend.id, orgId: null }),
257
+ );
258
+
259
+ bootAt(`?${BACKEND_QUERY_PARAM}=${secondLocalBackend.id}`);
260
+
261
+ expect(getActiveBackend().backend).toEqual(secondLocalBackend);
262
+ });
263
+ });
__tests__/api/backend-registry/health-store.test.ts ADDED
@@ -0,0 +1,111 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import { afterEach, beforeEach, describe, expect, it } from "vitest";
2
+ import {
3
+ BACKEND_HEALTH_STORAGE_KEY,
4
+ MAX_CONSECUTIVE_FAILURES,
5
+ } from "#/api/backend-registry/health-storage";
6
+ import {
7
+ __resetHealthStoreForTests,
8
+ getBackendHealthEntry,
9
+ recordBackendFailure,
10
+ recordBackendSuccess,
11
+ resetBackendHealth,
12
+ subscribeBackendHealth,
13
+ } from "#/api/backend-registry/health-store";
14
+
15
+ const BACKEND_ID = "backend-under-test";
16
+
17
+ beforeEach(() => {
18
+ window.localStorage.clear();
19
+ __resetHealthStoreForTests();
20
+ });
21
+
22
+ afterEach(() => {
23
+ window.localStorage.clear();
24
+ __resetHealthStoreForTests();
25
+ });
26
+
27
+ describe("backend health store", () => {
28
+ it("increments the failure count and persists to localStorage without disabling polling below the cap", () => {
29
+ // Arrange / Act
30
+ recordBackendFailure(BACKEND_ID, new Error("ECONNREFUSED"));
31
+ recordBackendFailure(BACKEND_ID, new Error("ECONNREFUSED"));
32
+
33
+ // Assert
34
+ const entry = getBackendHealthEntry(BACKEND_ID);
35
+ expect(entry).toMatchObject({
36
+ consecutiveFailures: 2,
37
+ disabled: false,
38
+ lastError: "ECONNREFUSED",
39
+ });
40
+
41
+ const persisted = JSON.parse(
42
+ window.localStorage.getItem(BACKEND_HEALTH_STORAGE_KEY) ?? "{}",
43
+ );
44
+ expect(persisted[BACKEND_ID]).toMatchObject({
45
+ consecutiveFailures: 2,
46
+ disabled: false,
47
+ });
48
+ });
49
+
50
+ it("flips disabled=true once consecutive failures hit the cap and persists that flag", () => {
51
+ // Arrange / Act
52
+ for (let i = 0; i < MAX_CONSECUTIVE_FAILURES; i += 1) {
53
+ recordBackendFailure(BACKEND_ID, new Error("timeout"));
54
+ }
55
+
56
+ // Assert — the cap is reached; polling-consumers will see disabled.
57
+ const entry = getBackendHealthEntry(BACKEND_ID);
58
+ expect(entry?.consecutiveFailures).toBe(MAX_CONSECUTIVE_FAILURES);
59
+ expect(entry?.disabled).toBe(true);
60
+
61
+ const persisted = JSON.parse(
62
+ window.localStorage.getItem(BACKEND_HEALTH_STORAGE_KEY) ?? "{}",
63
+ );
64
+ expect(persisted[BACKEND_ID].disabled).toBe(true);
65
+ });
66
+
67
+ it("caps the failure count at the max when a disabled backend fails another recheck", () => {
68
+ for (let i = 0; i < MAX_CONSECUTIVE_FAILURES; i += 1) {
69
+ recordBackendFailure(BACKEND_ID, new Error("timeout"));
70
+ }
71
+
72
+ recordBackendFailure(BACKEND_ID, new Error("still down"));
73
+
74
+ const entry = getBackendHealthEntry(BACKEND_ID);
75
+ expect(entry).toMatchObject({
76
+ consecutiveFailures: MAX_CONSECUTIVE_FAILURES,
77
+ disabled: true,
78
+ lastError: "still down",
79
+ });
80
+
81
+ const persisted = JSON.parse(
82
+ window.localStorage.getItem(BACKEND_HEALTH_STORAGE_KEY) ?? "{}",
83
+ );
84
+ expect(persisted[BACKEND_ID].consecutiveFailures).toBe(
85
+ MAX_CONSECUTIVE_FAILURES,
86
+ );
87
+ });
88
+
89
+ it("clears the entry (and storage) and notifies subscribers when the backend recovers or the user edits its config", () => {
90
+ // Arrange — record one failure so there is something to clear, and
91
+ // subscribe so we can confirm listeners get notified.
92
+ recordBackendFailure(BACKEND_ID, new Error("boom"));
93
+ let notifications = 0;
94
+ const unsubscribe = subscribeBackendHealth(() => {
95
+ notifications += 1;
96
+ });
97
+
98
+ // Act — `recordBackendSuccess` (probe recovers) and
99
+ // `resetBackendHealth` (user edits host/apiKey) share the same
100
+ // clear-entry semantics, so we cover both in one go.
101
+ recordBackendSuccess(BACKEND_ID);
102
+ recordBackendFailure(BACKEND_ID, new Error("again"));
103
+ resetBackendHealth(BACKEND_ID);
104
+ unsubscribe();
105
+
106
+ // Assert
107
+ expect(getBackendHealthEntry(BACKEND_ID)).toBeNull();
108
+ expect(window.localStorage.getItem(BACKEND_HEALTH_STORAGE_KEY)).toBeNull();
109
+ expect(notifications).toBeGreaterThanOrEqual(3);
110
+ });
111
+ });
__tests__/api/backend-registry/last-conversation-store.test.ts ADDED
@@ -0,0 +1,67 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import { afterEach, beforeEach, describe, expect, it } from "vitest";
2
+ import {
3
+ clearLastConversationId,
4
+ getLastConversationId,
5
+ LAST_CONVERSATION_STORAGE_KEY,
6
+ setLastConversationId,
7
+ } from "#/api/backend-registry/last-conversation-store";
8
+
9
+ beforeEach(() => {
10
+ window.localStorage.clear();
11
+ });
12
+
13
+ afterEach(() => {
14
+ window.localStorage.clear();
15
+ });
16
+
17
+ describe("last-conversation-store", () => {
18
+ it("returns null when nothing has been remembered for a backend", () => {
19
+ expect(getLastConversationId("backend-a", null)).toBeNull();
20
+ });
21
+
22
+ it("remembers and reads back the most recently selected conversation per backend", () => {
23
+ setLastConversationId("backend-a", null, "convo-a-1");
24
+ setLastConversationId("backend-b", null, "convo-b-1");
25
+
26
+ expect(getLastConversationId("backend-a", null)).toBe("convo-a-1");
27
+ expect(getLastConversationId("backend-b", null)).toBe("convo-b-1");
28
+
29
+ // The most recent selection wins.
30
+ setLastConversationId("backend-a", null, "convo-a-2");
31
+ expect(getLastConversationId("backend-a", null)).toBe("convo-a-2");
32
+ // …without affecting other backends.
33
+ expect(getLastConversationId("backend-b", null)).toBe("convo-b-1");
34
+ });
35
+
36
+ it("keys cloud backends by (backendId, orgId) so each org gets its own slot", () => {
37
+ setLastConversationId("cloud-x", "org-1", "convo-org-1");
38
+ setLastConversationId("cloud-x", "org-2", "convo-org-2");
39
+
40
+ expect(getLastConversationId("cloud-x", "org-1")).toBe("convo-org-1");
41
+ expect(getLastConversationId("cloud-x", "org-2")).toBe("convo-org-2");
42
+ expect(getLastConversationId("cloud-x", null)).toBeNull();
43
+ });
44
+
45
+ it("clears a backend's slot without touching others", () => {
46
+ setLastConversationId("backend-a", null, "convo-a");
47
+ setLastConversationId("backend-b", null, "convo-b");
48
+
49
+ clearLastConversationId("backend-a", null);
50
+
51
+ expect(getLastConversationId("backend-a", null)).toBeNull();
52
+ expect(getLastConversationId("backend-b", null)).toBe("convo-b");
53
+ });
54
+
55
+ it("ignores empty conversation ids on write", () => {
56
+ setLastConversationId("backend-a", null, "");
57
+ expect(getLastConversationId("backend-a", null)).toBeNull();
58
+ });
59
+
60
+ it("survives malformed JSON in storage", () => {
61
+ window.localStorage.setItem(LAST_CONVERSATION_STORAGE_KEY, "not-json");
62
+ expect(getLastConversationId("backend-a", null)).toBeNull();
63
+ // A subsequent write recovers the storage shape.
64
+ setLastConversationId("backend-a", null, "convo-a");
65
+ expect(getLastConversationId("backend-a", null)).toBe("convo-a");
66
+ });
67
+ });
__tests__/api/backend-registry/storage.test.ts ADDED
@@ -0,0 +1,365 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import { afterEach, describe, expect, it, vi } from "vitest";
2
+ import {
3
+ ACTIVE_BACKEND_STORAGE_KEY,
4
+ BACKENDS_STORAGE_KEY,
5
+ readStoredActiveBackend,
6
+ readStoredBackends,
7
+ writeStoredActiveBackend,
8
+ writeStoredBackends,
9
+ } from "#/api/backend-registry/storage";
10
+ import type { Backend } from "#/api/backend-registry/types";
11
+
12
+ const ORIGINAL_LOCATION = window.location;
13
+
14
+ function mockWindowLocation(url: string) {
15
+ Object.defineProperty(window, "location", {
16
+ configurable: true,
17
+ value: new URL(url),
18
+ });
19
+ }
20
+
21
+ afterEach(() => {
22
+ window.localStorage.clear();
23
+ window.sessionStorage.clear();
24
+ delete (window as unknown as Record<string, unknown>)
25
+ .__AGENT_CANVAS_LOCK_TO_CLOUD__;
26
+ Object.defineProperty(window, "location", {
27
+ configurable: true,
28
+ value: ORIGINAL_LOCATION,
29
+ });
30
+ window.history.pushState({}, "", "/");
31
+ vi.unstubAllEnvs();
32
+ });
33
+
34
+ describe("backend-registry storage", () => {
35
+ it("round-trips a list of backends", () => {
36
+ const backends: Backend[] = [
37
+ {
38
+ id: "abc",
39
+ name: "Local 1",
40
+ host: "http://127.0.0.1:9000",
41
+ apiKey: "key-1",
42
+ kind: "local",
43
+ },
44
+ {
45
+ id: "xyz",
46
+ name: "Production",
47
+ host: "https://app.all-hands.dev",
48
+ apiKey: "bearer-2",
49
+ kind: "cloud",
50
+ },
51
+ ];
52
+
53
+ writeStoredBackends(backends);
54
+
55
+ expect(readStoredBackends()).toEqual(backends);
56
+ });
57
+
58
+ it("returns empty list when storage is malformed", () => {
59
+ window.localStorage.setItem(BACKENDS_STORAGE_KEY, "{not-json");
60
+ expect(readStoredBackends()).toEqual([]);
61
+ });
62
+
63
+ it("does not seed the default Local backend when launcher details are missing", () => {
64
+ vi.stubEnv("VITE_SESSION_API_KEY", "");
65
+ expect(window.localStorage.getItem(BACKENDS_STORAGE_KEY)).toBeNull();
66
+
67
+ expect(readStoredBackends()).toEqual([]);
68
+ expect(window.localStorage.getItem(BACKENDS_STORAGE_KEY)).toBeNull();
69
+ });
70
+
71
+ it("seeds the default Local backend when host and API key are available", () => {
72
+ vi.stubEnv("VITE_BACKEND_BASE_URL", "http://localhost:9000");
73
+ vi.stubEnv("VITE_SESSION_API_KEY", "fresh-session-key");
74
+
75
+ const result = readStoredBackends();
76
+
77
+ expect(result).toHaveLength(1);
78
+ expect(result[0]).toMatchObject({
79
+ id: "default-local",
80
+ host: "http://localhost:9000",
81
+ apiKey: "fresh-session-key",
82
+ kind: "local",
83
+ });
84
+ expect(window.localStorage.getItem(BACKENDS_STORAGE_KEY)).not.toBeNull();
85
+ expect(readStoredBackends()).toEqual(result);
86
+ });
87
+
88
+ it("seeds a cookie-auth Cloud backend when locked to the current origin", () => {
89
+ (
90
+ window as unknown as Record<string, unknown>
91
+ ).__AGENT_CANVAS_LOCK_TO_CLOUD__ = window.location.origin;
92
+ window.localStorage.setItem(
93
+ BACKENDS_STORAGE_KEY,
94
+ JSON.stringify([
95
+ {
96
+ id: "stale-local",
97
+ name: "Stale Local",
98
+ host: "http://localhost:18000",
99
+ apiKey: "stale-session",
100
+ kind: "local",
101
+ },
102
+ ]),
103
+ );
104
+
105
+ const result = readStoredBackends();
106
+
107
+ expect(result).toEqual([
108
+ {
109
+ id: "locked-cloud",
110
+ name: "OpenHands Cloud",
111
+ host: window.location.origin,
112
+ apiKey: "",
113
+ kind: "cloud",
114
+ authMode: "cookie",
115
+ },
116
+ ]);
117
+ expect(readStoredActiveBackend()).toEqual({
118
+ backendId: "locked-cloud",
119
+ orgId: null,
120
+ });
121
+ });
122
+
123
+ it("seeds the cookie-auth Cloud backend on the current origin when the locked host is an equivalent transition domain", () => {
124
+ mockWindowLocation("https://pr-254.staging.openhands.dev/canvas");
125
+ (
126
+ window as unknown as Record<string, unknown>
127
+ ).__AGENT_CANVAS_LOCK_TO_CLOUD__ = "https://pr-254.staging.all-hands.dev";
128
+
129
+ expect(readStoredBackends()).toEqual([
130
+ {
131
+ id: "locked-cloud",
132
+ name: "OpenHands Cloud",
133
+ host: "https://pr-254.staging.openhands.dev",
134
+ apiKey: "",
135
+ kind: "cloud",
136
+ authMode: "cookie",
137
+ },
138
+ ]);
139
+ });
140
+
141
+ it("preserves an existing locked Cloud org selection while reseeding", () => {
142
+ (
143
+ window as unknown as Record<string, unknown>
144
+ ).__AGENT_CANVAS_LOCK_TO_CLOUD__ = window.location.origin;
145
+ writeStoredActiveBackend({ backendId: "locked-cloud", orgId: "org-1" });
146
+
147
+ expect(readStoredBackends()[0]).toMatchObject({ id: "locked-cloud" });
148
+ expect(readStoredActiveBackend()).toEqual({
149
+ backendId: "locked-cloud",
150
+ orgId: "org-1",
151
+ });
152
+ });
153
+
154
+ it("does not seed a cookie-auth Cloud backend when locked cross-origin", () => {
155
+ (
156
+ window as unknown as Record<string, unknown>
157
+ ).__AGENT_CANVAS_LOCK_TO_CLOUD__ = "https://app.all-hands.dev";
158
+ vi.stubEnv("VITE_SESSION_API_KEY", "");
159
+
160
+ expect(readStoredBackends()).toEqual([]);
161
+ });
162
+
163
+ it("re-seeds the default Local backend when storage holds an empty array and launcher details are available", () => {
164
+ vi.stubEnv("VITE_BACKEND_BASE_URL", "http://localhost:9000");
165
+ vi.stubEnv("VITE_SESSION_API_KEY", "fresh-session-key");
166
+ window.localStorage.setItem(BACKENDS_STORAGE_KEY, JSON.stringify([]));
167
+
168
+ const result = readStoredBackends();
169
+
170
+ expect(result).toHaveLength(1);
171
+ expect(result[0]).toMatchObject({ id: "default-local", kind: "local" });
172
+ });
173
+
174
+ it("re-seeds the default Local backend when every stored entry is invalid and launcher details are available", () => {
175
+ vi.stubEnv("VITE_BACKEND_BASE_URL", "http://localhost:9000");
176
+ vi.stubEnv("VITE_SESSION_API_KEY", "fresh-session-key");
177
+ window.localStorage.setItem(
178
+ BACKENDS_STORAGE_KEY,
179
+ JSON.stringify([{ kind: "cloud" }, "not-an-object"]),
180
+ );
181
+
182
+ const result = readStoredBackends();
183
+
184
+ expect(result).toHaveLength(1);
185
+ expect(result[0]).toMatchObject({ id: "default-local", kind: "local" });
186
+ });
187
+
188
+ it("filters out backends with invalid shape", () => {
189
+ window.localStorage.setItem(
190
+ BACKENDS_STORAGE_KEY,
191
+ JSON.stringify([
192
+ { id: "ok", name: "x", host: "y", apiKey: "z", kind: "local" },
193
+ { id: "missing-kind", name: "x", host: "y", apiKey: "z" },
194
+ { kind: "cloud" },
195
+ "not-an-object",
196
+ ]),
197
+ );
198
+
199
+ expect(readStoredBackends()).toEqual([
200
+ { id: "ok", name: "x", host: "y", apiKey: "z", kind: "local" },
201
+ ]);
202
+ });
203
+
204
+ it("preserves stored backends without API keys", () => {
205
+ vi.stubEnv("VITE_SESSION_API_KEY", "");
206
+ const storedBackend: Backend = {
207
+ id: "default-local",
208
+ name: "Local",
209
+ host: window.location.origin,
210
+ apiKey: "",
211
+ kind: "local",
212
+ };
213
+ window.localStorage.setItem(
214
+ BACKENDS_STORAGE_KEY,
215
+ JSON.stringify([storedBackend]),
216
+ );
217
+
218
+ expect(readStoredBackends()).toEqual([storedBackend]);
219
+ });
220
+
221
+ it("syncs a stale default Local API key from env defaults", () => {
222
+ vi.stubEnv("VITE_SESSION_API_KEY", "fresh-session-key");
223
+ window.localStorage.setItem(
224
+ BACKENDS_STORAGE_KEY,
225
+ JSON.stringify([
226
+ {
227
+ id: "default-local",
228
+ name: "Local",
229
+ host: window.location.origin,
230
+ apiKey: "stored-session-key",
231
+ kind: "local",
232
+ },
233
+ ]),
234
+ );
235
+
236
+ expect(readStoredBackends()[0]).toMatchObject({
237
+ id: "default-local",
238
+ apiKey: "fresh-session-key",
239
+ });
240
+ });
241
+
242
+ it("syncs a stale default Local API key across localhost and 127.0.0.1", () => {
243
+ vi.stubEnv("VITE_BACKEND_BASE_URL", "http://127.0.0.1:8000");
244
+ vi.stubEnv("VITE_SESSION_API_KEY", "fresh-session-key");
245
+ window.localStorage.setItem(
246
+ BACKENDS_STORAGE_KEY,
247
+ JSON.stringify([
248
+ {
249
+ id: "default-local",
250
+ name: "Local",
251
+ host: "http://localhost:8000",
252
+ apiKey: "stored-session-key",
253
+ kind: "local",
254
+ },
255
+ ]),
256
+ );
257
+
258
+ expect(readStoredBackends()[0]).toMatchObject({
259
+ id: "default-local",
260
+ host: "http://localhost:8000",
261
+ apiKey: "fresh-session-key",
262
+ });
263
+ });
264
+
265
+ it("preserves a custom backend API key instead of syncing from env defaults", () => {
266
+ vi.stubEnv("VITE_SESSION_API_KEY", "fresh-session-key");
267
+ const storedBackend: Backend = {
268
+ id: "custom-local",
269
+ name: "Custom Local",
270
+ host: window.location.origin,
271
+ apiKey: "stored-session-key",
272
+ kind: "local",
273
+ };
274
+ window.localStorage.setItem(
275
+ BACKENDS_STORAGE_KEY,
276
+ JSON.stringify([storedBackend]),
277
+ );
278
+
279
+ expect(readStoredBackends()[0]).toMatchObject({
280
+ id: "custom-local",
281
+ apiKey: "stored-session-key",
282
+ });
283
+ });
284
+
285
+ it("preserves a user-edited non-loopback default Local backend API key", () => {
286
+ vi.stubEnv("VITE_SESSION_API_KEY", "fresh-session-key");
287
+ const storedBackend: Backend = {
288
+ id: "default-local",
289
+ name: "Edited Local",
290
+ host: "https://example.com",
291
+ apiKey: "stored-session-key",
292
+ kind: "local",
293
+ };
294
+ window.localStorage.setItem(
295
+ BACKENDS_STORAGE_KEY,
296
+ JSON.stringify([storedBackend]),
297
+ );
298
+
299
+ expect(readStoredBackends()[0]).toMatchObject({
300
+ id: "default-local",
301
+ host: "https://example.com",
302
+ apiKey: "stored-session-key",
303
+ });
304
+ });
305
+
306
+ it("round-trips active selection with orgId", () => {
307
+ writeStoredActiveBackend({ backendId: "xyz", orgId: "org-1" });
308
+ expect(readStoredActiveBackend()).toEqual({
309
+ backendId: "xyz",
310
+ orgId: "org-1",
311
+ });
312
+ });
313
+
314
+ it("normalizes missing orgId to null", () => {
315
+ writeStoredActiveBackend({ backendId: "xyz" });
316
+ expect(readStoredActiveBackend()).toEqual({
317
+ backendId: "xyz",
318
+ orgId: null,
319
+ });
320
+ });
321
+
322
+ it("prefers the tab-scoped active selection over the global fallback", () => {
323
+ window.localStorage.setItem(
324
+ ACTIVE_BACKEND_STORAGE_KEY,
325
+ JSON.stringify({ backendId: "global-backend", orgId: null }),
326
+ );
327
+ window.sessionStorage.setItem(
328
+ ACTIVE_BACKEND_STORAGE_KEY,
329
+ JSON.stringify({ backendId: "tab-backend", orgId: "org-1" }),
330
+ );
331
+
332
+ expect(readStoredActiveBackend()).toEqual({
333
+ backendId: "tab-backend",
334
+ orgId: "org-1",
335
+ });
336
+ });
337
+
338
+ it("falls back to the global active selection for new tabs", () => {
339
+ window.localStorage.setItem(
340
+ ACTIVE_BACKEND_STORAGE_KEY,
341
+ JSON.stringify({ backendId: "global-backend", orgId: null }),
342
+ );
343
+
344
+ expect(readStoredActiveBackend()).toEqual({
345
+ backendId: "global-backend",
346
+ orgId: null,
347
+ });
348
+ });
349
+
350
+ it("clears storage when active selection is set to null", () => {
351
+ writeStoredActiveBackend({ backendId: "xyz", orgId: "o" });
352
+ writeStoredActiveBackend(null);
353
+
354
+ expect(
355
+ window.sessionStorage.getItem(ACTIVE_BACKEND_STORAGE_KEY),
356
+ ).toBeNull();
357
+ expect(window.localStorage.getItem(ACTIVE_BACKEND_STORAGE_KEY)).toBeNull();
358
+ expect(readStoredActiveBackend()).toBeNull();
359
+ });
360
+
361
+ it("returns null active selection when storage is malformed", () => {
362
+ window.localStorage.setItem(ACTIVE_BACKEND_STORAGE_KEY, "{broken");
363
+ expect(readStoredActiveBackend()).toBeNull();
364
+ });
365
+ });
__tests__/api/backend-registry/url-selection.test.ts ADDED
@@ -0,0 +1,166 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import { describe, expect, it } from "vitest";
2
+ import {
3
+ BACKEND_QUERY_PARAM,
4
+ ORG_QUERY_PARAM,
5
+ readBackendSelectionFromUrl,
6
+ withBackendSelectionParams,
7
+ } from "#/api/backend-registry/url-selection";
8
+ import type { Backend } from "#/api/backend-registry/types";
9
+
10
+ const localBackend: Backend = {
11
+ id: "local-1",
12
+ name: "Local 1",
13
+ host: "http://localhost:9000",
14
+ apiKey: "k",
15
+ kind: "local",
16
+ };
17
+
18
+ const cloudBackend: Backend = {
19
+ id: "prod",
20
+ name: "Production",
21
+ host: "https://app.all-hands.dev",
22
+ apiKey: "bearer-key",
23
+ kind: "cloud",
24
+ };
25
+
26
+ const backends = [localBackend, cloudBackend];
27
+
28
+ describe("withBackendSelectionParams", () => {
29
+ it("pins the active backend id onto the path", () => {
30
+ const path = withBackendSelectionParams("/conversations/abc", {
31
+ backend: localBackend,
32
+ orgId: null,
33
+ });
34
+
35
+ expect(path).toBe(`/conversations/abc?${BACKEND_QUERY_PARAM}=local-1`);
36
+ });
37
+
38
+ it("includes the org id for a cloud backend", () => {
39
+ const path = withBackendSelectionParams("/conversations/abc", {
40
+ backend: cloudBackend,
41
+ orgId: "org-7",
42
+ });
43
+
44
+ expect(path).toBe(
45
+ `/conversations/abc?${BACKEND_QUERY_PARAM}=prod&${ORG_QUERY_PARAM}=org-7`,
46
+ );
47
+ });
48
+
49
+ it("omits the org id when there is none", () => {
50
+ const path = withBackendSelectionParams("/conversations/abc", {
51
+ backend: cloudBackend,
52
+ orgId: null,
53
+ });
54
+
55
+ expect(path).not.toContain(ORG_QUERY_PARAM);
56
+ });
57
+
58
+ it("preserves query parameters already on the path", () => {
59
+ const path = withBackendSelectionParams("/conversations/abc?tab=files", {
60
+ backend: localBackend,
61
+ orgId: null,
62
+ });
63
+
64
+ expect(path).toBe(
65
+ `/conversations/abc?tab=files&${BACKEND_QUERY_PARAM}=local-1`,
66
+ );
67
+ });
68
+
69
+ it("keeps a fragment after existing query parameters intact and after the query", () => {
70
+ const path = withBackendSelectionParams(
71
+ "/conversations/abc?tab=files#detail",
72
+ {
73
+ backend: localBackend,
74
+ orgId: null,
75
+ },
76
+ );
77
+
78
+ expect(path).toBe(
79
+ `/conversations/abc?tab=files&${BACKEND_QUERY_PARAM}=local-1#detail`,
80
+ );
81
+ });
82
+
83
+ it("keeps a fragment on a path without query parameters after the query", () => {
84
+ const path = withBackendSelectionParams("/conversations/abc#detail", {
85
+ backend: localBackend,
86
+ orgId: null,
87
+ });
88
+
89
+ expect(path).toBe(
90
+ `/conversations/abc?${BACKEND_QUERY_PARAM}=local-1#detail`,
91
+ );
92
+ });
93
+
94
+ it("does not treat a ? inside the fragment as a query separator", () => {
95
+ const path = withBackendSelectionParams("/conversations/abc#detail?x=1", {
96
+ backend: localBackend,
97
+ orgId: null,
98
+ });
99
+
100
+ expect(path).toBe(
101
+ `/conversations/abc?${BACKEND_QUERY_PARAM}=local-1#detail?x=1`,
102
+ );
103
+ });
104
+
105
+ it("round-trips an empty fragment verbatim", () => {
106
+ const path = withBackendSelectionParams("/conversations/abc#", {
107
+ backend: localBackend,
108
+ orgId: null,
109
+ });
110
+
111
+ expect(path).toBe(`/conversations/abc?${BACKEND_QUERY_PARAM}=local-1#`);
112
+ });
113
+
114
+ it("keeps the org id and the fragment together", () => {
115
+ const path = withBackendSelectionParams("/conversations/abc#detail", {
116
+ backend: cloudBackend,
117
+ orgId: "org-7",
118
+ });
119
+
120
+ expect(path).toBe(
121
+ `/conversations/abc?${BACKEND_QUERY_PARAM}=prod&${ORG_QUERY_PARAM}=org-7#detail`,
122
+ );
123
+ });
124
+
125
+ it("keeps query data that itself contains a ?", () => {
126
+ const path = withBackendSelectionParams("/conversations/abc?next=/a?b=1", {
127
+ backend: localBackend,
128
+ orgId: null,
129
+ });
130
+
131
+ expect(path).toBe(
132
+ `/conversations/abc?next=%2Fa%3Fb%3D1&${BACKEND_QUERY_PARAM}=local-1`,
133
+ );
134
+ });
135
+ });
136
+
137
+ describe("readBackendSelectionFromUrl", () => {
138
+ it("reads a registered backend id", () => {
139
+ expect(
140
+ readBackendSelectionFromUrl(backends, `?${BACKEND_QUERY_PARAM}=local-1`),
141
+ ).toEqual({ backendId: "local-1", orgId: null });
142
+ });
143
+
144
+ it("reads the org id alongside the backend id", () => {
145
+ expect(
146
+ readBackendSelectionFromUrl(
147
+ backends,
148
+ `?${BACKEND_QUERY_PARAM}=prod&${ORG_QUERY_PARAM}=org-7`,
149
+ ),
150
+ ).toEqual({ backendId: "prod", orgId: "org-7" });
151
+ });
152
+
153
+ it("ignores a backend id that is not registered", () => {
154
+ expect(
155
+ readBackendSelectionFromUrl(backends, `?${BACKEND_QUERY_PARAM}=gone`),
156
+ ).toBeNull();
157
+ });
158
+
159
+ it("ignores an empty or absent parameter", () => {
160
+ expect(readBackendSelectionFromUrl(backends, "")).toBeNull();
161
+ expect(readBackendSelectionFromUrl(backends, "?tab=files")).toBeNull();
162
+ expect(
163
+ readBackendSelectionFromUrl(backends, `?${BACKEND_QUERY_PARAM}=`),
164
+ ).toBeNull();
165
+ });
166
+ });
__tests__/api/cloud/conversation-create.test.ts ADDED
@@ -0,0 +1,128 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
2
+ import {
3
+ __resetActiveStoreForTests,
4
+ setActiveSelection,
5
+ setRegisteredBackends,
6
+ } from "#/api/backend-registry/active-store";
7
+ import type { Backend } from "#/api/backend-registry/types";
8
+ import AgentServerConversationService from "#/api/conversation-service/agent-server-conversation-service.api";
9
+ import {
10
+ getFetchCall,
11
+ getJsonBody,
12
+ mockJsonResponse,
13
+ } from "./fetch-test-utils";
14
+
15
+ const cloudBackend: Backend = {
16
+ id: "prod",
17
+ name: "Production",
18
+ host: "https://app.all-hands.dev",
19
+ apiKey: "bearer-token",
20
+ kind: "cloud",
21
+ };
22
+
23
+ const originalFetch = global.fetch;
24
+ const fetchMock = vi.fn();
25
+
26
+ beforeEach(() => {
27
+ window.localStorage.clear();
28
+ __resetActiveStoreForTests();
29
+ setRegisteredBackends([cloudBackend]);
30
+ setActiveSelection({ backendId: cloudBackend.id });
31
+ fetchMock.mockReset();
32
+ global.fetch = fetchMock as typeof fetch;
33
+ });
34
+
35
+ afterEach(() => {
36
+ window.localStorage.clear();
37
+ __resetActiveStoreForTests();
38
+ fetchMock.mockReset();
39
+ global.fetch = originalFetch;
40
+ });
41
+
42
+ describe("AgentServerConversationService cloud branch", () => {
43
+ it("createConversation POSTs the cloud payload directly and returns a WORKING task", async () => {
44
+ fetchMock.mockResolvedValue(
45
+ mockJsonResponse({
46
+ id: "task-123",
47
+ created_by_user_id: null,
48
+ status: "WORKING",
49
+ detail: null,
50
+ app_conversation_id: null,
51
+ agent_server_url: null,
52
+ request: {},
53
+ created_at: "2026-05-06T00:00:00Z",
54
+ updated_at: "2026-05-06T00:00:00Z",
55
+ }),
56
+ );
57
+
58
+ const result = await AgentServerConversationService.createConversation({
59
+ initialUserMsg: "fix the bug",
60
+ conversationInstructions: "Optional title",
61
+ metadata: {
62
+ selected_repository: "user/repo",
63
+ selected_branch: "main",
64
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
65
+ git_provider: "github" as any,
66
+ },
67
+ });
68
+
69
+ expect(fetchMock).toHaveBeenCalledOnce();
70
+ const [url, init] = getFetchCall(fetchMock);
71
+ expect(url).toBe(`${cloudBackend.host}/api/v1/app-conversations`);
72
+ expect(init).toMatchObject({
73
+ method: "POST",
74
+ headers: { Authorization: "Bearer bearer-token" },
75
+ });
76
+ const requestBody = getJsonBody(init);
77
+
78
+ // cloud payload shape — flat fields, NO encrypted-settings round-trip.
79
+ expect(requestBody.selected_repository).toBe("user/repo");
80
+ expect(requestBody.selected_branch).toBe("main");
81
+ expect(requestBody.git_provider).toBe("github");
82
+ expect(requestBody.title).toBe("Optional title");
83
+ expect(requestBody.initial_message).toEqual({
84
+ role: "user",
85
+ content: [{ type: "text", text: "fix the bug" }],
86
+ });
87
+ // The local-only encrypted-settings keys must NOT be present.
88
+ expect(requestBody).not.toHaveProperty("agent_settings_encrypted");
89
+ expect(requestBody).not.toHaveProperty("conversation_settings_encrypted");
90
+
91
+ // The returned task is the upstream task — WORKING, no app_conversation_id yet.
92
+ expect(result.id).toBe("task-123");
93
+ expect(result.status).toBe("WORKING");
94
+ expect(result.app_conversation_id).toBeNull();
95
+ });
96
+
97
+ it("getStartTask polls /api/v1/app-conversations/start-tasks?ids= directly", async () => {
98
+ fetchMock.mockResolvedValue(
99
+ mockJsonResponse([
100
+ {
101
+ id: "task-123",
102
+ created_by_user_id: null,
103
+ status: "READY",
104
+ detail: null,
105
+ app_conversation_id: "conv-456",
106
+ agent_server_url: "https://runtime-456.app.all-hands.dev",
107
+ request: {},
108
+ created_at: "2026-05-06T00:00:00Z",
109
+ updated_at: "2026-05-06T00:00:00Z",
110
+ },
111
+ ]),
112
+ );
113
+
114
+ const result =
115
+ await AgentServerConversationService.getStartTask("task-123");
116
+
117
+ const [url, init] = getFetchCall(fetchMock);
118
+ expect(url).toBe(
119
+ `${cloudBackend.host}/api/v1/app-conversations/start-tasks?ids=task-123`,
120
+ );
121
+ expect(init).toMatchObject({
122
+ method: "GET",
123
+ headers: { Authorization: "Bearer bearer-token" },
124
+ });
125
+ expect(result?.status).toBe("READY");
126
+ expect(result?.app_conversation_id).toBe("conv-456");
127
+ });
128
+ });
__tests__/api/cloud/conversation-delete.test.ts ADDED
@@ -0,0 +1,51 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
2
+ import {
3
+ __resetActiveStoreForTests,
4
+ setActiveSelection,
5
+ setRegisteredBackends,
6
+ } from "#/api/backend-registry/active-store";
7
+ import type { Backend } from "#/api/backend-registry/types";
8
+ import AgentServerConversationService from "#/api/conversation-service/agent-server-conversation-service.api";
9
+ import { getFetchCall, mockJsonResponse } from "./fetch-test-utils";
10
+
11
+ const cloudBackend: Backend = {
12
+ id: "prod",
13
+ name: "Production",
14
+ host: "https://app.all-hands.dev",
15
+ apiKey: "bearer-token",
16
+ kind: "cloud",
17
+ };
18
+
19
+ const originalFetch = global.fetch;
20
+ const fetchMock = vi.fn();
21
+
22
+ beforeEach(() => {
23
+ window.localStorage.clear();
24
+ __resetActiveStoreForTests();
25
+ setRegisteredBackends([cloudBackend]);
26
+ setActiveSelection({ backendId: cloudBackend.id });
27
+ fetchMock.mockReset();
28
+ fetchMock.mockResolvedValue(mockJsonResponse({ success: true }));
29
+ global.fetch = fetchMock as typeof fetch;
30
+ });
31
+
32
+ afterEach(() => {
33
+ window.localStorage.clear();
34
+ __resetActiveStoreForTests();
35
+ fetchMock.mockReset();
36
+ global.fetch = originalFetch;
37
+ });
38
+
39
+ describe("AgentServerConversationService.deleteConversation cloud branch", () => {
40
+ it("calls the cloud DELETE app-conversations endpoint directly", async () => {
41
+ await AgentServerConversationService.deleteConversation("conv-abc");
42
+
43
+ expect(fetchMock).toHaveBeenCalledOnce();
44
+ const [url, init] = getFetchCall(fetchMock);
45
+ expect(url).toBe(`${cloudBackend.host}/api/v1/app-conversations/conv-abc`);
46
+ expect(init).toMatchObject({
47
+ method: "DELETE",
48
+ headers: { Authorization: "Bearer bearer-token" },
49
+ });
50
+ });
51
+ });
__tests__/api/cloud/conversation-download.test.ts ADDED
@@ -0,0 +1,63 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
2
+ import {
3
+ __resetActiveStoreForTests,
4
+ setActiveSelection,
5
+ setRegisteredBackends,
6
+ } from "#/api/backend-registry/active-store";
7
+ import type { Backend } from "#/api/backend-registry/types";
8
+ import AgentServerConversationService from "#/api/conversation-service/agent-server-conversation-service.api";
9
+ import {
10
+ getFetchCall,
11
+ mockBlobResponse,
12
+ mockJsonResponse,
13
+ } from "./fetch-test-utils";
14
+
15
+ const cloudBackend: Backend = {
16
+ id: "prod",
17
+ name: "Production",
18
+ host: "https://app.all-hands.dev",
19
+ apiKey: "bearer-token",
20
+ kind: "cloud",
21
+ };
22
+
23
+ const originalFetch = global.fetch;
24
+ const fetchMock = vi.fn();
25
+
26
+ beforeEach(() => {
27
+ window.localStorage.clear();
28
+ __resetActiveStoreForTests();
29
+ setRegisteredBackends([cloudBackend]);
30
+ setActiveSelection({ backendId: cloudBackend.id });
31
+ fetchMock.mockReset();
32
+ fetchMock.mockResolvedValue(mockJsonResponse({}));
33
+ global.fetch = fetchMock as typeof fetch;
34
+ });
35
+
36
+ afterEach(() => {
37
+ window.localStorage.clear();
38
+ __resetActiveStoreForTests();
39
+ fetchMock.mockReset();
40
+ global.fetch = originalFetch;
41
+ });
42
+
43
+ describe("AgentServerConversationService.downloadConversation cloud branch", () => {
44
+ it("calls the cloud download endpoint directly with responseType blob and returns the Blob", async () => {
45
+ fetchMock.mockResolvedValueOnce(
46
+ mockBlobResponse("zip-bytes", "application/zip"),
47
+ );
48
+
49
+ const result =
50
+ await AgentServerConversationService.downloadConversation("conv-abc");
51
+
52
+ expect(fetchMock).toHaveBeenCalledOnce();
53
+ const [url, init] = getFetchCall(fetchMock);
54
+ expect(url).toBe(
55
+ `${cloudBackend.host}/api/v1/app-conversations/conv-abc/download`,
56
+ );
57
+ expect(init).toMatchObject({
58
+ method: "GET",
59
+ headers: { Authorization: "Bearer bearer-token" },
60
+ });
61
+ await expect(result.text()).resolves.toBe("zip-bytes");
62
+ });
63
+ });
__tests__/api/cloud/conversation-pause.test.ts ADDED
@@ -0,0 +1,93 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
2
+ import {
3
+ __resetActiveStoreForTests,
4
+ setActiveSelection,
5
+ setRegisteredBackends,
6
+ } from "#/api/backend-registry/active-store";
7
+ import type { Backend } from "#/api/backend-registry/types";
8
+ import AgentServerConversationService from "#/api/conversation-service/agent-server-conversation-service.api";
9
+ import type { AppConversation } from "#/api/conversation-service/agent-server-conversation-service.types";
10
+ import { pauseConversation } from "#/hooks/mutation/conversation-mutation-utils";
11
+ import { ExecutionStatus } from "#/types/agent-server/core/base/common";
12
+ import { getFetchCall, mockJsonResponse } from "./fetch-test-utils";
13
+
14
+ const cloudBackend: Backend = {
15
+ id: "prod",
16
+ name: "Production",
17
+ host: "https://app.all-hands.dev",
18
+ apiKey: "bearer-token",
19
+ kind: "cloud",
20
+ };
21
+
22
+ const buildConversation = (
23
+ overrides: Partial<AppConversation> = {},
24
+ ): AppConversation => ({
25
+ id: "conv-abc",
26
+ created_by_user_id: null,
27
+ selected_repository: null,
28
+ selected_branch: null,
29
+ git_provider: null,
30
+ title: "Test",
31
+ trigger: null,
32
+ pr_number: [],
33
+ llm_model: null,
34
+ metrics: null,
35
+ created_at: "2026-04-16T00:00:00Z",
36
+ updated_at: "2026-04-16T00:00:00Z",
37
+ execution_status: ExecutionStatus.RUNNING,
38
+ conversation_url: null,
39
+ session_api_key: null,
40
+ sandbox_id: "sandbox-xyz",
41
+ sub_conversation_ids: [],
42
+ ...overrides,
43
+ });
44
+
45
+ const originalFetch = global.fetch;
46
+ const fetchMock = vi.fn();
47
+
48
+ beforeEach(() => {
49
+ window.localStorage.clear();
50
+ __resetActiveStoreForTests();
51
+ setRegisteredBackends([cloudBackend]);
52
+ setActiveSelection({ backendId: cloudBackend.id });
53
+ fetchMock.mockReset();
54
+ fetchMock.mockResolvedValue(mockJsonResponse({ success: true }));
55
+ global.fetch = fetchMock as typeof fetch;
56
+ });
57
+
58
+ afterEach(() => {
59
+ window.localStorage.clear();
60
+ __resetActiveStoreForTests();
61
+ fetchMock.mockReset();
62
+ global.fetch = originalFetch;
63
+ vi.restoreAllMocks();
64
+ });
65
+
66
+ describe("pauseConversation cloud branch", () => {
67
+ it("POSTs directly to the cloud sandbox pause endpoint", async () => {
68
+ vi.spyOn(
69
+ AgentServerConversationService,
70
+ "batchGetAppConversations",
71
+ ).mockResolvedValue([buildConversation({ sandbox_id: "sandbox-xyz" })]);
72
+
73
+ await pauseConversation("conv-abc");
74
+
75
+ expect(fetchMock).toHaveBeenCalledOnce();
76
+ const [url, init] = getFetchCall(fetchMock);
77
+ expect(url).toBe(`${cloudBackend.host}/api/v1/sandboxes/sandbox-xyz/pause`);
78
+ expect(init).toMatchObject({
79
+ method: "POST",
80
+ headers: { Authorization: "Bearer bearer-token" },
81
+ });
82
+ });
83
+
84
+ it("throws and does not call the cloud API when the cloud conversation has no sandbox_id", async () => {
85
+ vi.spyOn(
86
+ AgentServerConversationService,
87
+ "batchGetAppConversations",
88
+ ).mockResolvedValue([buildConversation({ sandbox_id: null })]);
89
+
90
+ await expect(pauseConversation("conv-abc")).rejects.toThrow(/sandbox_id/);
91
+ expect(fetchMock).not.toHaveBeenCalled();
92
+ });
93
+ });
__tests__/api/cloud/conversation-public-flag.test.ts ADDED
@@ -0,0 +1,73 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
2
+ import {
3
+ __resetActiveStoreForTests,
4
+ setActiveSelection,
5
+ setRegisteredBackends,
6
+ } from "#/api/backend-registry/active-store";
7
+ import type { Backend } from "#/api/backend-registry/types";
8
+ import AgentServerConversationService from "#/api/conversation-service/agent-server-conversation-service.api";
9
+ import {
10
+ getFetchCall,
11
+ getJsonBody,
12
+ mockJsonResponse,
13
+ } from "./fetch-test-utils";
14
+
15
+ const cloudBackend: Backend = {
16
+ id: "prod",
17
+ name: "Production",
18
+ host: "https://app.all-hands.dev",
19
+ apiKey: "bearer-token",
20
+ kind: "cloud",
21
+ };
22
+
23
+ const originalFetch = global.fetch;
24
+ const fetchMock = vi.fn();
25
+
26
+ beforeEach(() => {
27
+ window.localStorage.clear();
28
+ __resetActiveStoreForTests();
29
+ fetchMock.mockReset();
30
+ fetchMock.mockResolvedValue(
31
+ mockJsonResponse({ id: "conv-abc", public: true }),
32
+ );
33
+ global.fetch = fetchMock as typeof fetch;
34
+ });
35
+
36
+ afterEach(() => {
37
+ window.localStorage.clear();
38
+ __resetActiveStoreForTests();
39
+ fetchMock.mockReset();
40
+ global.fetch = originalFetch;
41
+ });
42
+
43
+ describe("AgentServerConversationService.updateConversationPublicFlag", () => {
44
+ it("PATCHes /api/v1/app-conversations/{id} directly on a cloud backend", async () => {
45
+ setRegisteredBackends([cloudBackend]);
46
+ setActiveSelection({ backendId: cloudBackend.id });
47
+
48
+ await AgentServerConversationService.updateConversationPublicFlag(
49
+ "conv-abc",
50
+ true,
51
+ );
52
+
53
+ expect(fetchMock).toHaveBeenCalledOnce();
54
+ const [url, init] = getFetchCall(fetchMock);
55
+ expect(url).toBe(`${cloudBackend.host}/api/v1/app-conversations/conv-abc`);
56
+ expect(init).toMatchObject({
57
+ method: "PATCH",
58
+ headers: { Authorization: "Bearer bearer-token" },
59
+ });
60
+ expect(getJsonBody(init)).toEqual({ public: true });
61
+ });
62
+
63
+ it("rejects without calling the cloud API when the active backend is local", async () => {
64
+ // Default state after reset is the bundled local backend.
65
+ await expect(
66
+ AgentServerConversationService.updateConversationPublicFlag(
67
+ "conv-abc",
68
+ true,
69
+ ),
70
+ ).rejects.toThrow(/cloud backend/);
71
+ expect(fetchMock).not.toHaveBeenCalled();
72
+ });
73
+ });
__tests__/api/cloud/conversation-runtime-info.test.ts ADDED
@@ -0,0 +1,135 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
2
+ import {
3
+ __resetActiveStoreForTests,
4
+ setActiveSelection,
5
+ setRegisteredBackends,
6
+ } from "#/api/backend-registry/active-store";
7
+ import type { Backend } from "#/api/backend-registry/types";
8
+ import AgentServerConversationService from "#/api/conversation-service/agent-server-conversation-service.api";
9
+ import {
10
+ getFetchCall,
11
+ getJsonBody,
12
+ mockJsonResponse,
13
+ } from "./fetch-test-utils";
14
+
15
+ const cloudBackend: Backend = {
16
+ id: "prod",
17
+ name: "Production",
18
+ host: "https://app.all-hands.dev",
19
+ apiKey: "bearer-token",
20
+ kind: "cloud",
21
+ };
22
+
23
+ const localBackend: Backend = {
24
+ id: "self-hosted",
25
+ name: "Self-hosted",
26
+ host: "http://192.168.1.99:9999",
27
+ apiKey: "local-key",
28
+ kind: "local",
29
+ };
30
+
31
+ const runtimeResponse = {
32
+ id: "conv-abc",
33
+ title: "Test conversation",
34
+ created_at: "2026-04-16T00:00:00Z",
35
+ updated_at: "2026-04-16T00:00:00Z",
36
+ execution_status: "idle",
37
+ metrics: null,
38
+ stats: {
39
+ usage_to_metrics: {
40
+ agent: {
41
+ model_name: "test-model",
42
+ accumulated_cost: 1.23,
43
+ max_budget_per_task: null,
44
+ accumulated_token_usage: null,
45
+ costs: [],
46
+ response_latencies: [],
47
+ token_usages: [],
48
+ },
49
+ },
50
+ },
51
+ };
52
+
53
+ const fetchMock = vi.fn();
54
+
55
+ afterEach(() => {
56
+ window.localStorage.clear();
57
+ __resetActiveStoreForTests();
58
+ fetchMock.mockReset();
59
+ vi.unstubAllGlobals();
60
+ vi.restoreAllMocks();
61
+ });
62
+
63
+ describe("AgentServerConversationService.getRuntimeConversation", () => {
64
+ describe("cloud mode", () => {
65
+ beforeEach(() => {
66
+ __resetActiveStoreForTests();
67
+ setRegisteredBackends([cloudBackend]);
68
+ setActiveSelection({ backendId: cloudBackend.id });
69
+ fetchMock.mockReset();
70
+ vi.stubGlobal("fetch", fetchMock);
71
+ });
72
+
73
+ it("targets the conversation runtime host directly and forwards X-Session-API-Key", async () => {
74
+ // Arrange
75
+ fetchMock.mockResolvedValue(mockJsonResponse(runtimeResponse));
76
+ const conversationUrl =
77
+ "http://abc123.runtime.all-hands.dev/api/conversations/conv-abc";
78
+
79
+ // Act
80
+ const result =
81
+ await AgentServerConversationService.getRuntimeConversation(
82
+ "conv-abc",
83
+ conversationUrl,
84
+ "session-xyz",
85
+ );
86
+
87
+ // Assert — fetch goes directly to the runtime host, not through
88
+ // /api/cloud-proxy (which was removed from the agent-server).
89
+ expect(fetchMock).toHaveBeenCalledOnce();
90
+ const [url, init] = getFetchCall(fetchMock);
91
+ expect(url).toContain("abc123.runtime.all-hands.dev");
92
+ expect(url).toContain("/api/conversations/conv-abc");
93
+ expect(url).not.toMatch(/\/api\/cloud-proxy$/);
94
+ expect(init.headers).toMatchObject({
95
+ "X-Session-API-Key": "session-xyz",
96
+ });
97
+ expect(result.stats.usage_to_metrics.agent?.accumulated_cost).toBe(1.23);
98
+ });
99
+ });
100
+
101
+ describe("local mode", () => {
102
+ beforeEach(() => {
103
+ __resetActiveStoreForTests();
104
+ setRegisteredBackends([localBackend]);
105
+ setActiveSelection({ backendId: localBackend.id });
106
+ fetchMock.mockReset();
107
+ vi.stubGlobal("fetch", fetchMock);
108
+ });
109
+
110
+ it("targets the conversation_url host (not the active backend host) and forwards X-Session-API-Key", async () => {
111
+ // Arrange
112
+ fetchMock.mockResolvedValue(mockJsonResponse(runtimeResponse));
113
+ const conversationUrl =
114
+ "http://192.168.1.42:8888/api/conversations/conv-abc";
115
+
116
+ // Act
117
+ const result =
118
+ await AgentServerConversationService.getRuntimeConversation(
119
+ "conv-abc",
120
+ conversationUrl,
121
+ "session-xyz",
122
+ );
123
+
124
+ // Assert
125
+ expect(fetchMock).toHaveBeenCalledOnce();
126
+ const [url, init] = getFetchCall(fetchMock);
127
+ expect(url).toContain("192.168.1.42:8888");
128
+ expect(url).not.toContain(localBackend.host);
129
+ expect(init.headers).toMatchObject({
130
+ "X-Session-API-Key": "session-xyz",
131
+ });
132
+ expect(result.id).toBe("conv-abc");
133
+ });
134
+ });
135
+ });
__tests__/api/cloud/conversation-title.test.ts ADDED
@@ -0,0 +1,84 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
2
+ import {
3
+ __resetActiveStoreForTests,
4
+ setActiveSelection,
5
+ setRegisteredBackends,
6
+ } from "#/api/backend-registry/active-store";
7
+ import type { Backend } from "#/api/backend-registry/types";
8
+ import AgentServerConversationService from "#/api/conversation-service/agent-server-conversation-service.api";
9
+ import {
10
+ getFetchCall,
11
+ getJsonBody,
12
+ mockJsonResponse,
13
+ } from "./fetch-test-utils";
14
+
15
+ vi.mock("@openhands/typescript-client/clients", async (importOriginal) => {
16
+ const actual =
17
+ await importOriginal<
18
+ typeof import("@openhands/typescript-client/clients")
19
+ >();
20
+
21
+ return {
22
+ ...actual,
23
+ ConversationClient: vi.fn().mockImplementation(() => ({
24
+ updateConversation: vi
25
+ .fn()
26
+ .mockRejectedValue(
27
+ new Error("Cloud title updates must not use ConversationClient"),
28
+ ),
29
+ })),
30
+ };
31
+ });
32
+
33
+ const cloudBackend: Backend = {
34
+ id: "prod",
35
+ name: "Production",
36
+ host: "https://app.all-hands.dev",
37
+ apiKey: "bearer-token",
38
+ kind: "cloud",
39
+ };
40
+
41
+ const originalFetch = global.fetch;
42
+ const fetchMock = vi.fn();
43
+
44
+ beforeEach(() => {
45
+ window.localStorage.clear();
46
+ __resetActiveStoreForTests();
47
+ fetchMock.mockReset();
48
+ fetchMock.mockResolvedValue(
49
+ mockJsonResponse({ id: "conv-abc", title: "Renamed conversation" }),
50
+ );
51
+ global.fetch = fetchMock as typeof fetch;
52
+ });
53
+
54
+ afterEach(() => {
55
+ window.localStorage.clear();
56
+ __resetActiveStoreForTests();
57
+ fetchMock.mockReset();
58
+ global.fetch = originalFetch;
59
+ });
60
+
61
+ describe("AgentServerConversationService.updateConversationTitle", () => {
62
+ it("PATCHes the app-conversation directly on a cloud backend", async () => {
63
+ setRegisteredBackends([cloudBackend]);
64
+ setActiveSelection({ backendId: cloudBackend.id });
65
+
66
+ const result = await AgentServerConversationService.updateConversationTitle(
67
+ "conv-abc",
68
+ "Renamed conversation",
69
+ );
70
+
71
+ expect(fetchMock).toHaveBeenCalledOnce();
72
+ const [url, init] = getFetchCall(fetchMock);
73
+ expect(url).toBe(`${cloudBackend.host}/api/v1/app-conversations/conv-abc`);
74
+ expect(init).toMatchObject({
75
+ method: "PATCH",
76
+ headers: { Authorization: "Bearer bearer-token" },
77
+ });
78
+ expect(getJsonBody(init)).toEqual({ title: "Renamed conversation" });
79
+ expect(result).toMatchObject({
80
+ id: "conv-abc",
81
+ title: "Renamed conversation",
82
+ });
83
+ });
84
+ });
__tests__/api/cloud/fetch-test-utils.ts ADDED
@@ -0,0 +1,33 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import { vi } from "vitest";
2
+
3
+ type FetchMock = ReturnType<typeof vi.fn>;
4
+
5
+ export function mockJsonResponse(data: unknown, status = 200): Response {
6
+ return new Response(JSON.stringify(data), {
7
+ status,
8
+ statusText: status === 200 ? "OK" : "Error",
9
+ headers: { "content-type": "application/json" },
10
+ });
11
+ }
12
+
13
+ export function mockBlobResponse(
14
+ body: BodyInit,
15
+ contentType: string,
16
+ ): Response {
17
+ return new Response(body, {
18
+ status: 200,
19
+ headers: { "content-type": contentType },
20
+ });
21
+ }
22
+
23
+ export function getFetchCall(
24
+ fetchMock: FetchMock,
25
+ index = 0,
26
+ ): [string, RequestInit] {
27
+ const [url, init] = fetchMock.mock.calls[index] as [string, RequestInit];
28
+ return [url, init ?? {}];
29
+ }
30
+
31
+ export function getJsonBody(init: RequestInit): Record<string, unknown> {
32
+ return JSON.parse(String(init.body ?? "{}")) as Record<string, unknown>;
33
+ }
__tests__/api/cloud/git-service.test.ts ADDED
@@ -0,0 +1,69 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
2
+ import {
3
+ __resetActiveStoreForTests,
4
+ setActiveSelection,
5
+ setRegisteredBackends,
6
+ } from "#/api/backend-registry/active-store";
7
+ import type { Backend } from "#/api/backend-registry/types";
8
+ import { getCloudRepositoryBranches } from "#/api/cloud/git-service.api";
9
+ import { getFetchCall, mockJsonResponse } from "./fetch-test-utils";
10
+
11
+ const cloudBackend: Backend = {
12
+ id: "prod",
13
+ name: "Production",
14
+ host: "https://app.all-hands.dev",
15
+ apiKey: "bearer-token",
16
+ kind: "cloud",
17
+ };
18
+
19
+ const emptyBranchPage = { items: [], next_page_id: null };
20
+ const originalFetch = global.fetch;
21
+ const fetchMock = vi.fn();
22
+
23
+ beforeEach(() => {
24
+ window.localStorage.clear();
25
+ __resetActiveStoreForTests();
26
+ setRegisteredBackends([cloudBackend]);
27
+ setActiveSelection({ backendId: cloudBackend.id });
28
+ fetchMock.mockReset();
29
+ fetchMock.mockResolvedValue(mockJsonResponse(emptyBranchPage));
30
+ global.fetch = fetchMock as typeof fetch;
31
+ });
32
+
33
+ afterEach(() => {
34
+ window.localStorage.clear();
35
+ __resetActiveStoreForTests();
36
+ fetchMock.mockReset();
37
+ global.fetch = originalFetch;
38
+ });
39
+
40
+ describe("getCloudRepositoryBranches", () => {
41
+ it("includes an empty query parameter when listing all branches so the upstream schema is satisfied", async () => {
42
+ // Act
43
+ await getCloudRepositoryBranches({
44
+ provider: "github",
45
+ repository: "hieptl/hieptl",
46
+ });
47
+
48
+ // Assert
49
+ const [url, init] = getFetchCall(fetchMock);
50
+ expect(init).toMatchObject({
51
+ method: "GET",
52
+ headers: { Authorization: "Bearer bearer-token" },
53
+ });
54
+ expect(url).toMatch(/[?&]query=(&|$)/);
55
+ });
56
+
57
+ it("forwards a non-empty query parameter when searching branches", async () => {
58
+ // Act
59
+ await getCloudRepositoryBranches({
60
+ provider: "github",
61
+ repository: "hieptl/hieptl",
62
+ query: "feature/login",
63
+ });
64
+
65
+ // Assert
66
+ const [url] = getFetchCall(fetchMock);
67
+ expect(url).toContain("query=feature%2Flogin");
68
+ });
69
+ });
__tests__/api/cloud/organization-me.test.ts ADDED
@@ -0,0 +1,88 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
2
+ import {
3
+ __resetActiveStoreForTests,
4
+ setActiveSelection,
5
+ setRegisteredBackends,
6
+ } from "#/api/backend-registry/active-store";
7
+ import type { Backend } from "#/api/backend-registry/types";
8
+ import { getCloudOrganizationMe } from "#/api/cloud/organization-service.api";
9
+ import { getFetchCall, mockJsonResponse } from "./fetch-test-utils";
10
+
11
+ const cloudBackend: Backend = {
12
+ id: "prod",
13
+ name: "Production",
14
+ host: "https://app.all-hands.dev",
15
+ apiKey: "bearer-token",
16
+ kind: "cloud",
17
+ };
18
+
19
+ const originalFetch = global.fetch;
20
+ const fetchMock = vi.fn();
21
+
22
+ beforeEach(() => {
23
+ window.localStorage.clear();
24
+ __resetActiveStoreForTests();
25
+ setRegisteredBackends([cloudBackend]);
26
+ setActiveSelection({ backendId: cloudBackend.id });
27
+ fetchMock.mockReset();
28
+ global.fetch = fetchMock as typeof fetch;
29
+ });
30
+
31
+ afterEach(() => {
32
+ window.localStorage.clear();
33
+ __resetActiveStoreForTests();
34
+ fetchMock.mockReset();
35
+ global.fetch = originalFetch;
36
+ });
37
+
38
+ describe("cloud organization /me", () => {
39
+ it("calls /api/organizations/{orgId}/me directly and returns user_id", async () => {
40
+ const orgId = "0b93b5f2-5396-49f2-8d98-61f906184270";
41
+ fetchMock.mockResolvedValue(
42
+ mockJsonResponse({
43
+ org_id: orgId,
44
+ user_id: orgId,
45
+ email: "hieptl.developer@gmail.com",
46
+ role: "owner",
47
+ permissions: ["view_org_settings", "edit_org_settings"],
48
+ }),
49
+ );
50
+
51
+ const result = await getCloudOrganizationMe(orgId);
52
+
53
+ const [url, init] = getFetchCall(fetchMock);
54
+ expect(url).toBe(`${cloudBackend.host}/api/organizations/${orgId}/me`);
55
+ expect(init).toMatchObject({
56
+ method: "GET",
57
+ headers: { Authorization: "Bearer bearer-token" },
58
+ });
59
+ expect(result).toEqual({
60
+ orgId,
61
+ userId: orgId,
62
+ role: "owner",
63
+ permissions: ["view_org_settings", "edit_org_settings"],
64
+ });
65
+ });
66
+
67
+ it("returns null permissions when the app-server omits them (older version)", async () => {
68
+ const orgId = "0b93b5f2-5396-49f2-8d98-61f906184270";
69
+ fetchMock.mockResolvedValue(
70
+ mockJsonResponse({
71
+ org_id: orgId,
72
+ user_id: orgId,
73
+ email: "x@example.com",
74
+ role: "member",
75
+ }),
76
+ );
77
+
78
+ const result = await getCloudOrganizationMe(orgId);
79
+
80
+ // Absent `permissions` → null, so callers fall back to the role check.
81
+ expect(result).toEqual({
82
+ orgId,
83
+ userId: orgId,
84
+ role: "member",
85
+ permissions: null,
86
+ });
87
+ });
88
+ });
__tests__/api/cloud/organization-service.test.ts ADDED
@@ -0,0 +1,144 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
2
+ import { HttpError } from "@openhands/typescript-client";
3
+ import {
4
+ __resetActiveStoreForTests,
5
+ setRegisteredBackends,
6
+ } from "#/api/backend-registry/active-store";
7
+ import {
8
+ getCloudOrganizationMember,
9
+ getCloudOrganizations,
10
+ getCurrentCloudApiKey,
11
+ } from "#/api/cloud/organization-service.api";
12
+ import type { Backend } from "#/api/backend-registry/types";
13
+
14
+ const cloudBackend: Backend = {
15
+ id: "prod",
16
+ name: "Production",
17
+ host: "https://app.all-hands.dev",
18
+ apiKey: "bearer-token",
19
+ kind: "cloud",
20
+ };
21
+
22
+ const originalFetch = global.fetch;
23
+ const fetchMock = vi.fn();
24
+
25
+ function mockJsonResponse(data: unknown, status = 200): Response {
26
+ return new Response(JSON.stringify(data), {
27
+ status,
28
+ statusText: status === 200 ? "OK" : "Error",
29
+ headers: { "content-type": "application/json" },
30
+ });
31
+ }
32
+
33
+ beforeEach(() => {
34
+ window.localStorage.clear();
35
+ __resetActiveStoreForTests();
36
+ setRegisteredBackends([]);
37
+ fetchMock.mockReset();
38
+ global.fetch = fetchMock as typeof fetch;
39
+ });
40
+
41
+ afterEach(() => {
42
+ window.localStorage.clear();
43
+ __resetActiveStoreForTests();
44
+ fetchMock.mockReset();
45
+ global.fetch = originalFetch;
46
+ });
47
+
48
+ describe("cloud organization-service", () => {
49
+ it("getCloudOrganizations calls the cloud API directly and returns normalized data", async () => {
50
+ fetchMock.mockResolvedValueOnce(
51
+ mockJsonResponse({
52
+ items: [{ id: "org-1", name: "Personal" }],
53
+ current_org_id: "org-1",
54
+ }),
55
+ );
56
+
57
+ const result = await getCloudOrganizations(cloudBackend);
58
+
59
+ expect(fetchMock).toHaveBeenCalledOnce();
60
+ const [url, init] = fetchMock.mock.calls[0]!;
61
+
62
+ expect(url).toBe(`${cloudBackend.host}/api/organizations`);
63
+ expect(init).toMatchObject({
64
+ method: "GET",
65
+ headers: { Authorization: "Bearer bearer-token" },
66
+ });
67
+
68
+ expect(result).toEqual({
69
+ items: [{ id: "org-1", name: "Personal" }],
70
+ currentOrgId: "org-1",
71
+ });
72
+ });
73
+
74
+ it("getCurrentCloudApiKey hits /api/keys/current and returns the bound orgId", async () => {
75
+ fetchMock.mockResolvedValueOnce(
76
+ mockJsonResponse({
77
+ id: "key-1",
78
+ name: "k",
79
+ org_id: "org-bound",
80
+ user_id: "user-1",
81
+ auth_type: "bearer",
82
+ }),
83
+ );
84
+
85
+ const result = await getCurrentCloudApiKey(cloudBackend);
86
+
87
+ const [url, init] = fetchMock.mock.calls[0]!;
88
+ expect(url).toBe(`${cloudBackend.host}/api/keys/current`);
89
+ expect(init).toMatchObject({
90
+ method: "GET",
91
+ headers: { Authorization: "Bearer bearer-token" },
92
+ });
93
+ expect(result).toEqual({ orgId: "org-bound", isLegacyKey: false });
94
+ });
95
+
96
+ it("getCurrentCloudApiKey treats an upstream 400 as a legacy key (no binding)", async () => {
97
+ fetchMock.mockResolvedValueOnce(mockJsonResponse({ detail: "bad" }, 400));
98
+
99
+ const result = await getCurrentCloudApiKey(cloudBackend);
100
+
101
+ expect(result).toEqual({ orgId: null, isLegacyKey: true });
102
+ });
103
+
104
+ it("getCurrentCloudApiKey rethrows non-400 upstream errors (e.g. revoked key)", async () => {
105
+ fetchMock.mockResolvedValueOnce(
106
+ mockJsonResponse({ detail: "unauthorized" }, 401),
107
+ );
108
+
109
+ await expect(getCurrentCloudApiKey(cloudBackend)).rejects.toBeInstanceOf(
110
+ HttpError,
111
+ );
112
+ });
113
+
114
+ it("getCloudOrganizationMember hits /api/organizations/{orgId}/members/{userId} and returns the member", async () => {
115
+ // Arrange
116
+ const member = {
117
+ org_id: "org-1",
118
+ user_id: "user-1",
119
+ email: "jdoe@acme.com",
120
+ role: "member",
121
+ status: "active",
122
+ };
123
+ fetchMock.mockResolvedValueOnce(mockJsonResponse(member));
124
+
125
+ // Act
126
+ const result = await getCloudOrganizationMember(
127
+ "org-1",
128
+ "user-1",
129
+ cloudBackend,
130
+ );
131
+
132
+ // Assert
133
+ expect(fetchMock).toHaveBeenCalledOnce();
134
+ const [url, init] = fetchMock.mock.calls[0]!;
135
+ expect(url).toBe(
136
+ `${cloudBackend.host}/api/organizations/org-1/members/user-1`,
137
+ );
138
+ expect(init).toMatchObject({
139
+ method: "GET",
140
+ headers: { Authorization: "Bearer bearer-token" },
141
+ });
142
+ expect(result).toEqual(member);
143
+ });
144
+ });
__tests__/api/cloud/profiles-service.test.ts ADDED
@@ -0,0 +1,217 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
2
+ import {
3
+ __resetActiveStoreForTests,
4
+ setActiveSelection,
5
+ setRegisteredBackends,
6
+ } from "#/api/backend-registry/active-store";
7
+ import type { Backend } from "#/api/backend-registry/types";
8
+ import ProfilesService, {
9
+ type SaveProfileRequest,
10
+ } from "#/api/profiles-service/profiles-service.api";
11
+ import {
12
+ getFetchCall,
13
+ getJsonBody,
14
+ mockJsonResponse,
15
+ } from "./fetch-test-utils";
16
+
17
+ const cloudBackend: Backend = {
18
+ id: "prod",
19
+ name: "Production",
20
+ host: "https://app.all-hands.dev",
21
+ apiKey: "bearer-token",
22
+ kind: "cloud",
23
+ };
24
+
25
+ const ORG_ID = "org-1";
26
+ const ORG_BASE = `https://app.all-hands.dev/api/organizations/${ORG_ID}/profiles`;
27
+ const SETTINGS_BASE = "https://app.all-hands.dev/api/v1/settings/profiles";
28
+ const originalFetch = global.fetch;
29
+ const fetchMock = vi.fn();
30
+
31
+ beforeEach(() => {
32
+ window.localStorage.clear();
33
+ __resetActiveStoreForTests();
34
+ setRegisteredBackends([cloudBackend]);
35
+ fetchMock.mockReset();
36
+ fetchMock.mockResolvedValue(mockJsonResponse({}));
37
+ global.fetch = fetchMock as typeof fetch;
38
+ });
39
+
40
+ afterEach(() => {
41
+ window.localStorage.clear();
42
+ __resetActiveStoreForTests();
43
+ fetchMock.mockReset();
44
+ global.fetch = originalFetch;
45
+ });
46
+
47
+ // With an org bound, profile CRUD goes through the org-gated routes so the
48
+ // server enforces EDIT_ORG_SETTINGS (a member's mutation 403s, not just hidden).
49
+ describe("ProfilesService against a cloud org (gated org routes)", () => {
50
+ beforeEach(() => {
51
+ setActiveSelection({ backendId: cloudBackend.id, orgId: ORG_ID });
52
+ });
53
+
54
+ it("lists profiles via GET /api/organizations/{orgId}/profiles", async () => {
55
+ fetchMock.mockResolvedValueOnce(
56
+ mockJsonResponse({
57
+ profiles: [
58
+ { name: "gpt", model: "gpt-4o", base_url: null, api_key_set: true },
59
+ ],
60
+ active_profile: "gpt",
61
+ }),
62
+ );
63
+
64
+ const res = await ProfilesService.listProfiles();
65
+
66
+ const [url, init] = getFetchCall(fetchMock);
67
+ expect(url).toBe(ORG_BASE);
68
+ expect(init).toMatchObject({
69
+ method: "GET",
70
+ headers: { Authorization: "Bearer bearer-token" },
71
+ });
72
+ expect(res.active_profile).toBe("gpt");
73
+ });
74
+
75
+ it("fetches a profile and maps the org `llm` onto `config`", async () => {
76
+ fetchMock.mockResolvedValueOnce(
77
+ mockJsonResponse({
78
+ name: "my profile",
79
+ llm: { model: "gpt-4o", api_key: null },
80
+ }),
81
+ );
82
+
83
+ const res = await ProfilesService.getProfile("my profile");
84
+
85
+ const [url, init] = getFetchCall(fetchMock);
86
+ expect(url).toBe(`${ORG_BASE}/my%20profile`);
87
+ expect(init).toMatchObject({
88
+ method: "GET",
89
+ });
90
+ expect(res).toEqual({
91
+ name: "my profile",
92
+ config: { model: "gpt-4o", api_key: null },
93
+ api_key_set: false,
94
+ });
95
+ });
96
+
97
+ it("saves a profile via POST .../{name} forwarding the request body", async () => {
98
+ fetchMock.mockResolvedValueOnce(
99
+ mockJsonResponse({ name: "gpt", message: "Profile 'gpt' saved" }),
100
+ );
101
+
102
+ await ProfilesService.saveProfile("gpt", {
103
+ llm: { model: "gpt-4o" } as SaveProfileRequest["llm"],
104
+ include_secrets: true,
105
+ });
106
+
107
+ const [url, init] = getFetchCall(fetchMock);
108
+ expect(url).toBe(`${ORG_BASE}/gpt`);
109
+ expect(init).toMatchObject({
110
+ method: "POST",
111
+ });
112
+ expect(getJsonBody(init)).toEqual({
113
+ llm: { model: "gpt-4o" },
114
+ include_secrets: true,
115
+ });
116
+ });
117
+
118
+ it("deletes a profile via DELETE .../{name}", async () => {
119
+ fetchMock.mockResolvedValueOnce(
120
+ mockJsonResponse({ name: "gpt", message: "Profile 'gpt' deleted" }),
121
+ );
122
+
123
+ await ProfilesService.deleteProfile("gpt");
124
+
125
+ const [url, init] = getFetchCall(fetchMock);
126
+ expect(url).toBe(`${ORG_BASE}/gpt`);
127
+ expect(init).toMatchObject({ method: "DELETE" });
128
+ });
129
+
130
+ it("renames a profile via POST .../{name}/rename with new_name", async () => {
131
+ fetchMock.mockResolvedValueOnce(
132
+ mockJsonResponse({ name: "new", message: "renamed" }),
133
+ );
134
+
135
+ await ProfilesService.renameProfile("old", "new");
136
+
137
+ const [url, init] = getFetchCall(fetchMock);
138
+ expect(url).toBe(`${ORG_BASE}/old/rename`);
139
+ expect(init).toMatchObject({
140
+ method: "POST",
141
+ });
142
+ expect(getJsonBody(init)).toEqual({ new_name: "new" });
143
+ });
144
+
145
+ it("activates a profile and maps the org `llm` onto `llm_applied`", async () => {
146
+ fetchMock.mockResolvedValueOnce(
147
+ mockJsonResponse({
148
+ name: "gpt",
149
+ message: "Switched to profile 'gpt'",
150
+ llm: { model: "gpt-4o" },
151
+ }),
152
+ );
153
+
154
+ const res = await ProfilesService.activateProfile("gpt");
155
+
156
+ const [url, init] = getFetchCall(fetchMock);
157
+ expect(url).toBe(`${ORG_BASE}/gpt/activate`);
158
+ expect(init).toMatchObject({
159
+ method: "POST",
160
+ });
161
+ expect(res).toEqual({
162
+ name: "gpt",
163
+ message: "Switched to profile 'gpt'",
164
+ llm_applied: true,
165
+ });
166
+ });
167
+ });
168
+
169
+ // Legacy API keys have no org bound; CRUD falls back to the per-user settings
170
+ // route (ungated — there is no org role to enforce against).
171
+ describe("ProfilesService on a cloud backend with no org (fallback)", () => {
172
+ beforeEach(() => {
173
+ setActiveSelection({ backendId: cloudBackend.id });
174
+ });
175
+
176
+ it("lists via the per-user settings route", async () => {
177
+ fetchMock.mockResolvedValueOnce(
178
+ mockJsonResponse({ profiles: [], active_profile: null }),
179
+ );
180
+
181
+ await ProfilesService.listProfiles();
182
+
183
+ const [url, init] = getFetchCall(fetchMock);
184
+ expect(url).toBe(SETTINGS_BASE);
185
+ expect(init).toMatchObject({ method: "GET" });
186
+ });
187
+
188
+ it("saves via the per-user settings route", async () => {
189
+ fetchMock.mockResolvedValueOnce(
190
+ mockJsonResponse({ name: "gpt", message: "saved" }),
191
+ );
192
+
193
+ await ProfilesService.saveProfile("gpt", {
194
+ llm: { model: "gpt-4o" } as SaveProfileRequest["llm"],
195
+ include_secrets: true,
196
+ });
197
+
198
+ const [url, init] = getFetchCall(fetchMock);
199
+ expect(url).toBe(`${SETTINGS_BASE}/gpt`);
200
+ expect(init).toMatchObject({ method: "POST" });
201
+ });
202
+
203
+ it("activates via the per-user settings route and maps `model`", async () => {
204
+ fetchMock.mockResolvedValueOnce(
205
+ mockJsonResponse({ name: "gpt", message: "ok", model: "gpt-4o" }),
206
+ );
207
+
208
+ const res = await ProfilesService.activateProfile("gpt");
209
+
210
+ const [url, init] = getFetchCall(fetchMock);
211
+ expect(url).toBe(`${SETTINGS_BASE}/gpt/activate`);
212
+ expect(init).toMatchObject({
213
+ method: "POST",
214
+ });
215
+ expect(res.llm_applied).toBe(true);
216
+ });
217
+ });
__tests__/api/cloud/provider-connections-service.test.ts ADDED
@@ -0,0 +1,140 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
2
+ import {
3
+ __resetActiveStoreForTests,
4
+ setActiveSelection,
5
+ setRegisteredBackends,
6
+ } from "#/api/backend-registry/active-store";
7
+ import type { Backend } from "#/api/backend-registry/types";
8
+ import ProviderConnectionsService from "#/api/provider-connections-service/provider-connections-service.api";
9
+ import {
10
+ getFetchCall,
11
+ getJsonBody,
12
+ mockJsonResponse,
13
+ } from "./fetch-test-utils";
14
+
15
+ const cloudBackend: Backend = {
16
+ id: "prod",
17
+ name: "Production",
18
+ host: "https://app.all-hands.dev",
19
+ apiKey: "bearer-token",
20
+ kind: "cloud",
21
+ };
22
+
23
+ const ORG_ID = "org-1";
24
+ const BASE = `https://app.all-hands.dev/api/organizations/${ORG_ID}/provider-connections`;
25
+ const originalFetch = global.fetch;
26
+ const fetchMock = vi.fn();
27
+
28
+ const connection = {
29
+ id: "conn-1",
30
+ display_name: "My OpenAI",
31
+ provider: "openai",
32
+ base_url: null,
33
+ created_at: 1,
34
+ updated_at: 2,
35
+ api_key_set: true,
36
+ };
37
+
38
+ beforeEach(() => {
39
+ window.localStorage.clear();
40
+ __resetActiveStoreForTests();
41
+ setRegisteredBackends([cloudBackend]);
42
+ fetchMock.mockReset();
43
+ fetchMock.mockResolvedValue(mockJsonResponse({}));
44
+ global.fetch = fetchMock as typeof fetch;
45
+ });
46
+
47
+ afterEach(() => {
48
+ window.localStorage.clear();
49
+ __resetActiveStoreForTests();
50
+ fetchMock.mockReset();
51
+ global.fetch = originalFetch;
52
+ });
53
+
54
+ // With an org bound, provider-connection CRUD goes through the org-gated routes
55
+ // so the server enforces EDIT_ORG_SETTINGS / VIEW_ORG_SETTINGS.
56
+ describe("ProviderConnectionsService against a cloud org", () => {
57
+ beforeEach(() => {
58
+ setActiveSelection({ backendId: cloudBackend.id, orgId: ORG_ID });
59
+ });
60
+
61
+ it("lists connections and unwraps the { connections } envelope", async () => {
62
+ fetchMock.mockResolvedValueOnce(
63
+ mockJsonResponse({ connections: [connection] }),
64
+ );
65
+
66
+ const res = await ProviderConnectionsService.list();
67
+
68
+ const [url, init] = getFetchCall(fetchMock);
69
+ expect(url).toBe(BASE);
70
+ expect(init).toMatchObject({
71
+ method: "GET",
72
+ headers: { Authorization: "Bearer bearer-token" },
73
+ });
74
+ expect(res).toEqual([connection]);
75
+ });
76
+
77
+ it("returns [] when the org has no connections", async () => {
78
+ fetchMock.mockResolvedValueOnce(mockJsonResponse({ connections: [] }));
79
+
80
+ const res = await ProviderConnectionsService.list();
81
+
82
+ expect(res).toEqual([]);
83
+ });
84
+
85
+ it("creates a connection via POST with the request body", async () => {
86
+ fetchMock.mockResolvedValueOnce(mockJsonResponse(connection));
87
+
88
+ const request = {
89
+ display_name: "My OpenAI",
90
+ provider: "openai",
91
+ api_key: "sk-123",
92
+ base_url: null,
93
+ };
94
+ const res = await ProviderConnectionsService.create(request);
95
+
96
+ const [url, init] = getFetchCall(fetchMock);
97
+ expect(url).toBe(BASE);
98
+ expect(init).toMatchObject({ method: "POST" });
99
+ expect(getJsonBody(init)).toEqual(request);
100
+ expect(res).toEqual(connection);
101
+ });
102
+
103
+ it("updates a connection via PATCH at the id-scoped path", async () => {
104
+ fetchMock.mockResolvedValueOnce(mockJsonResponse(connection));
105
+
106
+ const res = await ProviderConnectionsService.update("conn-1", {
107
+ display_name: "Renamed",
108
+ });
109
+
110
+ const [url, init] = getFetchCall(fetchMock);
111
+ expect(url).toBe(`${BASE}/conn-1`);
112
+ expect(init).toMatchObject({ method: "PATCH" });
113
+ expect(getJsonBody(init)).toEqual({ display_name: "Renamed" });
114
+ expect(res).toEqual(connection);
115
+ });
116
+
117
+ it("url-encodes the id when deleting", async () => {
118
+ fetchMock.mockResolvedValueOnce(mockJsonResponse(connection));
119
+
120
+ await ProviderConnectionsService.delete("a b/c");
121
+
122
+ const [url, init] = getFetchCall(fetchMock);
123
+ expect(url).toBe(`${BASE}/a%20b%2Fc`);
124
+ expect(init).toMatchObject({ method: "DELETE" });
125
+ });
126
+ });
127
+
128
+ // Without an org bound (legacy API keys), the org-scoped route is unaddressable.
129
+ describe("ProviderConnectionsService on a cloud backend with no org", () => {
130
+ beforeEach(() => {
131
+ setActiveSelection({ backendId: cloudBackend.id });
132
+ });
133
+
134
+ it("throws rather than firing an unaddressable request", async () => {
135
+ await expect(ProviderConnectionsService.list()).rejects.toThrow(
136
+ /organization-bound/i,
137
+ );
138
+ expect(fetchMock).not.toHaveBeenCalled();
139
+ });
140
+ });
__tests__/api/cloud/proxy.test.ts ADDED
@@ -0,0 +1,272 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
2
+ import {
3
+ __resetActiveStoreForTests,
4
+ setActiveSelection,
5
+ setRegisteredBackends,
6
+ } from "#/api/backend-registry/active-store";
7
+ import { callCloudProxy } from "#/api/cloud/proxy";
8
+ import type { Backend } from "#/api/backend-registry/types";
9
+
10
+ const cloudPersonal: Backend = {
11
+ id: "cloud-personal",
12
+ name: "Production - Personal",
13
+ host: "https://app.all-hands.dev",
14
+ apiKey: "personal-key",
15
+ kind: "cloud",
16
+ };
17
+
18
+ const cloudAcme: Backend = {
19
+ id: "cloud-acme",
20
+ name: "Production - Acme",
21
+ host: "https://app.all-hands.dev",
22
+ apiKey: "acme-key",
23
+ kind: "cloud",
24
+ };
25
+
26
+ const cookieCloud: Backend = {
27
+ id: "cloud-cookie",
28
+ name: "Production - Cookie",
29
+ host: "https://app.all-hands.dev",
30
+ apiKey: "",
31
+ kind: "cloud",
32
+ authMode: "cookie",
33
+ };
34
+
35
+ const originalFetch = global.fetch;
36
+ const fetchMock = vi.fn();
37
+
38
+ function mockJsonResponse(data: unknown, status = 200): Response {
39
+ return new Response(JSON.stringify(data), {
40
+ status,
41
+ headers: { "content-type": "application/json" },
42
+ });
43
+ }
44
+
45
+ beforeEach(() => {
46
+ window.localStorage.clear();
47
+ __resetActiveStoreForTests();
48
+ fetchMock.mockReset();
49
+ fetchMock.mockResolvedValue(mockJsonResponse({}));
50
+ global.fetch = fetchMock as typeof fetch;
51
+ });
52
+
53
+ afterEach(() => {
54
+ window.localStorage.clear();
55
+ __resetActiveStoreForTests();
56
+ fetchMock.mockReset();
57
+ global.fetch = originalFetch;
58
+ });
59
+
60
+ describe("callCloudProxy X-Org-Id injection", () => {
61
+ it("sends X-Org-Id when targeting the active cloud backend with a selected orgId", async () => {
62
+ // Arrange — active selection points at the cloud backend with a
63
+ // resolved orgId. This is the steady-state case after the user picks
64
+ // an org row in the BackendSelector.
65
+ setRegisteredBackends([cloudPersonal]);
66
+ setActiveSelection({
67
+ backendId: cloudPersonal.id,
68
+ orgId: "org-personal-uuid",
69
+ });
70
+
71
+ // Act
72
+ await callCloudProxy({
73
+ backend: cloudPersonal,
74
+ method: "GET",
75
+ path: "/api/v1/app-conversations/search",
76
+ });
77
+
78
+ // Assert — the request carries the X-Org-Id of the active selection so the
79
+ // cloud backend can scope this request to the user's locally-chosen org
80
+ // without depending on user.current_org_id.
81
+ const [url, init] = fetchMock.mock.calls[0]!;
82
+ expect(url).toBe(`${cloudPersonal.host}/api/v1/app-conversations/search`);
83
+ expect(init).toMatchObject({
84
+ method: "GET",
85
+ });
86
+ expect(
87
+ (init as { headers: Record<string, string> }).headers["X-Org-Id"],
88
+ ).toBe("org-personal-uuid");
89
+ });
90
+
91
+ it("omits X-Org-Id when targeting a different cloud backend than the active one", async () => {
92
+ // Arrange — the BackendSelector fan-out (e.g. useAllCloudOrganizations)
93
+ // calls callCloudProxy(b) for every registered cloud backend. Sending
94
+ // the active backend's orgId across an unrelated API key would cause
95
+ // the cloud backend to 403 on api_key_org_id / X-Org-Id mismatch.
96
+ setRegisteredBackends([cloudPersonal, cloudAcme]);
97
+ setActiveSelection({
98
+ backendId: cloudPersonal.id,
99
+ orgId: "org-personal-uuid",
100
+ });
101
+
102
+ // Act — request targets the non-active backend.
103
+ await callCloudProxy({
104
+ backend: cloudAcme,
105
+ method: "GET",
106
+ path: "/api/keys/current",
107
+ });
108
+
109
+ // Assert
110
+ const [, init] = fetchMock.mock.calls[0]!;
111
+ expect(
112
+ (init as { headers: Record<string, string> }).headers,
113
+ ).not.toHaveProperty("X-Org-Id");
114
+ });
115
+ });
116
+
117
+ describe("callCloudProxy cookie auth", () => {
118
+ it("omits bearer auth for same-origin cookie-backed Cloud requests", async () => {
119
+ setRegisteredBackends([cookieCloud]);
120
+ setActiveSelection({ backendId: cookieCloud.id, orgId: null });
121
+
122
+ await callCloudProxy({
123
+ backend: cookieCloud,
124
+ method: "GET",
125
+ path: "/api/v1/users/me",
126
+ });
127
+
128
+ const [url, init] = fetchMock.mock.calls[0]!;
129
+ expect(url).toBe(`${cookieCloud.host}/api/v1/users/me`);
130
+ expect(init).toMatchObject({ method: "GET" });
131
+ expect(
132
+ (init as { headers: Record<string, string> }).headers,
133
+ ).not.toHaveProperty("Authorization");
134
+ });
135
+
136
+ it("omits bearer auth in runtime proxy envelope requests", async () => {
137
+ setRegisteredBackends([cookieCloud]);
138
+ setActiveSelection({ backendId: cookieCloud.id, orgId: null });
139
+
140
+ await callCloudProxy({
141
+ backend: cookieCloud,
142
+ method: "GET",
143
+ path: "/api/bash/bash_events/search",
144
+ hostOverride: "https://abc123.prod-runtime.all-hands.dev",
145
+ });
146
+
147
+ const [, init] = fetchMock.mock.calls[0]!;
148
+ const envelope = JSON.parse((init as { body: string }).body);
149
+ expect(
150
+ (envelope as { headers: Record<string, string> }).headers,
151
+ ).not.toHaveProperty("Authorization");
152
+ });
153
+ });
154
+
155
+ describe("callCloudProxy automation direct routing", () => {
156
+ it("sends automation requests straight to the cloud host with the API key instead of the /api/cloud-proxy envelope", async () => {
157
+ // Arrange — the automation service grants permissive CORS to API-key
158
+ // requests (automation#185), so app-host automation calls no longer
159
+ // need the same-origin proxy hop through the bundled agent-server.
160
+ setRegisteredBackends([cloudPersonal]);
161
+ setActiveSelection({ backendId: cloudPersonal.id, orgId: null });
162
+ const page = { automations: [], total: 0 };
163
+ fetchMock.mockResolvedValueOnce(mockJsonResponse(page));
164
+
165
+ // Act
166
+ const result = await callCloudProxy({
167
+ backend: cloudPersonal,
168
+ method: "GET",
169
+ path: "/api/automation/v1?limit=50&offset=0",
170
+ });
171
+
172
+ // Assert — the browser calls the automation API on the cloud host
173
+ // directly, authenticated by the backend's API key, and no envelope
174
+ // POST reaches /api/cloud-proxy.
175
+ const [url, init] = fetchMock.mock.calls[0]!;
176
+ expect(url).toBe(
177
+ `${cloudPersonal.host}/api/automation/v1?limit=50&offset=0`,
178
+ );
179
+ expect(init).toMatchObject({
180
+ method: "GET",
181
+ });
182
+ expect(
183
+ (init as { headers: Record<string, string> }).headers.Authorization,
184
+ ).toBe(`Bearer ${cloudPersonal.apiKey}`);
185
+ expect(result).toEqual(page);
186
+ });
187
+
188
+ it("forwards the blob responseType and fail-fast timeout to the direct request", async () => {
189
+ // Arrange — tarball downloads and health probes rely on these
190
+ // per-request options surviving the switch from the proxy envelope to
191
+ // the direct call.
192
+ setRegisteredBackends([cloudPersonal]);
193
+ setActiveSelection({ backendId: cloudPersonal.id, orgId: null });
194
+
195
+ // Act
196
+ await callCloudProxy({
197
+ backend: cloudPersonal,
198
+ method: "GET",
199
+ path: "/api/automation/v1/auto-1/tarball",
200
+ responseType: "blob",
201
+ timeoutSeconds: 5,
202
+ });
203
+
204
+ // Assert
205
+ const [url, init] = fetchMock.mock.calls[0]!;
206
+ expect(url).toBe(`${cloudPersonal.host}/api/automation/v1/auto-1/tarball`);
207
+ expect(init).toMatchObject({ method: "GET" });
208
+ });
209
+ });
210
+
211
+ describe("callCloudProxy hostOverride routing", () => {
212
+ const runtimeHost = "https://abc123.prod-runtime.all-hands.dev";
213
+
214
+ it("routes through the local /api/cloud-proxy instead of the upstream host when hostOverride is set", async () => {
215
+ // Arrange — runtime-sandbox endpoints need the proxy hop because the
216
+ // per-conversation runtime hosts reject browser requests from the
217
+ // local GUI origin.
218
+ setRegisteredBackends([cloudPersonal]);
219
+ setActiveSelection({ backendId: cloudPersonal.id, orgId: null });
220
+ fetchMock.mockResolvedValueOnce(mockJsonResponse({ items: [] }));
221
+
222
+ // Act
223
+ const result = await callCloudProxy({
224
+ backend: cloudPersonal,
225
+ method: "GET",
226
+ path: "/api/bash/bash_events/search",
227
+ hostOverride: runtimeHost,
228
+ });
229
+
230
+ // Assert — the browser only makes a same-origin POST to the bundled
231
+ // agent-server's proxy endpoint carrying the upstream call as an
232
+ // envelope, and the upstream payload is unwrapped for the caller.
233
+ const [url, init] = fetchMock.mock.calls[0]!;
234
+ expect(url).toMatch(/\/api\/cloud-proxy$/);
235
+ const envelope = JSON.parse((init as { body: string }).body);
236
+ expect(envelope).toMatchObject({
237
+ host: runtimeHost,
238
+ method: "GET",
239
+ path: "/api/bash/bash_events/search",
240
+ });
241
+ expect(result).toEqual({ items: [] });
242
+ });
243
+
244
+ it("carries bearer auth and X-Org-Id inside the proxy envelope", async () => {
245
+ // Arrange — org scoping must survive the server-side hop: the envelope
246
+ // headers are what the agent-server attaches to the upstream call in
247
+ // place of the headers a direct browser request would have sent.
248
+ setRegisteredBackends([cloudPersonal]);
249
+ setActiveSelection({
250
+ backendId: cloudPersonal.id,
251
+ orgId: "org-personal-uuid",
252
+ });
253
+
254
+ // Act
255
+ await callCloudProxy({
256
+ backend: cloudPersonal,
257
+ method: "GET",
258
+ path: "/api/bash/bash_events/search",
259
+ hostOverride: runtimeHost,
260
+ });
261
+
262
+ // Assert
263
+ const [, init] = fetchMock.mock.calls[0]!;
264
+ const envelope = JSON.parse((init as { body: string }).body);
265
+ expect(
266
+ (envelope as { headers: Record<string, string> }).headers,
267
+ ).toMatchObject({
268
+ Authorization: `Bearer ${cloudPersonal.apiKey}`,
269
+ "X-Org-Id": "org-personal-uuid",
270
+ });
271
+ });
272
+ });
__tests__/api/cloud/sandbox-service.test.ts ADDED
@@ -0,0 +1,59 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
2
+ import {
3
+ __resetActiveStoreForTests,
4
+ setActiveSelection,
5
+ setRegisteredBackends,
6
+ } from "#/api/backend-registry/active-store";
7
+ import { batchGetCloudSandboxes } from "#/api/cloud/sandbox-service.api";
8
+ import type { Backend } from "#/api/backend-registry/types";
9
+ import { getFetchCall, mockJsonResponse } from "./fetch-test-utils";
10
+
11
+ const cloudBackend: Backend = {
12
+ id: "cloud-prod",
13
+ name: "Production",
14
+ host: "https://app.all-hands.dev",
15
+ apiKey: "bearer-token",
16
+ kind: "cloud",
17
+ };
18
+
19
+ const originalFetch = global.fetch;
20
+ const fetchMock = vi.fn();
21
+
22
+ beforeEach(() => {
23
+ window.localStorage.clear();
24
+ __resetActiveStoreForTests();
25
+ setRegisteredBackends([cloudBackend]);
26
+ setActiveSelection({ backendId: cloudBackend.id });
27
+ fetchMock.mockReset();
28
+ fetchMock.mockResolvedValue(mockJsonResponse([]));
29
+ global.fetch = fetchMock as typeof fetch;
30
+ });
31
+
32
+ afterEach(() => {
33
+ window.localStorage.clear();
34
+ __resetActiveStoreForTests();
35
+ fetchMock.mockReset();
36
+ global.fetch = originalFetch;
37
+ });
38
+
39
+ describe("batchGetCloudSandboxes", () => {
40
+ it("targets /api/v1/sandboxes with one id query param per sandbox id", async () => {
41
+ // Arrange — multiple ids exercises the URLSearchParams.append path,
42
+ // which is the cloud contract for batch-fetching sandboxes (the GUI
43
+ // reads sandbox.exposed_urls from the response to find the VSCODE
44
+ // URL instead of asking the runtime for a localhost address).
45
+ const ids = ["sandbox-a", "sandbox-b"];
46
+
47
+ // Act
48
+ await batchGetCloudSandboxes(ids);
49
+
50
+ const [url, init] = getFetchCall(fetchMock);
51
+ expect(init).toMatchObject({
52
+ method: "GET",
53
+ headers: { Authorization: "Bearer bearer-token" },
54
+ });
55
+ expect(url).toBe(
56
+ `${cloudBackend.host}/api/v1/sandboxes?id=sandbox-a&id=sandbox-b`,
57
+ );
58
+ });
59
+ });
__tests__/api/cloud/secrets-service.test.ts ADDED
@@ -0,0 +1,179 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
2
+ import {
3
+ __resetActiveStoreForTests,
4
+ setActiveSelection,
5
+ setRegisteredBackends,
6
+ } from "#/api/backend-registry/active-store";
7
+ import type { Backend } from "#/api/backend-registry/types";
8
+ import { SecretsService } from "#/api/secrets-service";
9
+ import {
10
+ getFetchCall,
11
+ getJsonBody,
12
+ mockJsonResponse,
13
+ } from "./fetch-test-utils";
14
+
15
+ const cloudBackend: Backend = {
16
+ id: "prod",
17
+ name: "Production",
18
+ host: "https://app.all-hands.dev",
19
+ apiKey: "bearer-token",
20
+ kind: "cloud",
21
+ };
22
+
23
+ const originalFetch = global.fetch;
24
+ const fetchMock = vi.fn();
25
+
26
+ beforeEach(() => {
27
+ window.localStorage.clear();
28
+ __resetActiveStoreForTests();
29
+ setRegisteredBackends([cloudBackend]);
30
+ setActiveSelection({ backendId: cloudBackend.id });
31
+ fetchMock.mockReset();
32
+ fetchMock.mockResolvedValue(mockJsonResponse({}));
33
+ global.fetch = fetchMock as typeof fetch;
34
+ });
35
+
36
+ afterEach(() => {
37
+ window.localStorage.clear();
38
+ __resetActiveStoreForTests();
39
+ fetchMock.mockReset();
40
+ global.fetch = originalFetch;
41
+ });
42
+
43
+ describe("SecretsService against cloud backend", () => {
44
+ it("paginates getSecrets directly and returns the merged list", async () => {
45
+ fetchMock
46
+ .mockResolvedValueOnce(
47
+ mockJsonResponse({
48
+ items: [
49
+ { name: "ALPHA", description: "first" },
50
+ { name: "BETA", description: "second" },
51
+ ],
52
+ next_page_id: "BETA",
53
+ }),
54
+ )
55
+ .mockResolvedValueOnce(
56
+ mockJsonResponse({
57
+ items: [{ name: "GAMMA", description: "third" }],
58
+ next_page_id: null,
59
+ }),
60
+ );
61
+
62
+ const secrets = await SecretsService.getSecrets();
63
+
64
+ expect(fetchMock).toHaveBeenCalledTimes(2);
65
+
66
+ const [firstUrl, firstInit] = getFetchCall(fetchMock, 0);
67
+ expect(firstInit).toMatchObject({
68
+ method: "GET",
69
+ headers: { Authorization: "Bearer bearer-token" },
70
+ });
71
+ expect(firstUrl).toMatch(
72
+ /^https:\/\/app\.all-hands\.dev\/api\/v1\/secrets\/search\?/,
73
+ );
74
+ expect(firstUrl).not.toContain("page_id=");
75
+
76
+ const [secondUrl] = getFetchCall(fetchMock, 1);
77
+ expect(secondUrl).toContain("page_id=BETA");
78
+
79
+ expect(secrets.map((s) => s.name)).toEqual(["ALPHA", "BETA", "GAMMA"]);
80
+ });
81
+
82
+ it("creates a secret via direct POST /api/v1/secrets", async () => {
83
+ await SecretsService.createSecret(
84
+ "OPENAI_API_KEY",
85
+ "sk-test",
86
+ "OpenAI key",
87
+ );
88
+
89
+ const [url, init] = getFetchCall(fetchMock);
90
+ expect(url).toBe(`${cloudBackend.host}/api/v1/secrets`);
91
+ expect(init).toMatchObject({
92
+ method: "POST",
93
+ headers: { Authorization: "Bearer bearer-token" },
94
+ });
95
+ expect(getJsonBody(init)).toEqual({
96
+ name: "OPENAI_API_KEY",
97
+ value: "sk-test",
98
+ description: "OpenAI key",
99
+ });
100
+ });
101
+
102
+ it("updates a secret via PUT /api/v1/secrets/{id} with name + description only", async () => {
103
+ // The form/hook calls updateSecret(secretToEdit, newName, description).
104
+ await SecretsService.updateSecret("OLD_NAME", "NEW_NAME", "renamed");
105
+
106
+ const [url, init] = getFetchCall(fetchMock);
107
+ expect(url).toBe(`${cloudBackend.host}/api/v1/secrets/OLD_NAME`);
108
+ expect(init).toMatchObject({
109
+ method: "PUT",
110
+ headers: { Authorization: "Bearer bearer-token" },
111
+ });
112
+ expect(getJsonBody(init)).toEqual({
113
+ name: "NEW_NAME",
114
+ description: "renamed",
115
+ });
116
+ // No value supplied, so nothing overwrites the stored one.
117
+ expect(fetchMock).toHaveBeenCalledTimes(1);
118
+ });
119
+
120
+ it("overwrites the value with a POST following the PUT", async () => {
121
+ // Fresh Response per call: a Response body can only be consumed once.
122
+ fetchMock.mockImplementation(() => Promise.resolve(mockJsonResponse({})));
123
+
124
+ await SecretsService.updateSecret(
125
+ "API_KEY",
126
+ "API_KEY",
127
+ "Demo secret",
128
+ "new-value",
129
+ );
130
+
131
+ expect(fetchMock).toHaveBeenCalledTimes(2);
132
+
133
+ const [putUrl, putInit] = getFetchCall(fetchMock, 0);
134
+ expect(putUrl).toBe(`${cloudBackend.host}/api/v1/secrets/API_KEY`);
135
+ expect(putInit).toMatchObject({ method: "PUT" });
136
+
137
+ const [postUrl, postInit] = getFetchCall(fetchMock, 1);
138
+ expect(postUrl).toBe(`${cloudBackend.host}/api/v1/secrets`);
139
+ expect(postInit).toMatchObject({ method: "POST" });
140
+ expect(getJsonBody(postInit)).toEqual({
141
+ name: "API_KEY",
142
+ value: "new-value",
143
+ description: "Demo secret",
144
+ });
145
+ });
146
+
147
+ it("deletes a secret via direct DELETE /api/v1/secrets/{id}", async () => {
148
+ await SecretsService.deleteSecret("token with space");
149
+
150
+ const [url, init] = getFetchCall(fetchMock);
151
+ expect(url).toBe(
152
+ `${cloudBackend.host}/api/v1/secrets/token%20with%20space`,
153
+ );
154
+ expect(init).toMatchObject({
155
+ method: "DELETE",
156
+ headers: { Authorization: "Bearer bearer-token" },
157
+ });
158
+ });
159
+
160
+ it("treats a delete 404 as success (secret already gone)", async () => {
161
+ // Fresh Response per attempt: the retry helper re-fetches and a
162
+ // Response body can only be consumed once. Fake timers skip the
163
+ // retry backoff sleeps.
164
+ fetchMock.mockImplementation(() =>
165
+ Promise.resolve(mockJsonResponse({ detail: "Secret not found" }, 404)),
166
+ );
167
+ vi.useFakeTimers();
168
+
169
+ try {
170
+ const assertion = expect(
171
+ SecretsService.deleteSecret("ALREADY_GONE"),
172
+ ).resolves.toBeUndefined();
173
+ await vi.runAllTimersAsync();
174
+ await assertion;
175
+ } finally {
176
+ vi.useRealTimers();
177
+ }
178
+ });
179
+ });
__tests__/api/cloud/settings-service.test.ts ADDED
@@ -0,0 +1,200 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
2
+ import {
3
+ __resetActiveStoreForTests,
4
+ setActiveSelection,
5
+ setRegisteredBackends,
6
+ } from "#/api/backend-registry/active-store";
7
+ import type { Backend } from "#/api/backend-registry/types";
8
+ import {
9
+ fetchCloudSettings,
10
+ saveCloudSettings,
11
+ } from "#/api/cloud/settings-service.api";
12
+ import SettingsService from "#/api/settings-service/settings-service.api";
13
+ import {
14
+ getFetchCall,
15
+ getJsonBody,
16
+ mockJsonResponse,
17
+ } from "./fetch-test-utils";
18
+
19
+ const cloudBackend: Backend = {
20
+ id: "prod",
21
+ name: "Production",
22
+ host: "https://app.all-hands.dev",
23
+ apiKey: "bearer-token",
24
+ kind: "cloud",
25
+ };
26
+
27
+ const originalFetch = global.fetch;
28
+ const fetchMock = vi.fn();
29
+
30
+ beforeEach(() => {
31
+ window.localStorage.clear();
32
+ __resetActiveStoreForTests();
33
+ setRegisteredBackends([cloudBackend]);
34
+ setActiveSelection({ backendId: cloudBackend.id });
35
+ fetchMock.mockReset();
36
+ fetchMock.mockResolvedValue(mockJsonResponse({}));
37
+ global.fetch = fetchMock as typeof fetch;
38
+ });
39
+
40
+ afterEach(() => {
41
+ window.localStorage.clear();
42
+ __resetActiveStoreForTests();
43
+ fetchMock.mockReset();
44
+ global.fetch = originalFetch;
45
+ });
46
+
47
+ describe("cloud settings", () => {
48
+ it("fetchCloudSettings preserves provider_tokens_set so the repo chain can fire", async () => {
49
+ fetchMock.mockResolvedValueOnce(
50
+ mockJsonResponse({
51
+ llm_model: "anthropic/claude-3-5-sonnet",
52
+ llm_base_url: "https://api.anthropic.com",
53
+ llm_api_key_set: true,
54
+ agent: "CodeActAgent",
55
+ confirmation_mode: true,
56
+ security_analyzer: "llm",
57
+ max_iterations: 30,
58
+ provider_tokens_set: { github: "***" },
59
+ }),
60
+ );
61
+
62
+ const result = await fetchCloudSettings();
63
+
64
+ const [url, init] = getFetchCall(fetchMock);
65
+ expect(url).toBe(`${cloudBackend.host}/api/v1/settings`);
66
+ expect(init).toMatchObject({
67
+ method: "GET",
68
+ headers: { Authorization: "Bearer bearer-token" },
69
+ });
70
+
71
+ // provider_tokens_set must round-trip — it's what drives
72
+ // useUserProviders → useAppInstallations → useGitRepositories.
73
+ expect(result.provider_tokens_set).toEqual({ github: "***" });
74
+
75
+ // Top-level cloud fields are preserved as-is.
76
+ expect(result.llm_model).toBe("anthropic/claude-3-5-sonnet");
77
+ expect(result.llm_api_key_set).toBe(true);
78
+ expect(result.agent).toBe("CodeActAgent");
79
+
80
+ // Nested shape derived for the local-mode settings page.
81
+ expect(result.agent_settings?.agent).toBe("CodeActAgent");
82
+ expect(result.agent_settings?.llm).toEqual({
83
+ model: "anthropic/claude-3-5-sonnet",
84
+ base_url: "https://api.anthropic.com",
85
+ });
86
+ expect(result.conversation_settings?.confirmation_mode).toBe(true);
87
+ expect(result.conversation_settings?.security_analyzer).toBe("llm");
88
+ expect(result.conversation_settings?.max_iterations).toBe(30);
89
+ });
90
+
91
+ it("saveCloudSettings forwards diffs verbatim and omits the legacy keys the cloud rejects", async () => {
92
+ const agentDiff = {
93
+ llm: { model: "openai/gpt-4o", base_url: "https://api.openai.com" },
94
+ agent: "CodeActAgent",
95
+ };
96
+ const conversationDiff = { max_iterations: 50 };
97
+
98
+ await saveCloudSettings({
99
+ agent_settings_diff: agentDiff,
100
+ conversation_settings_diff: conversationDiff,
101
+ });
102
+
103
+ const [url, init] = getFetchCall(fetchMock);
104
+ expect(url).toBe(`${cloudBackend.host}/api/v1/settings`);
105
+ expect(init).toMatchObject({
106
+ method: "POST",
107
+ headers: { Authorization: "Bearer bearer-token" },
108
+ });
109
+ const requestBody = getJsonBody(init);
110
+ expect(requestBody).toEqual({
111
+ agent_settings_diff: agentDiff,
112
+ conversation_settings_diff: conversationDiff,
113
+ });
114
+ expect(requestBody).not.toHaveProperty("agent_settings");
115
+ expect(requestBody).not.toHaveProperty("conversation_settings");
116
+ });
117
+
118
+ it("SettingsService.saveSettings forwards disabled_skills to cloud when active backend is cloud", async () => {
119
+ // Act: save a skills-only update — previously this short-circuited and
120
+ // sent nothing at all, leaving the toggle un-persisted.
121
+ await SettingsService.saveSettings({
122
+ disabled_skills: ["SSH Microagent"],
123
+ });
124
+
125
+ // Assert: a single POST /api/v1/settings reached the wire with
126
+ // disabled_skills as a top-level field.
127
+ expect(fetchMock).toHaveBeenCalledTimes(1);
128
+ const [url, init] = getFetchCall(fetchMock);
129
+ expect(url).toBe(`${cloudBackend.host}/api/v1/settings`);
130
+ expect(init).toMatchObject({
131
+ method: "POST",
132
+ headers: { Authorization: "Bearer bearer-token" },
133
+ });
134
+ expect(getJsonBody(init)).toEqual({
135
+ disabled_skills: ["SSH Microagent"],
136
+ });
137
+ });
138
+
139
+ it("saveCloudSettings omits an empty conversation_settings_diff (LLM-only save)", async () => {
140
+ await saveCloudSettings({
141
+ agent_settings_diff: {
142
+ llm: { model: "anthropic/claude-sonnet-4-20250514" },
143
+ },
144
+ conversation_settings_diff: {},
145
+ });
146
+
147
+ const [, init] = getFetchCall(fetchMock);
148
+ const requestBody = getJsonBody(init);
149
+ expect(requestBody).toEqual({
150
+ agent_settings_diff: {
151
+ llm: { model: "anthropic/claude-sonnet-4-20250514" },
152
+ },
153
+ });
154
+ });
155
+ });
156
+
157
+ describe("saveCloudSettings drops agent_context: null (agent-canvas#981)", () => {
158
+ it("strips a null agent_context while preserving sibling agent settings", async () => {
159
+ // Act
160
+ await saveCloudSettings({
161
+ agent_settings_diff: {
162
+ llm: { model: "anthropic/claude-sonnet-4-20250514" },
163
+ agent_context: null,
164
+ },
165
+ });
166
+
167
+ // Assert: agent_context never reaches the wire, but the real llm change does.
168
+ const [, init] = getFetchCall(fetchMock);
169
+ const requestBody = getJsonBody(init);
170
+ expect(requestBody).toEqual({
171
+ agent_settings_diff: {
172
+ llm: { model: "anthropic/claude-sonnet-4-20250514" },
173
+ },
174
+ });
175
+ });
176
+
177
+ it("preserves a null mcp_config so clearing MCP servers still round-trips", async () => {
178
+ // Act
179
+ await saveCloudSettings({
180
+ agent_settings_diff: { mcp_config: null },
181
+ });
182
+
183
+ // Assert: the null mcp_config must survive (don't over-strip nulls).
184
+ const [, init] = getFetchCall(fetchMock);
185
+ const requestBody = getJsonBody(init);
186
+ expect(requestBody).toEqual({ agent_settings_diff: { mcp_config: null } });
187
+ });
188
+
189
+ it("omits agent_settings_diff when agent_context: null is its only key", async () => {
190
+ // Act
191
+ await saveCloudSettings({
192
+ agent_settings_diff: { agent_context: null },
193
+ });
194
+
195
+ // Assert: nothing is left to send, so no agent_settings_diff goes on the wire.
196
+ const [, init] = getFetchCall(fetchMock);
197
+ const requestBody = getJsonBody(init);
198
+ expect(requestBody).toEqual({});
199
+ });
200
+ });
__tests__/api/cloud/skills-service.test.ts ADDED
@@ -0,0 +1,136 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
2
+ import {
3
+ __resetActiveStoreForTests,
4
+ setActiveSelection,
5
+ setRegisteredBackends,
6
+ } from "#/api/backend-registry/active-store";
7
+ import type { Backend } from "#/api/backend-registry/types";
8
+ import SkillsService from "#/api/skills-service";
9
+ import { getFetchCall, mockJsonResponse } from "./fetch-test-utils";
10
+
11
+ const cloudBackend: Backend = {
12
+ id: "prod",
13
+ name: "Production",
14
+ host: "https://app.all-hands.dev",
15
+ apiKey: "bearer-token",
16
+ kind: "cloud",
17
+ };
18
+
19
+ const originalFetch = global.fetch;
20
+ const fetchMock = vi.fn();
21
+
22
+ beforeEach(() => {
23
+ window.localStorage.clear();
24
+ __resetActiveStoreForTests();
25
+ setRegisteredBackends([cloudBackend]);
26
+ setActiveSelection({ backendId: cloudBackend.id });
27
+ fetchMock.mockReset();
28
+ global.fetch = fetchMock as typeof fetch;
29
+ });
30
+
31
+ afterEach(() => {
32
+ window.localStorage.clear();
33
+ __resetActiveStoreForTests();
34
+ fetchMock.mockReset();
35
+ global.fetch = originalFetch;
36
+ });
37
+
38
+ describe("SkillsService.getSkills against cloud backend", () => {
39
+ it("paginates /api/v1/skills/search directly and returns the merged list", async () => {
40
+ fetchMock
41
+ .mockResolvedValueOnce(
42
+ mockJsonResponse({
43
+ items: [
44
+ { name: "alpha", type: "knowledge", source: "global" },
45
+ {
46
+ name: "beta",
47
+ type: "task",
48
+ source: "user",
49
+ triggers: ["foo"],
50
+ },
51
+ ],
52
+ next_page_id: "beta",
53
+ }),
54
+ )
55
+ .mockResolvedValueOnce(
56
+ mockJsonResponse({
57
+ items: [{ name: "gamma", type: "knowledge", source: "user" }],
58
+ next_page_id: null,
59
+ }),
60
+ );
61
+
62
+ const skills = await SkillsService.getSkills();
63
+
64
+ expect(fetchMock).toHaveBeenCalledTimes(2);
65
+
66
+ const [firstUrl, firstInit] = getFetchCall(fetchMock, 0);
67
+ expect(firstInit).toMatchObject({
68
+ method: "GET",
69
+ headers: { Authorization: "Bearer bearer-token" },
70
+ });
71
+ expect(firstUrl).toMatch(
72
+ /^https:\/\/app\.all-hands\.dev\/api\/v1\/skills\/search\?/,
73
+ );
74
+ expect(firstUrl).not.toContain("page_id=");
75
+
76
+ const [secondUrl] = getFetchCall(fetchMock, 1);
77
+ expect(secondUrl).toContain("page_id=beta");
78
+
79
+ expect(skills.map((s) => s.name)).toEqual(["alpha", "beta", "gamma"]);
80
+ expect(skills[1]).toMatchObject({ triggers: ["foo"] });
81
+ });
82
+ });
83
+
84
+ describe("SkillsService.getConversationSkills against cloud backend", () => {
85
+ it("reads the conversation's own skills route and maps entries to SkillInfo", async () => {
86
+ // Arrange
87
+ fetchMock.mockResolvedValueOnce(
88
+ mockJsonResponse({
89
+ skills: [
90
+ {
91
+ name: "release-notes",
92
+ type: "agentskills",
93
+ content: "# Release notes",
94
+ triggers: ["/release-notes"],
95
+ },
96
+ {
97
+ name: "repo",
98
+ type: "repo",
99
+ content: "Repository instructions",
100
+ triggers: [],
101
+ },
102
+ ],
103
+ }),
104
+ );
105
+
106
+ // Act
107
+ const skills = await SkillsService.getConversationSkills("conv-1");
108
+
109
+ // Assert
110
+ expect(fetchMock).toHaveBeenCalledTimes(1);
111
+ const [url, init] = getFetchCall(fetchMock, 0);
112
+ expect(url).toBe(
113
+ "https://app.all-hands.dev/api/v1/app-conversations/conv-1/skills",
114
+ );
115
+ expect(init).toMatchObject({
116
+ method: "GET",
117
+ headers: { Authorization: "Bearer bearer-token" },
118
+ });
119
+ expect(skills).toEqual([
120
+ {
121
+ name: "release-notes",
122
+ type: "agentskills",
123
+ content: "# Release notes",
124
+ triggers: ["/release-notes"],
125
+ source: null,
126
+ },
127
+ {
128
+ name: "repo",
129
+ type: "repo",
130
+ content: "Repository instructions",
131
+ triggers: [],
132
+ source: null,
133
+ },
134
+ ]);
135
+ });
136
+ });
__tests__/api/mcp-health/mcp-health-store.test.ts ADDED
@@ -0,0 +1,64 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import { beforeEach, describe, expect, it } from "vitest";
2
+ import {
3
+ __resetMcpHealthStoreForTests,
4
+ beginMcpHealthCheck,
5
+ clearMcpServerHealth,
6
+ getMcpHealthSnapshot,
7
+ resolveMcpHealthCheck,
8
+ } from "#/api/mcp-health/mcp-health-store";
9
+ import type { McpServerHealth } from "#/types/mcp-health";
10
+
11
+ const HEALTHY: McpServerHealth = {
12
+ status: "healthy",
13
+ verification: "verified",
14
+ toolCount: 3,
15
+ checkedAt: 1,
16
+ };
17
+
18
+ const FAILED: McpServerHealth = {
19
+ status: "failed",
20
+ kind: "connection",
21
+ error: "refused",
22
+ checkedAt: 2,
23
+ };
24
+
25
+ describe("mcp-health-store", () => {
26
+ beforeEach(() => {
27
+ __resetMcpHealthStoreForTests();
28
+ });
29
+
30
+ it("marks a check as in flight and commits its result", () => {
31
+ const checkId = beginMcpHealthCheck("key");
32
+ expect(getMcpHealthSnapshot().key).toEqual({ status: "checking", checkId });
33
+
34
+ resolveMcpHealthCheck("key", checkId, HEALTHY);
35
+
36
+ expect(getMcpHealthSnapshot().key).toEqual(HEALTHY);
37
+ });
38
+
39
+ it("drops a stale result that was superseded by a newer check", () => {
40
+ const staleId = beginMcpHealthCheck("key");
41
+ const freshId = beginMcpHealthCheck("key");
42
+
43
+ // The slow older probe must not overwrite the newer probe's lifecycle.
44
+ resolveMcpHealthCheck("key", staleId, HEALTHY);
45
+ expect(getMcpHealthSnapshot().key).toEqual({
46
+ status: "checking",
47
+ checkId: freshId,
48
+ });
49
+
50
+ resolveMcpHealthCheck("key", freshId, FAILED);
51
+ expect(getMcpHealthSnapshot().key).toEqual(FAILED);
52
+ });
53
+
54
+ it("drops a result whose entry was cleared mid-flight", () => {
55
+ // e.g. the server was edited or deleted while its probe was running —
56
+ // the late result must not resurrect a verdict for a gone config.
57
+ const checkId = beginMcpHealthCheck("key");
58
+ clearMcpServerHealth("key");
59
+
60
+ resolveMcpHealthCheck("key", checkId, HEALTHY);
61
+
62
+ expect(getMcpHealthSnapshot().key).toBeUndefined();
63
+ });
64
+ });
__tests__/api/mcp-health/probe-mcp-server-health.test.ts ADDED
@@ -0,0 +1,179 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import { beforeEach, describe, expect, it, vi } from "vitest";
2
+ import {
3
+ __resetMcpHealthStoreForTests,
4
+ getMcpHealthSnapshot,
5
+ setMcpServerHealth,
6
+ } from "#/api/mcp-health/mcp-health-store";
7
+ import {
8
+ interpretMcpTestResponse,
9
+ probeMcpServerHealth,
10
+ seedMcpServerHealth,
11
+ } from "#/api/mcp-health/probe-mcp-server-health";
12
+ import McpService from "#/api/mcp-service/mcp-service.api";
13
+ import type { ExtendedMCPTestResponse, MCPServerConfig } from "#/types/mcp-server";
14
+ import { getMcpServerHealthKey } from "#/utils/mcp-server-health-key";
15
+
16
+ /** Matches the catalog `github` entry, so the `get_me` probe spec applies. */
17
+ const GITHUB: MCPServerConfig = {
18
+ id: "shttp-0",
19
+ type: "shttp",
20
+ name: "github",
21
+ url: "https://api.githubcopilot.com/mcp/",
22
+ auth: { strategy: "api_key", value: "github_pat_x" },
23
+ };
24
+
25
+ /** Matches no catalog entry, so no probe spec exists. */
26
+ const CUSTOM: MCPServerConfig = {
27
+ id: "shttp-1",
28
+ type: "shttp",
29
+ name: "custom",
30
+ url: "https://mcp.example.com/mcp",
31
+ };
32
+
33
+ describe("interpretMcpTestResponse", () => {
34
+ it("passes a non-auth connection failure through with its kind", () => {
35
+ const health = interpretMcpTestResponse(CUSTOM, {
36
+ ok: false,
37
+ error: "connection refused",
38
+ error_kind: "connection",
39
+ });
40
+
41
+ expect(health).toMatchObject({
42
+ status: "failed",
43
+ kind: "connection",
44
+ error: "connection refused",
45
+ });
46
+ });
47
+
48
+ it("reclassifies a connection failure with auth-rejection text as a credentials failure", () => {
49
+ // Hosted servers reject bad tokens at the HTTP handshake, which the
50
+ // backend can only report as a connection failure.
51
+ const health = interpretMcpTestResponse(GITHUB, {
52
+ ok: false,
53
+ error: "Client error '401 Unauthorized' for url …",
54
+ error_kind: "connection",
55
+ });
56
+
57
+ expect(health).toMatchObject({ status: "failed", kind: "credentials" });
58
+ });
59
+
60
+ it("reports verified health when the read-only probe tool ran cleanly", () => {
61
+ const health = interpretMcpTestResponse(GITHUB, {
62
+ ok: true,
63
+ tools: ["get_me", "search_code"],
64
+ tool_result: { is_error: false, text: '{"login":"octocat"}' },
65
+ });
66
+
67
+ expect(health).toMatchObject({
68
+ status: "healthy",
69
+ verification: "verified",
70
+ toolCount: 2,
71
+ });
72
+ });
73
+
74
+ it("downgrades to connectivity-only when the probe tool is not advertised", () => {
75
+ const health = interpretMcpTestResponse(GITHUB, {
76
+ ok: true,
77
+ tools: ["search_code"],
78
+ tool_result: {
79
+ is_error: true,
80
+ text: "Tool 'get_me' not advertised by server",
81
+ },
82
+ });
83
+
84
+ expect(health).toMatchObject({
85
+ status: "healthy",
86
+ verification: "connectivity-only",
87
+ });
88
+ });
89
+
90
+ it("reports connectivity-only for servers without a probe spec", () => {
91
+ const health = interpretMcpTestResponse(CUSTOM, {
92
+ ok: true,
93
+ tools: ["a"],
94
+ });
95
+
96
+ expect(health).toMatchObject({
97
+ status: "healthy",
98
+ verification: "connectivity-only",
99
+ toolCount: 1,
100
+ });
101
+ });
102
+ });
103
+
104
+ describe("probeMcpServerHealth", () => {
105
+ beforeEach(() => {
106
+ __resetMcpHealthStoreForTests();
107
+ vi.restoreAllMocks();
108
+ });
109
+
110
+ it("publishes checking while the probe runs, then the interpreted result", async () => {
111
+ let resolveProbe!: (value: ExtendedMCPTestResponse) => void;
112
+ vi.spyOn(McpService, "testServer").mockReturnValue(
113
+ new Promise((resolve) => {
114
+ resolveProbe = resolve;
115
+ }),
116
+ );
117
+ const key = getMcpServerHealthKey(CUSTOM);
118
+
119
+ const probe = probeMcpServerHealth(CUSTOM);
120
+ expect(getMcpHealthSnapshot()[key]).toMatchObject({ status: "checking" });
121
+
122
+ resolveProbe({ ok: true, tools: ["a", "b"] });
123
+ await probe;
124
+
125
+ expect(getMcpHealthSnapshot()[key]).toMatchObject({
126
+ status: "healthy",
127
+ verification: "connectivity-only",
128
+ toolCount: 2,
129
+ });
130
+ });
131
+
132
+ it("converts a thrown transport error into a failed verdict", async () => {
133
+ vi.spyOn(McpService, "testServer").mockRejectedValue(
134
+ new Error("network down"),
135
+ );
136
+
137
+ await probeMcpServerHealth(CUSTOM);
138
+
139
+ expect(getMcpHealthSnapshot()[getMcpServerHealthKey(CUSTOM)]).toMatchObject(
140
+ { status: "failed", kind: "unknown", error: "network down" },
141
+ );
142
+ });
143
+ });
144
+
145
+ describe("seedMcpServerHealth", () => {
146
+ beforeEach(() => {
147
+ __resetMcpHealthStoreForTests();
148
+ });
149
+
150
+ it("publishes the saved server's health from its pre-save test result", () => {
151
+ seedMcpServerHealth(CUSTOM, { ok: true, tools: ["a"] }, []);
152
+
153
+ expect(getMcpHealthSnapshot()[getMcpServerHealthKey(CUSTOM)]).toMatchObject(
154
+ { status: "healthy" },
155
+ );
156
+ });
157
+
158
+ it("skips seeding when another installed server shares the health key", () => {
159
+ // A second install of the same catalog entry collides on the key until
160
+ // the save suffixes its name; it must not overwrite the first card.
161
+ const existingVerdict = {
162
+ status: "failed",
163
+ kind: "credentials",
164
+ error: "bad token",
165
+ checkedAt: 1,
166
+ } as const;
167
+ setMcpServerHealth(getMcpServerHealthKey(GITHUB), existingVerdict);
168
+
169
+ seedMcpServerHealth(
170
+ { ...GITHUB, id: "shttp-9" },
171
+ { ok: true, tools: ["get_me"] },
172
+ [GITHUB],
173
+ );
174
+
175
+ expect(getMcpHealthSnapshot()[getMcpServerHealthKey(GITHUB)]).toEqual(
176
+ existingVerdict,
177
+ );
178
+ });
179
+ });
__tests__/api/mcp-service/mcp-service.api.test.ts ADDED
@@ -0,0 +1,395 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import { describe, it, expect, vi, beforeEach } from "vitest";
2
+ import McpService from "#/api/mcp-service/mcp-service.api";
3
+ import SettingsService, {
4
+ type SettingsApiResponse,
5
+ } from "#/api/settings-service/settings-service.api";
6
+ import * as activeStore from "#/api/backend-registry/active-store";
7
+ import type { MCPServerConfig } from "#/types/mcp-server";
8
+ import { REDACTED_MCP_SECRET_VALUE } from "#/utils/mcp-config";
9
+
10
+ // vi.mock factories are hoisted before imports, so spy functions must be
11
+ // created with vi.hoisted() to be in scope inside the factory.
12
+ const { mockTestServer } = vi.hoisted(() => ({
13
+ mockTestServer: vi.fn(),
14
+ }));
15
+
16
+ vi.mock("@openhands/typescript-client/clients", () => ({
17
+ // Real class so `new MCPClient(...)` works; testServer delegates to the
18
+ // shared spy so each test can configure the return value independently.
19
+ MCPClient: class {
20
+ // eslint-disable-next-line class-methods-use-this
21
+ testServer = mockTestServer;
22
+
23
+ // eslint-disable-next-line class-methods-use-this
24
+ close = vi.fn();
25
+ },
26
+ }));
27
+
28
+ vi.mock("#/api/agent-server-client-options", () => ({
29
+ getAgentServerClientOptions: () => ({
30
+ host: "http://localhost:3000",
31
+ apiKey: "test-key",
32
+ }),
33
+ }));
34
+
35
+ vi.mock("#/api/backend-registry/active-store", () => ({
36
+ getActiveBackend: vi.fn(),
37
+ }));
38
+
39
+ const mockGetActiveBackend = vi.mocked(activeStore.getActiveBackend);
40
+
41
+ const localActive = () =>
42
+ mockGetActiveBackend.mockReturnValue({
43
+ backend: {
44
+ id: "local-1",
45
+ name: "Local",
46
+ host: "http://localhost:3000",
47
+ apiKey: "test-key",
48
+ kind: "local",
49
+ },
50
+ orgId: null,
51
+ });
52
+
53
+ const cloudActive = () =>
54
+ mockGetActiveBackend.mockReturnValue({
55
+ backend: {
56
+ id: "cloud-1",
57
+ name: "Cloud",
58
+ host: "https://app.all-hands.dev",
59
+ apiKey: "cloud-key",
60
+ kind: "cloud",
61
+ },
62
+ orgId: null,
63
+ });
64
+
65
+ const SERVER: MCPServerConfig = {
66
+ id: "shttp-1",
67
+ type: "shttp",
68
+ url: "https://mcp.example.com/mcp",
69
+ };
70
+
71
+ describe("McpService.testServer", () => {
72
+ beforeEach(() => {
73
+ vi.clearAllMocks();
74
+ localActive();
75
+ });
76
+
77
+ it("passes success responses through unchanged", async () => {
78
+ mockTestServer.mockResolvedValue({ ok: true, tools: ["search", "fetch"] });
79
+
80
+ const result = await McpService.testServer(SERVER);
81
+
82
+ expect(result).toEqual({ ok: true, tools: ["search", "fetch"] });
83
+ });
84
+
85
+ it("passes failure responses through unchanged (no server-side escaping)", async () => {
86
+ // The backend returns plain text; HTML-escaping of {{-error}} is handled
87
+ // by the i18next no-escape prefix in the translation string, not here.
88
+ mockTestServer.mockResolvedValue({
89
+ ok: false,
90
+ error:
91
+ "Client error '401 Unauthorized' for url https://mcp.example.com/mcp",
92
+ error_kind: "unknown",
93
+ });
94
+
95
+ const result = await McpService.testServer(SERVER);
96
+
97
+ expect(result).toEqual({
98
+ ok: false,
99
+ error:
100
+ "Client error '401 Unauthorized' for url https://mcp.example.com/mcp",
101
+ error_kind: "unknown",
102
+ });
103
+ });
104
+
105
+ it("maps a stdio config to a StdioMCPServerSpec", async () => {
106
+ mockTestServer.mockResolvedValue({ ok: true, tools: [] });
107
+ const stdio: MCPServerConfig = {
108
+ id: "my-server",
109
+ type: "stdio",
110
+ name: "my-server",
111
+ command: "npx",
112
+ args: ["-y", "@my/mcp-server"],
113
+ env: { API_KEY: "secret" },
114
+ };
115
+
116
+ await McpService.testServer(stdio);
117
+
118
+ // Exact match also guards that non-catalog servers get no `tool_call`.
119
+ expect(mockTestServer).toHaveBeenCalledWith({
120
+ server: {
121
+ type: "stdio",
122
+ command: "npx",
123
+ args: ["-y", "@my/mcp-server"],
124
+ env: { API_KEY: "secret" },
125
+ },
126
+ name: "my-server",
127
+ });
128
+ });
129
+
130
+ // -------------------------------------------------------------------------
131
+ // Credential verification for marketplace servers (Slack)
132
+ //
133
+ // The Slack MCP server lists its tools with any credentials and reports
134
+ // upstream auth failures as ordinary text content, so the service attaches
135
+ // a read-only verification tool call and interprets its payload.
136
+ // -------------------------------------------------------------------------
137
+
138
+ const SLACK_SERVER: MCPServerConfig = {
139
+ id: "slack",
140
+ type: "stdio",
141
+ name: "slack",
142
+ command: "npx",
143
+ args: ["-y", "@zencoderai/slack-mcp-server"],
144
+ env: { SLACK_TEAM_ID: "T01", SLACK_BOT_TOKEN: "xoxb-abc" },
145
+ };
146
+
147
+ const slackToolResult = (text: string, isError = false) => ({
148
+ ok: true,
149
+ tools: ["slack_list_channels"],
150
+ tool_result: { is_error: isError, text },
151
+ });
152
+
153
+ it("attaches the read-only Slack verification tool call to the request", async () => {
154
+ mockTestServer.mockResolvedValue({ ok: true, tools: [] });
155
+
156
+ await McpService.testServer(SLACK_SERVER);
157
+
158
+ expect(mockTestServer).toHaveBeenCalledWith(
159
+ expect.objectContaining({
160
+ tool_call: { name: "slack_list_channels", arguments: { limit: 1 } },
161
+ }),
162
+ );
163
+ });
164
+
165
+ it("maps an in-band Slack auth error to a credentials failure", async () => {
166
+ mockTestServer.mockResolvedValue(
167
+ slackToolResult('{"ok":false,"error":"invalid_auth"}'),
168
+ );
169
+
170
+ const result = await McpService.testServer(SLACK_SERVER);
171
+
172
+ expect(result).toEqual({
173
+ ok: false,
174
+ error: "invalid_auth",
175
+ error_kind: "credentials",
176
+ });
177
+ });
178
+
179
+ it("does not flag non-auth Slack errors (missing_scope) as bad credentials", async () => {
180
+ // A valid token lacking a scope authenticated successfully — failing it
181
+ // would block correctly-configured installs.
182
+ const response = slackToolResult('{"ok":false,"error":"missing_scope"}');
183
+ mockTestServer.mockResolvedValue(response);
184
+
185
+ const result = await McpService.testServer(SLACK_SERVER);
186
+
187
+ expect(result).toEqual(response);
188
+ });
189
+
190
+ it("passes a succeeding Slack payload through unchanged", async () => {
191
+ const response = slackToolResult('{"ok":true,"channels":[]}');
192
+ mockTestServer.mockResolvedValue(response);
193
+
194
+ const result = await McpService.testServer(SLACK_SERVER);
195
+
196
+ expect(result).toEqual(response);
197
+ });
198
+
199
+ it("maps an errored verification call to a credentials failure", async () => {
200
+ mockTestServer.mockResolvedValue(
201
+ slackToolResult("Tool 'slack_list_channels' call timed out", true),
202
+ );
203
+
204
+ const result = await McpService.testServer(SLACK_SERVER);
205
+
206
+ expect(result).toEqual({
207
+ ok: false,
208
+ error: "Tool 'slack_list_channels' call timed out",
209
+ error_kind: "credentials",
210
+ });
211
+ });
212
+
213
+ it("returns the response unchanged when an older backend omits tool_result", async () => {
214
+ mockTestServer.mockResolvedValue({ ok: true, tools: ["a", "b"] });
215
+
216
+ const result = await McpService.testServer(SLACK_SERVER);
217
+
218
+ expect(result).toEqual({ ok: true, tools: ["a", "b"] });
219
+ });
220
+
221
+ it("skips credential interpretation when the probe tool is not advertised", async () => {
222
+ // A server variant that doesn't expose the probe tool (e.g. Slack's
223
+ // hosted MCP) returns a deterministic "not advertised" tool error; that
224
+ // proves nothing about credentials and must not block the install.
225
+ const response = {
226
+ ok: true,
227
+ tools: ["conversations_history"],
228
+ tool_result: {
229
+ is_error: true,
230
+ text: "Tool 'slack_list_channels' not advertised by server",
231
+ },
232
+ };
233
+ mockTestServer.mockResolvedValue(response);
234
+
235
+ const result = await McpService.testServer(SLACK_SERVER);
236
+
237
+ expect(result).toEqual(response);
238
+ });
239
+
240
+ // -------------------------------------------------------------------------
241
+ // Read-only credential probes for the hosted GitHub and Linear servers
242
+ // -------------------------------------------------------------------------
243
+
244
+ const GITHUB_SERVER: MCPServerConfig = {
245
+ id: "shttp-0",
246
+ type: "shttp",
247
+ name: "github",
248
+ url: "https://api.githubcopilot.com/mcp/",
249
+ auth: { strategy: "api_key", value: "github_pat_LIVETOKENVALUE" },
250
+ };
251
+
252
+ const LINEAR_SERVER: MCPServerConfig = {
253
+ id: "shttp-1",
254
+ type: "shttp",
255
+ name: "linear",
256
+ url: "https://mcp.linear.app/mcp",
257
+ auth: { strategy: "bearer", value: "lin_api_LIVETOKEN" },
258
+ };
259
+
260
+ it("attaches the read-only GitHub probe (get_me) to matching servers", async () => {
261
+ mockTestServer.mockResolvedValue({ ok: true, tools: [] });
262
+
263
+ await McpService.testServer(GITHUB_SERVER);
264
+
265
+ expect(mockTestServer).toHaveBeenCalledWith(
266
+ expect.objectContaining({
267
+ tool_call: { name: "get_me", arguments: {} },
268
+ }),
269
+ );
270
+ });
271
+
272
+ it("attaches the read-only Linear probe (list_teams) to matching servers", async () => {
273
+ mockTestServer.mockResolvedValue({ ok: true, tools: [] });
274
+
275
+ await McpService.testServer(LINEAR_SERVER);
276
+
277
+ expect(mockTestServer).toHaveBeenCalledWith(
278
+ expect.objectContaining({
279
+ tool_call: { name: "list_teams", arguments: {} },
280
+ }),
281
+ );
282
+ });
283
+
284
+ it("maps an errored GitHub probe call to a redacted credentials failure", async () => {
285
+ mockTestServer.mockResolvedValue({
286
+ ok: true,
287
+ tools: ["get_me"],
288
+ tool_result: {
289
+ is_error: true,
290
+ text: "401 for token github_pat_LIVETOKENVALUE",
291
+ },
292
+ });
293
+
294
+ const result = await McpService.testServer(GITHUB_SERVER);
295
+
296
+ // Interpretation runs on already-redacted text, so the surfaced
297
+ // credentials error never contains the configured secret.
298
+ expect(result).toEqual({
299
+ ok: false,
300
+ error: `401 for token ${REDACTED_MCP_SECRET_VALUE}`,
301
+ error_kind: "credentials",
302
+ });
303
+ });
304
+
305
+ it("redacts configured secrets from failure error text", async () => {
306
+ mockTestServer.mockResolvedValue({
307
+ ok: false,
308
+ error: "handshake rejected Bearer github_pat_LIVETOKENVALUE",
309
+ error_kind: "connection",
310
+ });
311
+
312
+ const result = await McpService.testServer(GITHUB_SERVER);
313
+
314
+ expect(result).toMatchObject({ ok: false, error_kind: "connection" });
315
+ expect(JSON.stringify(result)).not.toContain("github_pat_LIVETOKENVALUE");
316
+ });
317
+
318
+ // -------------------------------------------------------------------------
319
+ // Redacted-secret round-trip for the edit flow
320
+ //
321
+ // The MCP page reads settings with redacted secrets, so unchanged env
322
+ // values arrive as the literal redaction placeholder. The service swaps
323
+ // them for the stored values in encrypted form (decrypted server-side) so
324
+ // the test exercises the real credentials.
325
+ // -------------------------------------------------------------------------
326
+
327
+ const REDACTED_SLACK_SERVER: MCPServerConfig = {
328
+ ...SLACK_SERVER,
329
+ env: { SLACK_TEAM_ID: "T01", SLACK_BOT_TOKEN: REDACTED_MCP_SECRET_VALUE },
330
+ };
331
+
332
+ it("substitutes redacted env values with encrypted stored values", async () => {
333
+ mockTestServer.mockResolvedValue({ ok: true, tools: [] });
334
+ vi.spyOn(SettingsService, "fetchSettingsFromApi").mockResolvedValue({
335
+ agent_settings: {
336
+ mcp_config: {
337
+ slack: { env: { SLACK_BOT_TOKEN: "gAAAAA-encrypted-token" } },
338
+ },
339
+ },
340
+ } as unknown as SettingsApiResponse);
341
+
342
+ await McpService.testServer(REDACTED_SLACK_SERVER);
343
+
344
+ expect(SettingsService.fetchSettingsFromApi).toHaveBeenCalledWith(
345
+ "encrypted",
346
+ );
347
+ expect(mockTestServer).toHaveBeenCalledWith(
348
+ expect.objectContaining({
349
+ server: expect.objectContaining({
350
+ // Placeholder replaced by ciphertext; typed value left untouched.
351
+ env: {
352
+ SLACK_TEAM_ID: "T01",
353
+ SLACK_BOT_TOKEN: "gAAAAA-encrypted-token",
354
+ },
355
+ }),
356
+ }),
357
+ );
358
+ });
359
+
360
+ it("keeps the placeholder when encrypted settings cannot be fetched", async () => {
361
+ // e.g. HTTP 503 from a backend without a cipher — the test must still
362
+ // run (and fail the credential check honestly) instead of crashing.
363
+ mockTestServer.mockResolvedValue({ ok: true, tools: [] });
364
+ vi.spyOn(SettingsService, "fetchSettingsFromApi").mockRejectedValue(
365
+ new Error("503 no cipher"),
366
+ );
367
+
368
+ await McpService.testServer(REDACTED_SLACK_SERVER);
369
+
370
+ expect(mockTestServer).toHaveBeenCalledWith(
371
+ expect.objectContaining({
372
+ server: expect.objectContaining({
373
+ env: {
374
+ SLACK_TEAM_ID: "T01",
375
+ SLACK_BOT_TOKEN: REDACTED_MCP_SECRET_VALUE,
376
+ },
377
+ }),
378
+ }),
379
+ );
380
+ });
381
+
382
+ it("short-circuits with a synthetic ok response on cloud backends", async () => {
383
+ // Regression: when the active backend is cloud, the local agent-server's
384
+ // /api/mcp/test endpoint is not reachable. Previously, the helper threw
385
+ // `NoBackendAvailableError("No backend is configured.")` which surfaced
386
+ // in the install modal and blocked users from creating any MCP server
387
+ // (e.g. Slack) on a cloud session.
388
+ cloudActive();
389
+
390
+ const result = await McpService.testServer(SERVER);
391
+
392
+ expect(result).toEqual({ ok: true, tools: [] });
393
+ expect(mockTestServer).not.toHaveBeenCalled();
394
+ });
395
+ });
__tests__/api/runtime-service/agent-server-runtime-service.test.ts ADDED
@@ -0,0 +1,307 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import { FileClient } from "@openhands/typescript-client/clients";
2
+ import { RemoteWorkspace } from "@openhands/typescript-client/workspace/remote-workspace";
3
+ import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
4
+ import {
5
+ __resetActiveStoreForTests,
6
+ setActiveSelection,
7
+ setRegisteredBackends,
8
+ } from "#/api/backend-registry/active-store";
9
+ import AgentServerRuntimeService from "#/api/runtime-service/agent-server-runtime-service";
10
+ import { callCloudProxy } from "#/api/cloud/proxy";
11
+ import type { Backend } from "#/api/backend-registry/types";
12
+
13
+ // ─── SDK client mocks ───────────────────────────────────────────────────────
14
+
15
+ const { executeCommandMock, downloadFileMock } = vi.hoisted(() => ({
16
+ executeCommandMock: vi.fn(),
17
+ downloadFileMock: vi.fn(),
18
+ }));
19
+
20
+ vi.mock("@openhands/typescript-client/workspace/remote-workspace", () => ({
21
+ RemoteWorkspace: vi.fn(function RemoteWorkspaceMock() {
22
+ return { executeCommand: executeCommandMock };
23
+ }),
24
+ }));
25
+
26
+ vi.mock("@openhands/typescript-client/clients", () => ({
27
+ FileClient: vi.fn(function FileClientMock() {
28
+ return { downloadFile: downloadFileMock };
29
+ }),
30
+ }));
31
+
32
+ vi.mock("#/api/agent-server-client-options", () => ({
33
+ getAgentServerClientOptions: vi.fn(() => ({
34
+ host: "http://local-agent.example.com",
35
+ apiKey: "local-key",
36
+ workingDir: "/workspace/project",
37
+ })),
38
+ }));
39
+
40
+ vi.mock("#/api/cloud/proxy", () => ({
41
+ callCloudProxy: vi.fn(),
42
+ }));
43
+
44
+ // ─── Backend fixtures ────────────────────────────────────────────────────────
45
+
46
+ const cloudBackend: Backend = {
47
+ id: "cloud-1",
48
+ name: "Production",
49
+ host: "https://app.all-hands.dev",
50
+ apiKey: "cloud-api-key",
51
+ kind: "cloud",
52
+ };
53
+
54
+ const CLOUD_CONVERSATION_URL =
55
+ "https://runtime.example.com/api/conversations/conv-1";
56
+ const SESSION_KEY = "session-key-abc";
57
+
58
+ // ─── Helpers ─────────────────────────────────────────────────────────────────
59
+
60
+ function activateCloud() {
61
+ setRegisteredBackends([cloudBackend]);
62
+ setActiveSelection({ backendId: cloudBackend.id, orgId: null });
63
+ }
64
+
65
+ // ─── Setup ───────────────────────────────────────────────────────────────────
66
+
67
+ beforeEach(() => {
68
+ window.localStorage.clear();
69
+ __resetActiveStoreForTests();
70
+ vi.mocked(RemoteWorkspace).mockClear();
71
+ vi.mocked(FileClient).mockClear();
72
+ executeCommandMock.mockReset();
73
+ downloadFileMock.mockReset();
74
+ vi.mocked(callCloudProxy).mockReset();
75
+ });
76
+
77
+ afterEach(() => {
78
+ window.localStorage.clear();
79
+ __resetActiveStoreForTests();
80
+ });
81
+
82
+ // ─── executeCommand ──────────────────────────────────────────────────────────
83
+
84
+ describe("AgentServerRuntimeService.executeCommand", () => {
85
+ describe("local backend", () => {
86
+ it("creates RemoteWorkspace with resolved options and delegates", async () => {
87
+ executeCommandMock.mockResolvedValue({
88
+ exit_code: 0,
89
+ stdout: "main\n",
90
+ stderr: "",
91
+ });
92
+
93
+ const result = await AgentServerRuntimeService.executeCommand(
94
+ "http://local-agent.example.com/api/conversations/conv-1",
95
+ SESSION_KEY,
96
+ "git rev-parse --abbrev-ref HEAD",
97
+ "/workspace/project",
98
+ 10,
99
+ );
100
+
101
+ expect(RemoteWorkspace).toHaveBeenCalledTimes(1);
102
+ expect(executeCommandMock).toHaveBeenCalledWith(
103
+ "git rev-parse --abbrev-ref HEAD",
104
+ "/workspace/project",
105
+ 10,
106
+ );
107
+ expect(result).toEqual({ exit_code: 0, stdout: "main\n", stderr: "" });
108
+ });
109
+
110
+ it("does not call callCloudProxy for local backends", async () => {
111
+ executeCommandMock.mockResolvedValue({
112
+ exit_code: 0,
113
+ stdout: "",
114
+ stderr: "",
115
+ });
116
+
117
+ await AgentServerRuntimeService.executeCommand(null, null, "ls", "/", 5);
118
+
119
+ expect(callCloudProxy).not.toHaveBeenCalled();
120
+ });
121
+ });
122
+
123
+ describe("cloud backend", () => {
124
+ beforeEach(activateCloud);
125
+
126
+ it("routes through callCloudProxy with correct path, body, and auth", async () => {
127
+ vi.mocked(callCloudProxy).mockResolvedValue({
128
+ exit_code: 0,
129
+ stdout: "src/index.ts\n",
130
+ stderr: "",
131
+ });
132
+
133
+ const result = await AgentServerRuntimeService.executeCommand(
134
+ CLOUD_CONVERSATION_URL,
135
+ SESSION_KEY,
136
+ "find . -type f",
137
+ "/workspace/project",
138
+ 30,
139
+ );
140
+
141
+ const proxyCall = vi.mocked(callCloudProxy).mock.calls[0][0];
142
+ expect(proxyCall.method).toBe("POST");
143
+ expect(proxyCall.path).toBe("/api/bash/execute_bash_command");
144
+ expect(proxyCall.hostOverride).toBe("https://runtime.example.com");
145
+ expect(proxyCall.body).toEqual({
146
+ command: "find . -type f",
147
+ cwd: "/workspace/project",
148
+ timeout: 30,
149
+ });
150
+ expect(proxyCall.authMode).toBe("session-api-key");
151
+ expect(proxyCall.sessionApiKey).toBe(SESSION_KEY);
152
+ expect(proxyCall.timeoutSeconds).toBe(40);
153
+ expect(result).toEqual({
154
+ exit_code: 0,
155
+ stdout: "src/index.ts\n",
156
+ stderr: "",
157
+ });
158
+ });
159
+
160
+ it("omits cwd from proxy body when not provided", async () => {
161
+ vi.mocked(callCloudProxy).mockResolvedValue({ exit_code: 0 });
162
+
163
+ await AgentServerRuntimeService.executeCommand(
164
+ CLOUD_CONVERSATION_URL,
165
+ SESSION_KEY,
166
+ "echo hi",
167
+ undefined,
168
+ 10,
169
+ );
170
+
171
+ const proxyBody = vi.mocked(callCloudProxy).mock.calls[0][0].body as Record<string, unknown>;
172
+ expect(proxyBody).not.toHaveProperty("cwd");
173
+ expect(proxyBody.command).toBe("echo hi");
174
+ });
175
+
176
+ it("normalises missing stdout/stderr fields to empty strings", async () => {
177
+ vi.mocked(callCloudProxy).mockResolvedValue({ exit_code: 1 });
178
+
179
+ const result = await AgentServerRuntimeService.executeCommand(
180
+ CLOUD_CONVERSATION_URL,
181
+ SESSION_KEY,
182
+ "false",
183
+ undefined,
184
+ 5,
185
+ );
186
+
187
+ expect(result).toEqual({ exit_code: 1, stdout: "", stderr: "" });
188
+ });
189
+
190
+ it("does not create a RemoteWorkspace for cloud calls", async () => {
191
+ vi.mocked(callCloudProxy).mockResolvedValue({ exit_code: 0 });
192
+
193
+ await AgentServerRuntimeService.executeCommand(
194
+ CLOUD_CONVERSATION_URL,
195
+ SESSION_KEY,
196
+ "echo ok",
197
+ );
198
+
199
+ expect(RemoteWorkspace).not.toHaveBeenCalled();
200
+ });
201
+
202
+ it("falls back to local path when conversationUrl is null", async () => {
203
+ executeCommandMock.mockResolvedValue({
204
+ exit_code: 0,
205
+ stdout: "",
206
+ stderr: "",
207
+ });
208
+
209
+ await AgentServerRuntimeService.executeCommand(
210
+ null,
211
+ SESSION_KEY,
212
+ "echo ok",
213
+ );
214
+
215
+ expect(callCloudProxy).not.toHaveBeenCalled();
216
+ expect(RemoteWorkspace).toHaveBeenCalledTimes(1);
217
+ });
218
+ });
219
+ });
220
+
221
+ // ─── downloadFile ─────────────────────────────────────────────────────────────
222
+
223
+ describe("AgentServerRuntimeService.downloadFile", () => {
224
+ describe("local backend", () => {
225
+ it("creates FileClient with resolved options and returns the ArrayBuffer", async () => {
226
+ const fileBytes = new TextEncoder().encode("# README");
227
+ downloadFileMock.mockResolvedValue(fileBytes.buffer);
228
+
229
+ const result = await AgentServerRuntimeService.downloadFile(
230
+ "http://local-agent.example.com/api/conversations/conv-1",
231
+ SESSION_KEY,
232
+ "/workspace/project/README.md",
233
+ );
234
+
235
+ expect(FileClient).toHaveBeenCalledTimes(1);
236
+ expect(downloadFileMock).toHaveBeenCalledWith(
237
+ "/workspace/project/README.md",
238
+ );
239
+ expect(result).toBe(fileBytes.buffer);
240
+ });
241
+
242
+ it("does not call callCloudProxy for local backends", async () => {
243
+ downloadFileMock.mockResolvedValue(new ArrayBuffer(0));
244
+
245
+ await AgentServerRuntimeService.downloadFile(
246
+ null,
247
+ null,
248
+ "/workspace/file.txt",
249
+ );
250
+
251
+ expect(callCloudProxy).not.toHaveBeenCalled();
252
+ });
253
+ });
254
+
255
+ describe("cloud backend", () => {
256
+ beforeEach(activateCloud);
257
+
258
+ it("routes through callCloudProxy with GET and URL-encoded path", async () => {
259
+ const blob = new Blob([new TextEncoder().encode("file content")]);
260
+ vi.mocked(callCloudProxy).mockResolvedValue(blob);
261
+
262
+ const result = await AgentServerRuntimeService.downloadFile(
263
+ CLOUD_CONVERSATION_URL,
264
+ SESSION_KEY,
265
+ "/workspace/project/src/main.ts",
266
+ );
267
+
268
+ const proxyCall = vi.mocked(callCloudProxy).mock.calls[0][0];
269
+ expect(proxyCall.method).toBe("GET");
270
+ expect(proxyCall.path).toBe(
271
+ "/api/file/download?path=%2Fworkspace%2Fproject%2Fsrc%2Fmain.ts",
272
+ );
273
+ expect(proxyCall.hostOverride).toBe("https://runtime.example.com");
274
+ expect(proxyCall.authMode).toBe("session-api-key");
275
+ expect(proxyCall.sessionApiKey).toBe(SESSION_KEY);
276
+ expect(proxyCall.responseType).toBe("blob");
277
+
278
+ // Blob.arrayBuffer() round-trip: decoded text should match the original.
279
+ expect(new TextDecoder().decode(result)).toBe("file content");
280
+ });
281
+
282
+ it("does not create a FileClient for cloud calls", async () => {
283
+ vi.mocked(callCloudProxy).mockResolvedValue(new Blob());
284
+
285
+ await AgentServerRuntimeService.downloadFile(
286
+ CLOUD_CONVERSATION_URL,
287
+ SESSION_KEY,
288
+ "/workspace/file.txt",
289
+ );
290
+
291
+ expect(FileClient).not.toHaveBeenCalled();
292
+ });
293
+
294
+ it("falls back to local path when conversationUrl is null", async () => {
295
+ downloadFileMock.mockResolvedValue(new ArrayBuffer(0));
296
+
297
+ await AgentServerRuntimeService.downloadFile(
298
+ null,
299
+ SESSION_KEY,
300
+ "/workspace/file.txt",
301
+ );
302
+
303
+ expect(callCloudProxy).not.toHaveBeenCalled();
304
+ expect(FileClient).toHaveBeenCalledTimes(1);
305
+ });
306
+ });
307
+ });
__tests__/components/analytics/telemetry-consent-banner.test.tsx ADDED
@@ -0,0 +1,198 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import { render, screen, waitFor } from "@testing-library/react";
2
+ import userEvent from "@testing-library/user-event";
3
+ import { beforeEach, describe, expect, it, vi } from "vitest";
4
+ import { TelemetryConsentBanner } from "#/components/features/analytics/telemetry-consent-banner";
5
+
6
+ const useActiveBackendMock = vi.fn();
7
+ const useSettingsMock = vi.fn();
8
+ const useBackendsHealthMock = vi.fn();
9
+ const saveSettingsMock = vi.fn();
10
+ const getLockedCloudHostMock = vi.fn();
11
+ const setTelemetryConsentMock = vi.fn();
12
+
13
+ vi.mock("react-i18next", () => ({
14
+ useTranslation: () => ({
15
+ ready: true,
16
+ t: (key: string, options?: Record<string, string>) =>
17
+ key === "TELEMETRY$BACKEND_SCOPE"
18
+ ? `This preference is saved for ${options?.name} at ${options?.host}.`
19
+ : key,
20
+ }),
21
+ }));
22
+
23
+ vi.mock("#/i18n", () => ({
24
+ OPENHANDS_I18N_NAMESPACE: "openhands",
25
+ }));
26
+
27
+ vi.mock("#/api/agent-server-config", () => ({
28
+ getLockedCloudHost: () => getLockedCloudHostMock(),
29
+ }));
30
+
31
+ vi.mock("#/contexts/active-backend-context", () => ({
32
+ useActiveBackend: () => useActiveBackendMock(),
33
+ }));
34
+
35
+ vi.mock("#/hooks/query/use-settings", () => ({
36
+ useSettings: () => useSettingsMock(),
37
+ }));
38
+
39
+ vi.mock("#/hooks/query/use-backends-health", () => ({
40
+ useBackendsHealth: (...args: unknown[]) => useBackendsHealthMock(...args),
41
+ }));
42
+
43
+ vi.mock("#/hooks/mutation/use-save-settings", () => ({
44
+ useSaveSettings: () => ({
45
+ mutateAsync: saveSettingsMock,
46
+ isPending: false,
47
+ }),
48
+ }));
49
+
50
+ vi.mock("#/services/telemetry", () => ({
51
+ setTelemetryConsent: (...args: unknown[]) => setTelemetryConsentMock(...args),
52
+ }));
53
+
54
+ describe("TelemetryConsentBanner", () => {
55
+ beforeEach(() => {
56
+ vi.clearAllMocks();
57
+ saveSettingsMock.mockResolvedValue(undefined);
58
+ getLockedCloudHostMock.mockReturnValue(null);
59
+ useActiveBackendMock.mockReturnValue({
60
+ backend: {
61
+ id: "local",
62
+ kind: "local",
63
+ name: "Local Dev",
64
+ host: "http://localhost:12000",
65
+ },
66
+ });
67
+ useBackendsHealthMock.mockReturnValue({
68
+ local: { isConnected: true },
69
+ });
70
+
71
+ useSettingsMock.mockReturnValue({
72
+ data: { user_consents_to_analytics: null },
73
+ isSuccess: true,
74
+ });
75
+ });
76
+
77
+ it("renders in local mode when agent-server consent is null", async () => {
78
+ render(<TelemetryConsentBanner />);
79
+
80
+ expect(
81
+ await screen.findByTestId("telemetry-consent-form"),
82
+ ).toBeInTheDocument();
83
+ expect(
84
+ screen.getByText(
85
+ "This preference is saved for Local Dev at http://localhost:12000.",
86
+ ),
87
+ ).toBeInTheDocument();
88
+ });
89
+
90
+ it("does not render until the local backend settings are connected", async () => {
91
+ useSettingsMock.mockReturnValue({
92
+ data: { user_consents_to_analytics: null },
93
+ isSuccess: false,
94
+ });
95
+
96
+ render(<TelemetryConsentBanner />);
97
+
98
+ await waitFor(() => {
99
+ expect(
100
+ screen.queryByTestId("telemetry-consent-form"),
101
+ ).not.toBeInTheDocument();
102
+ });
103
+ });
104
+
105
+ it("does not render when the active local backend is unhealthy", async () => {
106
+ useBackendsHealthMock.mockReturnValue({
107
+ local: { isConnected: false },
108
+ });
109
+ useSettingsMock.mockReturnValue({
110
+ data: { user_consents_to_analytics: null },
111
+ isSuccess: true,
112
+ });
113
+
114
+ render(<TelemetryConsentBanner />);
115
+
116
+ await waitFor(() => {
117
+ expect(
118
+ screen.queryByTestId("telemetry-consent-form"),
119
+ ).not.toBeInTheDocument();
120
+ });
121
+ });
122
+
123
+ it.each([true, false])(
124
+ "does not render when agent-server consent is %s",
125
+ async (serverConsent) => {
126
+ useSettingsMock.mockReturnValue({
127
+ data: { user_consents_to_analytics: serverConsent },
128
+ isSuccess: true,
129
+ });
130
+
131
+ render(<TelemetryConsentBanner />);
132
+
133
+ await waitFor(() => {
134
+ expect(
135
+ screen.queryByTestId("telemetry-consent-form"),
136
+ ).not.toBeInTheDocument();
137
+ });
138
+ },
139
+ );
140
+
141
+ it("does not render for cloud backends", async () => {
142
+ useActiveBackendMock.mockReturnValue({
143
+ backend: { id: "cloud", kind: "cloud" },
144
+ });
145
+
146
+ render(<TelemetryConsentBanner />);
147
+
148
+ await waitFor(() => {
149
+ expect(
150
+ screen.queryByTestId("telemetry-consent-form"),
151
+ ).not.toBeInTheDocument();
152
+ });
153
+ });
154
+
155
+ it("does not render in locked Cloud mode", async () => {
156
+ getLockedCloudHostMock.mockReturnValue("https://app.all-hands.dev");
157
+
158
+ render(<TelemetryConsentBanner />);
159
+
160
+ await waitFor(() => {
161
+ expect(
162
+ screen.queryByTestId("telemetry-consent-form"),
163
+ ).not.toBeInTheDocument();
164
+ });
165
+ });
166
+
167
+ it("persists the user's local-mode consent choice without useTelemetry", async () => {
168
+ const user = userEvent.setup();
169
+ render(<TelemetryConsentBanner />);
170
+
171
+ const checkbox = await screen.findByRole("checkbox");
172
+ await user.click(checkbox);
173
+ await user.click(screen.getByTestId("confirm-telemetry-preferences"));
174
+
175
+ expect(setTelemetryConsentMock).toHaveBeenCalledWith("denied");
176
+ expect(saveSettingsMock).toHaveBeenCalledWith({
177
+ user_consents_to_analytics: false,
178
+ });
179
+ });
180
+
181
+ it("does not persist browser consent when the backend save fails", async () => {
182
+ const user = userEvent.setup();
183
+ saveSettingsMock.mockRejectedValue(new Error("unauthorized"));
184
+ render(<TelemetryConsentBanner />);
185
+
186
+ await screen.findByTestId("telemetry-consent-form");
187
+ await user.click(screen.getByTestId("confirm-telemetry-preferences"));
188
+
189
+ await waitFor(() => {
190
+ expect(saveSettingsMock).toHaveBeenCalledWith({
191
+ user_consents_to_analytics: true,
192
+ });
193
+ });
194
+ expect(setTelemetryConsentMock).not.toHaveBeenCalled();
195
+ expect(screen.getByTestId("telemetry-consent-form")).toBeInTheDocument();
196
+ });
197
+
198
+ });
__tests__/components/automations/add-automation-modal.test.tsx ADDED
@@ -0,0 +1,107 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import React from "react";
2
+ import { render, screen } from "@testing-library/react";
3
+ import userEvent from "@testing-library/user-event";
4
+ import { describe, expect, it, vi } from "vitest";
5
+ import {
6
+ NavigationProvider,
7
+ type NavigationContextValue,
8
+ } from "#/context/navigation-context";
9
+ import { AddAutomationModal } from "#/components/features/automations/add-automation-modal";
10
+ import { I18nKey } from "#/i18n/declaration";
11
+
12
+ vi.mock("#/hooks/query/use-settings", () => ({
13
+ useSettings: () => ({ data: { user_consents_to_analytics: true } }),
14
+ }));
15
+
16
+ vi.mock("react-i18next", () => ({
17
+ useTranslation: () => ({
18
+ t: (key: string) => key,
19
+ }),
20
+ Trans: ({
21
+ i18nKey,
22
+ components,
23
+ children,
24
+ }: {
25
+ i18nKey: string;
26
+ components?: Record<string, React.ReactElement>;
27
+ children?: React.ReactNode;
28
+ }) => {
29
+ if (i18nKey !== I18nKey.AUTOMATIONS$EMPTY_OPTION_CONVERSATION_DESC) {
30
+ return children ?? i18nKey;
31
+ }
32
+
33
+ return (
34
+ <>
35
+ Start a new conversation and tell OpenHands to{" "}
36
+ {components?.example
37
+ ? React.cloneElement(
38
+ components.example,
39
+ {},
40
+ <>
41
+ {components.cmd
42
+ ? React.cloneElement(
43
+ components.cmd,
44
+ {},
45
+ "Create an automation",
46
+ )
47
+ : null}
48
+ {components.punct
49
+ ? React.cloneElement(components.punct, {}, ".")
50
+ : null}
51
+ </>,
52
+ )
53
+ : null}
54
+ </>
55
+ );
56
+ },
57
+ }));
58
+
59
+ function renderModal(isOpen = true) {
60
+ const onClose = vi.fn();
61
+ const navigation: NavigationContextValue = {
62
+ currentPath: "/automations",
63
+ conversationId: null,
64
+ isNavigating: false,
65
+ navigate: vi.fn(),
66
+ };
67
+
68
+ render(
69
+ <NavigationProvider value={navigation}>
70
+ <AddAutomationModal isOpen={isOpen} onClose={onClose} />
71
+ </NavigationProvider>,
72
+ );
73
+
74
+ return { onClose };
75
+ }
76
+
77
+ describe("AddAutomationModal", () => {
78
+ it("renders the create instructions content when open", () => {
79
+ renderModal();
80
+
81
+ expect(screen.getByTestId("add-automation-modal")).toBeInTheDocument();
82
+ expect(
83
+ screen.getByTestId("automations-create-instructions-example"),
84
+ ).toHaveTextContent("Create an automation");
85
+ expect(
86
+ screen.getByTestId("automations-create-automation"),
87
+ ).toHaveTextContent(I18nKey.AUTOMATIONS$CREATE_AUTOMATION_BUTTON);
88
+ expect(
89
+ screen.queryByText(I18nKey.AUTOMATIONS$EMPTY_OPTION_PLUGIN_TITLE),
90
+ ).not.toBeInTheDocument();
91
+ });
92
+
93
+ it("does not render when closed", () => {
94
+ renderModal(false);
95
+
96
+ expect(screen.queryByTestId("add-automation-modal")).not.toBeInTheDocument();
97
+ });
98
+
99
+ it("calls onClose when the close button is clicked", async () => {
100
+ const user = userEvent.setup();
101
+ const { onClose } = renderModal();
102
+
103
+ await user.click(screen.getByTestId("add-automation-modal-close"));
104
+
105
+ expect(onClose).toHaveBeenCalledTimes(1);
106
+ });
107
+ });
__tests__/components/automations/automation-card.test.tsx ADDED
@@ -0,0 +1,368 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import { render, screen } from "@testing-library/react";
2
+ import userEvent from "@testing-library/user-event";
3
+ import { describe, expect, it, vi, beforeEach, afterEach } from "vitest";
4
+ import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
5
+ import { http, HttpResponse } from "msw";
6
+ import { AutomationCard } from "#/components/features/automations/automation-card";
7
+ import {
8
+ AutomationRunStatus,
9
+ type Automation,
10
+ type AutomationRun,
11
+ } from "#/types/automation";
12
+ import { useAutomationRunSummaries } from "#/hooks/query/use-automation-run-summaries";
13
+ import {
14
+ __resetActiveStoreForTests,
15
+ setActiveSelection,
16
+ setRegisteredBackends,
17
+ } from "#/api/backend-registry/active-store";
18
+ import { ActiveBackendProvider } from "#/contexts/active-backend-context";
19
+ import type { Backend } from "#/api/backend-registry/types";
20
+ import { server } from "#/mocks/node";
21
+ import { I18nKey } from "#/i18n/declaration";
22
+ import type { InterfaceListInsights } from "#/manifests/types";
23
+
24
+ vi.mock("react-i18next", () => ({
25
+ useTranslation: () => ({
26
+ t: (key: string) => key,
27
+ i18n: { language: "en" },
28
+ }),
29
+ }));
30
+
31
+ vi.mock("#/context/navigation-context", () => ({
32
+ useNavigation: () => ({ navigate: vi.fn(), currentPath: "/" }),
33
+ }));
34
+
35
+ vi.mock("#/hooks/use-automation-permissions", () => ({
36
+ useAutomationPermissions: () => ({
37
+ canView: true,
38
+ canManage: true,
39
+ isLoading: false,
40
+ }),
41
+ useIsAutomationOwner: () => true,
42
+ }));
43
+
44
+ // The pinned package predates the `impact` field, so an entry carrying one is
45
+ // appended to the real catalog.
46
+ vi.mock("@openhands/extensions/automations", async (importOriginal) => {
47
+ const actual =
48
+ await importOriginal<typeof import("@openhands/extensions/automations")>();
49
+ return {
50
+ ...actual,
51
+ AUTOMATION_CATALOG: [
52
+ ...actual.AUTOMATION_CATALOG,
53
+ {
54
+ id: "widget-checker",
55
+ impact: {
56
+ basis: "completed-runs",
57
+ one: "1 widget check completed",
58
+ other: "{{count}} widget checks completed",
59
+ },
60
+ },
61
+ ] as typeof actual.AUTOMATION_CATALOG,
62
+ };
63
+ });
64
+
65
+ const automation: Automation = {
66
+ id: "automation-1",
67
+ name: "Async Standup Digest",
68
+ prompt: "Generate an async standup digest from Slack activity.",
69
+ enabled: true,
70
+ trigger: { type: "cron", schedule_human: "Mondays at 09:00" },
71
+ created_at: "2026-01-01T00:00:00Z",
72
+ updated_at: "2026-01-01T00:00:00Z",
73
+ };
74
+
75
+ const insightsSpec = {
76
+ health: {
77
+ healthy: "Healthy",
78
+ failing: "Failing",
79
+ running: "Running",
80
+ disabled: "Disabled",
81
+ neverRun: "Never run",
82
+ checking: "Checking",
83
+ },
84
+ lastRun: { label: "Last run", never: "Never", justNow: "Just now" },
85
+ stats: { runs: "Runs", recentSuccess: "Success", averageDuration: "Avg" },
86
+ };
87
+
88
+ function createRun(overrides: Partial<AutomationRun> = {}): AutomationRun {
89
+ return {
90
+ id: "run-1",
91
+ status: AutomationRunStatus.COMPLETED,
92
+ conversation_id: null,
93
+ bash_command_id: null,
94
+ error_detail: null,
95
+ started_at: "2026-01-02T00:00:00Z",
96
+ completed_at: "2026-01-02T00:02:00Z",
97
+ ...overrides,
98
+ };
99
+ }
100
+
101
+ describe("AutomationCard", () => {
102
+ it("uses the shared extension module interactive class without a resting border", () => {
103
+ render(
104
+ <AutomationCard
105
+ automation={automation}
106
+ onToggle={vi.fn()}
107
+ onRunNow={vi.fn()}
108
+ onExport={vi.fn()}
109
+ onDelete={vi.fn()}
110
+ />,
111
+ );
112
+
113
+ const card = screen.getByTestId("automation-card-automation-1");
114
+ expect(card.className).toContain("extension-module-card-interactive");
115
+ expect(card.className).toContain("bg-base-secondary");
116
+ expect(card.className).not.toContain("border-[var(--oh-border)]");
117
+ });
118
+
119
+ it("renders title, description, and overflow pills", () => {
120
+ render(
121
+ <AutomationCard
122
+ automation={automation}
123
+ onToggle={vi.fn()}
124
+ onRunNow={vi.fn()}
125
+ onExport={vi.fn()}
126
+ onDelete={vi.fn()}
127
+ />,
128
+ );
129
+
130
+ expect(screen.getByText("Async Standup Digest")).toBeInTheDocument();
131
+ expect(
132
+ screen.getByText("Generate an async standup digest from Slack activity."),
133
+ ).toBeInTheDocument();
134
+ expect(screen.getByText("Mondays at 09:00")).toBeInTheDocument();
135
+ expect(
136
+ screen.getByTestId("automation-pills-automation-1"),
137
+ ).toBeInTheDocument();
138
+ });
139
+
140
+ it("renders a play run button and menu actions instead of a toggle switch", async () => {
141
+ const user = userEvent.setup();
142
+
143
+ render(
144
+ <AutomationCard
145
+ automation={automation}
146
+ onToggle={vi.fn()}
147
+ onRunNow={vi.fn()}
148
+ onExport={vi.fn()}
149
+ onDelete={vi.fn()}
150
+ />,
151
+ );
152
+
153
+ expect(
154
+ screen.getByTestId("automation-run-now-automation-1"),
155
+ ).toHaveAttribute("aria-label", "AUTOMATIONS$RUN_NOW");
156
+ expect(screen.getByTestId("automation-run-now-automation-1")).toHaveClass(
157
+ "size-8",
158
+ );
159
+ expect(screen.queryByRole("switch")).not.toBeInTheDocument();
160
+
161
+ await user.click(
162
+ screen.getByRole("button", { name: "AUTOMATIONS$ACTIONS_MENU" }),
163
+ );
164
+
165
+ expect(screen.getByText("COMMON$VIEW")).toBeInTheDocument();
166
+ expect(screen.getByText("AUTOMATIONS$RUN_NOW")).toBeInTheDocument();
167
+ });
168
+
169
+ it("shows a status strip and sparkline when insights are present", () => {
170
+ const latestRun = createRun({
171
+ started_at: new Date(Date.now() - 10 * 60_000).toISOString(),
172
+ completed_at: new Date(Date.now() - 8 * 60_000).toISOString(),
173
+ });
174
+
175
+ render(
176
+ <AutomationCard
177
+ automation={automation}
178
+ onToggle={vi.fn()}
179
+ onRunNow={vi.fn()}
180
+ onExport={vi.fn()}
181
+ onDelete={vi.fn()}
182
+ insights={{
183
+ spec: insightsSpec satisfies InterfaceListInsights,
184
+ state: {
185
+ summary: {
186
+ total: 4,
187
+ completedTotal: 4,
188
+ latestRun,
189
+ recentRuns: [latestRun],
190
+ recentSuccessRate: 1,
191
+ averageDurationMs: 120_000,
192
+ },
193
+ isLoading: false,
194
+ isError: false,
195
+ },
196
+ }}
197
+ />,
198
+ );
199
+
200
+ expect(
201
+ screen.queryByTestId("automation-health-badge"),
202
+ ).not.toBeInTheDocument();
203
+ expect(
204
+ screen.getByTestId("automation-last-run-automation-1"),
205
+ ).toHaveTextContent("AUTOMATIONS$DETAIL$TIME_MINUTES_AGO");
206
+ expect(screen.getByTestId("run-status-icon-completed")).toBeInTheDocument();
207
+ expect(
208
+ screen.getByTestId("automation-activity-automation-1"),
209
+ ).toBeInTheDocument();
210
+ expect(screen.getByTestId("automation-run-stats")).toBeInTheDocument();
211
+ expect(screen.getByText("4")).toBeInTheDocument();
212
+ expect(screen.getByText("100%")).toBeInTheDocument();
213
+ });
214
+
215
+ it("shows the value statement for its completed runs", () => {
216
+ // Arrange — provenance joining back to a catalog entry with an impact
217
+ // declaration, and a summary carrying the lifetime completed count.
218
+ const latestRun = createRun();
219
+
220
+ // Act
221
+ render(
222
+ <AutomationCard
223
+ automation={{
224
+ ...automation,
225
+ preset_metadata: {
226
+ template: { id: "widget-checker", version: "1.0.0", config: {} },
227
+ },
228
+ }}
229
+ onToggle={vi.fn()}
230
+ onRunNow={vi.fn()}
231
+ onExport={vi.fn()}
232
+ onDelete={vi.fn()}
233
+ insights={{
234
+ spec: insightsSpec satisfies InterfaceListInsights,
235
+ state: {
236
+ summary: {
237
+ total: 5,
238
+ completedTotal: 4,
239
+ latestRun,
240
+ recentRuns: [latestRun],
241
+ recentSuccessRate: 1,
242
+ averageDurationMs: 120_000,
243
+ },
244
+ isLoading: false,
245
+ isError: false,
246
+ },
247
+ }}
248
+ />,
249
+ );
250
+
251
+ // Assert
252
+ expect(
253
+ screen.getByTestId("automation-impact-automation-1"),
254
+ ).toHaveTextContent("4 widget checks completed");
255
+ });
256
+ });
257
+
258
+ describe("AutomationCard — run phase", () => {
259
+ const localBackend: Backend = {
260
+ id: "local-1",
261
+ name: "Local 1",
262
+ host: "http://localhost:8000",
263
+ apiKey: "k",
264
+ kind: "local",
265
+ };
266
+
267
+ const insightAutomation: Automation = {
268
+ id: "auto-with-active-run",
269
+ name: "Digest",
270
+ prompt: null,
271
+ enabled: true,
272
+ trigger: { type: "cron", schedule_human: "cron" },
273
+ created_at: "2026-01-01T00:00:00Z",
274
+ updated_at: "2026-01-01T00:00:00Z",
275
+ };
276
+
277
+ const insightsSpec: InterfaceListInsights = {
278
+ health: {
279
+ healthy: "Healthy",
280
+ failing: "Failing",
281
+ running: "Running",
282
+ disabled: "Disabled",
283
+ neverRun: "Never run",
284
+ checking: "Checking",
285
+ },
286
+ lastRun: { label: "Last run", never: "Never", justNow: "Just now" },
287
+ stats: {
288
+ runs: "Runs",
289
+ recentSuccess: "Success",
290
+ averageDuration: "Duration",
291
+ },
292
+ };
293
+
294
+ beforeEach(() => {
295
+ __resetActiveStoreForTests();
296
+ setRegisteredBackends([localBackend]);
297
+ setActiveSelection({ backendId: localBackend.id });
298
+ });
299
+
300
+ afterEach(() => {
301
+ __resetActiveStoreForTests();
302
+ });
303
+
304
+ function Harness() {
305
+ const byId = useAutomationRunSummaries([insightAutomation]);
306
+ return (
307
+ <AutomationCard
308
+ automation={insightAutomation}
309
+ onToggle={vi.fn()}
310
+ onRunNow={vi.fn()}
311
+ onExport={vi.fn()}
312
+ onDelete={vi.fn()}
313
+ insights={{ spec: insightsSpec, state: byId.get(insightAutomation.id) }}
314
+ />
315
+ );
316
+ }
317
+
318
+ function renderHarness() {
319
+ const queryClient = new QueryClient({
320
+ defaultOptions: { queries: { retry: false } },
321
+ });
322
+ return render(
323
+ <QueryClientProvider client={queryClient}>
324
+ <ActiveBackendProvider>
325
+ <Harness />
326
+ </ActiveBackendProvider>
327
+ </QueryClientProvider>,
328
+ );
329
+ }
330
+
331
+ it("shows the active run's phase using only the run-summaries fetch insights already makes — no extra request", async () => {
332
+ // Arrange: count every hit to the runs endpoint the card's insights
333
+ // already fetch (via useAutomationRunSummaries) �� if displaying the
334
+ // phase required a second request, this would be > 1.
335
+ let callCount = 0;
336
+ server.use(
337
+ http.get("*/api/automation/v1/:id/runs", () => {
338
+ callCount += 1;
339
+ return HttpResponse.json({
340
+ runs: [
341
+ {
342
+ id: "run-active",
343
+ status: AutomationRunStatus.RUNNING,
344
+ conversation_id: null,
345
+ bash_command_id: null,
346
+ error_detail: null,
347
+ phase_code: "running_agent",
348
+ phase_label: null,
349
+ phase_updated_at: null,
350
+ started_at: "2026-01-01T09:00:00Z",
351
+ completed_at: null,
352
+ },
353
+ ],
354
+ total: 1,
355
+ });
356
+ }),
357
+ );
358
+
359
+ // Act
360
+ renderHarness();
361
+
362
+ // Assert: the phase renders ...
363
+ await screen.findByText(I18nKey.AUTOMATIONS$DETAIL$PHASE_RUNNING_AGENT);
364
+ // ... and exactly one request was made — the pre-existing insights
365
+ // fetch, not a new one just for the phase.
366
+ expect(callCount).toBe(1);
367
+ });
368
+ });
__tests__/components/automations/automation-list-row.permissions.test.tsx ADDED
@@ -0,0 +1,145 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import { render, screen } from "@testing-library/react";
2
+ import userEvent from "@testing-library/user-event";
3
+ import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
4
+ import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
5
+ import {
6
+ __resetActiveStoreForTests,
7
+ setActiveSelection,
8
+ setRegisteredBackends,
9
+ } from "#/api/backend-registry/active-store";
10
+ import type { Backend } from "#/api/backend-registry/types";
11
+ import {
12
+ getCloudOrganizationMe,
13
+ getCloudOrganizations,
14
+ } from "#/api/cloud/organization-service.api";
15
+ import { AutomationListRow } from "#/components/features/automations/automation-list-row";
16
+ import { ActiveBackendProvider } from "#/contexts/active-backend-context";
17
+ import { I18nKey } from "#/i18n/declaration";
18
+ import type { Automation } from "#/types/automation";
19
+
20
+ // `automation-list-row.test.tsx` mocks the permission hooks wholesale. These
21
+ // tests run the real hooks against a mocked `/me` service so the
22
+ // creator-vs-manager rule for the enabled toggle is exercised end to end.
23
+ vi.mock("#/api/cloud/organization-service.api", async (importOriginal) => ({
24
+ ...(await importOriginal<
25
+ typeof import("#/api/cloud/organization-service.api")
26
+ >()),
27
+ getCloudOrganizations: vi.fn(),
28
+ getCloudOrganizationMe: vi.fn(),
29
+ }));
30
+
31
+ vi.mock("#/context/navigation-context", () => ({
32
+ useNavigation: () => ({ navigate: vi.fn(), currentPath: "/" }),
33
+ }));
34
+
35
+ const ORG_ID = "org-1";
36
+
37
+ const cloudBackend: Backend = {
38
+ id: "cloud-1",
39
+ name: "Production",
40
+ host: "https://app.all-hands.dev",
41
+ apiKey: "bearer-key",
42
+ kind: "cloud",
43
+ };
44
+
45
+ function makeAutomation(overrides: Partial<Automation> = {}): Automation {
46
+ return {
47
+ id: "auto-1",
48
+ name: "Teammate digest",
49
+ prompt: "Summarize",
50
+ trigger: {
51
+ type: "cron",
52
+ schedule: "0 9 * * *",
53
+ schedule_human: "Daily at 09:00",
54
+ },
55
+ enabled: true,
56
+ user_id: "creator-user",
57
+ created_at: "2026-01-01T00:00:00Z",
58
+ updated_at: "2026-01-01T00:00:00Z",
59
+ ...overrides,
60
+ };
61
+ }
62
+
63
+ function renderRow(automation: Automation) {
64
+ const queryClient = new QueryClient({
65
+ defaultOptions: { queries: { retry: false } },
66
+ });
67
+ return render(
68
+ <QueryClientProvider client={queryClient}>
69
+ <ActiveBackendProvider>
70
+ <AutomationListRow
71
+ automation={automation}
72
+ onToggle={vi.fn()}
73
+ onRunNow={vi.fn()}
74
+ onExport={vi.fn()}
75
+ onDelete={vi.fn()}
76
+ />
77
+ </ActiveBackendProvider>
78
+ </QueryClientProvider>,
79
+ );
80
+ }
81
+
82
+ async function openActionsMenu(user: ReturnType<typeof userEvent.setup>) {
83
+ // Run now only renders once the caller's permissions have resolved.
84
+ await screen.findByTestId("automation-run-now-auto-1");
85
+ await user.click(screen.getByLabelText(I18nKey.AUTOMATIONS$ACTIONS_MENU));
86
+ }
87
+
88
+ beforeEach(() => {
89
+ window.localStorage.clear();
90
+ __resetActiveStoreForTests();
91
+ setRegisteredBackends([cloudBackend]);
92
+ setActiveSelection({ backendId: cloudBackend.id, orgId: ORG_ID });
93
+ vi.mocked(getCloudOrganizations).mockResolvedValue({
94
+ items: [],
95
+ currentOrgId: null,
96
+ });
97
+ // A manager (has manage_automations) who did not create the automation.
98
+ vi.mocked(getCloudOrganizationMe).mockResolvedValue({
99
+ orgId: ORG_ID,
100
+ userId: "manager-user",
101
+ role: "admin",
102
+ permissions: ["view_automations", "manage_automations"],
103
+ });
104
+ });
105
+
106
+ afterEach(() => {
107
+ window.localStorage.clear();
108
+ __resetActiveStoreForTests();
109
+ });
110
+
111
+ describe("AutomationListRow — non-creator manager", () => {
112
+ it("offers Turn off and Delete on an enabled automation", async () => {
113
+ // Arrange
114
+ const user = userEvent.setup();
115
+ renderRow(makeAutomation({ enabled: true }));
116
+
117
+ // Act
118
+ await openActionsMenu(user);
119
+
120
+ // Assert
121
+ expect(
122
+ screen.getByRole("button", { name: I18nKey.AUTOMATIONS$TURN_OFF }),
123
+ ).toBeInTheDocument();
124
+ expect(
125
+ screen.getByRole("button", { name: I18nKey.AUTOMATIONS$DELETE }),
126
+ ).toBeInTheDocument();
127
+ });
128
+
129
+ it("hides Turn on but keeps Delete on a disabled automation", async () => {
130
+ // Arrange
131
+ const user = userEvent.setup();
132
+ renderRow(makeAutomation({ enabled: false }));
133
+
134
+ // Act
135
+ await openActionsMenu(user);
136
+
137
+ // Assert
138
+ expect(
139
+ screen.queryByRole("button", { name: I18nKey.AUTOMATIONS$TURN_ON }),
140
+ ).not.toBeInTheDocument();
141
+ expect(
142
+ screen.getByRole("button", { name: I18nKey.AUTOMATIONS$DELETE }),
143
+ ).toBeInTheDocument();
144
+ });
145
+ });
__tests__/components/automations/automation-list-row.test.tsx ADDED
@@ -0,0 +1,225 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import { render, screen } from "@testing-library/react";
2
+ import userEvent from "@testing-library/user-event";
3
+ import { describe, expect, it, vi } from "vitest";
4
+ import { AutomationListRow } from "#/components/features/automations/automation-list-row";
5
+ import {
6
+ AutomationRunStatus,
7
+ type Automation,
8
+ type AutomationRun,
9
+ } from "#/types/automation";
10
+ import type { InterfaceListInsights } from "#/manifests/types";
11
+
12
+ vi.mock("react-i18next", () => ({
13
+ useTranslation: () => ({
14
+ t: (key: string) => key,
15
+ i18n: { language: "en" },
16
+ }),
17
+ }));
18
+
19
+ vi.mock("#/context/navigation-context", () => ({
20
+ useNavigation: () => ({ navigate: vi.fn(), currentPath: "/" }),
21
+ }));
22
+
23
+ vi.mock("#/hooks/use-automation-permissions", () => ({
24
+ useAutomationPermissions: () => ({
25
+ canView: true,
26
+ canManage: true,
27
+ isLoading: false,
28
+ }),
29
+ useIsAutomationOwner: () => true,
30
+ }));
31
+
32
+ // The pinned package predates the `impact` field, so an entry carrying one is
33
+ // appended to the real catalog.
34
+ vi.mock("@openhands/extensions/automations", async (importOriginal) => {
35
+ const actual =
36
+ await importOriginal<typeof import("@openhands/extensions/automations")>();
37
+ return {
38
+ ...actual,
39
+ AUTOMATION_CATALOG: [
40
+ ...actual.AUTOMATION_CATALOG,
41
+ {
42
+ id: "widget-checker",
43
+ impact: {
44
+ basis: "completed-runs",
45
+ one: "1 widget check completed",
46
+ other: "{{count}} widget checks completed",
47
+ },
48
+ },
49
+ ] as typeof actual.AUTOMATION_CATALOG,
50
+ };
51
+ });
52
+
53
+ const automation: Automation = {
54
+ id: "automation-1",
55
+ name: "GitHub PR Reviewer",
56
+ prompt: "Review pull requests.",
57
+ enabled: true,
58
+ trigger: {
59
+ type: "event",
60
+ on: "pull_request.opened",
61
+ source: "github",
62
+ },
63
+ repository: "acme/repo",
64
+ model: "Claude",
65
+ created_at: "2026-01-01T00:00:00Z",
66
+ updated_at: "2026-01-01T00:00:00Z",
67
+ };
68
+
69
+ const insightsSpec = {
70
+ health: {
71
+ healthy: "Healthy",
72
+ failing: "Failing",
73
+ running: "Running",
74
+ disabled: "Disabled",
75
+ neverRun: "Never run",
76
+ checking: "Checking",
77
+ },
78
+ lastRun: { label: "Last run", never: "Never", justNow: "Just now" },
79
+ stats: { runs: "Runs", recentSuccess: "Success", averageDuration: "Avg" },
80
+ };
81
+
82
+ function createRun(overrides: Partial<AutomationRun> = {}): AutomationRun {
83
+ return {
84
+ id: "run-1",
85
+ status: AutomationRunStatus.COMPLETED,
86
+ conversation_id: null,
87
+ bash_command_id: null,
88
+ error_detail: null,
89
+ started_at: "2026-01-02T00:00:00Z",
90
+ completed_at: "2026-01-02T00:02:00Z",
91
+ ...overrides,
92
+ };
93
+ }
94
+
95
+ describe("AutomationListRow", () => {
96
+ it("renders title, trigger meta, and action icons in a two-line list row", () => {
97
+ render(
98
+ <AutomationListRow
99
+ automation={automation}
100
+ onToggle={vi.fn()}
101
+ onRunNow={vi.fn()}
102
+ onExport={vi.fn()}
103
+ onDelete={vi.fn()}
104
+ />,
105
+ );
106
+
107
+ expect(
108
+ screen.getByTestId("automation-list-row-automation-1"),
109
+ ).toBeInTheDocument();
110
+ expect(screen.getByText("GitHub PR Reviewer")).toBeInTheDocument();
111
+ expect(screen.getByText("pull_request.opened")).toBeInTheDocument();
112
+ expect(screen.getByText("GitHub")).toBeInTheDocument();
113
+ expect(
114
+ screen.queryByTestId("automation-pills-automation-1"),
115
+ ).not.toBeInTheDocument();
116
+ expect(
117
+ screen.getByTestId("automation-run-now-automation-1"),
118
+ ).toHaveAttribute("aria-label", "AUTOMATIONS$RUN_NOW");
119
+ expect(screen.getByTestId("automation-run-now-automation-1")).toHaveClass(
120
+ "size-8",
121
+ );
122
+ });
123
+
124
+ it("shows last-run status, relative time, and a sparkline when insights are present", () => {
125
+ const latestRun = createRun({
126
+ started_at: new Date(Date.now() - 10 * 60_000).toISOString(),
127
+ completed_at: new Date(Date.now() - 8 * 60_000).toISOString(),
128
+ });
129
+
130
+ render(
131
+ <AutomationListRow
132
+ automation={automation}
133
+ onToggle={vi.fn()}
134
+ onRunNow={vi.fn()}
135
+ onExport={vi.fn()}
136
+ onDelete={vi.fn()}
137
+ insights={{
138
+ spec: insightsSpec satisfies InterfaceListInsights,
139
+ state: {
140
+ summary: {
141
+ total: 4,
142
+ completedTotal: 4,
143
+ latestRun,
144
+ recentRuns: [latestRun],
145
+ recentSuccessRate: 1,
146
+ averageDurationMs: 120_000,
147
+ },
148
+ isLoading: false,
149
+ isError: false,
150
+ },
151
+ }}
152
+ />,
153
+ );
154
+
155
+ expect(
156
+ screen.getByTestId("automation-last-run-automation-1"),
157
+ ).toHaveTextContent("AUTOMATIONS$DETAIL$TIME_MINUTES_AGO");
158
+ expect(screen.getByTestId("run-status-icon-completed")).toBeInTheDocument();
159
+ expect(
160
+ screen.getByTestId("automation-activity-automation-1"),
161
+ ).toBeInTheDocument();
162
+ });
163
+
164
+ it("shows the value statement in the meta line", () => {
165
+ // Arrange — provenance joining back to a catalog entry with an impact
166
+ // declaration, and a summary carrying the lifetime completed count.
167
+ const latestRun = createRun();
168
+
169
+ // Act
170
+ render(
171
+ <AutomationListRow
172
+ automation={{
173
+ ...automation,
174
+ preset_metadata: {
175
+ template: { id: "widget-checker", version: "1.0.0", config: {} },
176
+ },
177
+ }}
178
+ onToggle={vi.fn()}
179
+ onRunNow={vi.fn()}
180
+ onExport={vi.fn()}
181
+ onDelete={vi.fn()}
182
+ insights={{
183
+ spec: insightsSpec satisfies InterfaceListInsights,
184
+ state: {
185
+ summary: {
186
+ total: 5,
187
+ completedTotal: 4,
188
+ latestRun,
189
+ recentRuns: [latestRun],
190
+ recentSuccessRate: 1,
191
+ averageDurationMs: 120_000,
192
+ },
193
+ isLoading: false,
194
+ isError: false,
195
+ },
196
+ }}
197
+ />,
198
+ );
199
+
200
+ // Assert
201
+ expect(
202
+ screen.getByTestId("automation-impact-automation-1"),
203
+ ).toHaveTextContent("4 widget checks completed");
204
+ });
205
+
206
+ it("opens the actions menu without triggering row navigation handlers", async () => {
207
+ const user = userEvent.setup();
208
+
209
+ render(
210
+ <AutomationListRow
211
+ automation={automation}
212
+ onToggle={vi.fn()}
213
+ onRunNow={vi.fn()}
214
+ onExport={vi.fn()}
215
+ onDelete={vi.fn()}
216
+ />,
217
+ );
218
+
219
+ await user.click(
220
+ screen.getByRole("button", { name: "AUTOMATIONS$ACTIONS_MENU" }),
221
+ );
222
+
223
+ expect(screen.getByText("COMMON$VIEW")).toBeInTheDocument();
224
+ });
225
+ });
__tests__/components/automations/automation-view-toggle.test.tsx ADDED
@@ -0,0 +1,46 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import { render, screen } from "@testing-library/react";
2
+ import userEvent from "@testing-library/user-event";
3
+ import { describe, expect, it, vi } from "vitest";
4
+ import { AutomationViewToggle } from "#/components/features/automations/automation-view-toggle";
5
+
6
+ vi.mock("react-i18next", () => ({
7
+ useTranslation: () => ({ t: (key: string) => key }),
8
+ }));
9
+
10
+ describe("AutomationViewToggle", () => {
11
+ it("opens a menu from the icon trigger and switches to list view", async () => {
12
+ const user = userEvent.setup();
13
+ const onChange = vi.fn();
14
+
15
+ render(<AutomationViewToggle view="grid" onChange={onChange} />);
16
+
17
+ const trigger = screen.getByTestId("automations-view-toggle");
18
+ expect(trigger).toHaveClass("size-9");
19
+ expect(trigger).toHaveAttribute("aria-haspopup", "menu");
20
+
21
+ await user.click(trigger);
22
+ await user.click(screen.getByTestId("automations-view-toggle-list"));
23
+
24
+ expect(onChange).toHaveBeenCalledWith("list");
25
+ });
26
+
27
+ it("does not open the menu or fire onChange when disabled", async () => {
28
+ // Arrange
29
+ const user = userEvent.setup();
30
+ const onChange = vi.fn();
31
+ render(
32
+ <AutomationViewToggle view="grid" onChange={onChange} disabled />,
33
+ );
34
+ const trigger = screen.getByTestId("automations-view-toggle");
35
+
36
+ // Act — try to open the menu
37
+ await user.click(trigger);
38
+
39
+ // Assert — menu items never render and onChange stays untouched
40
+ expect(trigger).toBeDisabled();
41
+ expect(
42
+ screen.queryByTestId("automations-view-toggle-list"),
43
+ ).not.toBeInTheDocument();
44
+ expect(onChange).not.toHaveBeenCalled();
45
+ });
46
+ });
__tests__/components/automations/backend-not-configured.test.tsx ADDED
@@ -0,0 +1,49 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import { describe, it, expect, vi } from "vitest";
2
+ import { render, screen, fireEvent } from "@testing-library/react";
3
+ import { BackendUnavailable } from "#/components/features/automations/backend-not-configured";
4
+ import { I18nKey } from "#/i18n/declaration";
5
+
6
+ vi.mock("react-i18next", () => ({
7
+ useTranslation: () => ({
8
+ t: (key: string) => {
9
+ const translations: Record<string, string> = {
10
+ [I18nKey.AUTOMATIONS$BACKEND_UNAVAILABLE_TITLE]:
11
+ "Automations Unavailable",
12
+ [I18nKey.AUTOMATIONS$BACKEND_UNAVAILABLE_MESSAGE]:
13
+ "The automations backend is not available right now.",
14
+ [I18nKey.AUTOMATIONS$BACKEND_UNAVAILABLE_RETRY]: "Retry",
15
+ };
16
+ return translations[key] || key;
17
+ },
18
+ }),
19
+ }));
20
+
21
+ describe("BackendUnavailable", () => {
22
+ it("renders the unavailable message", () => {
23
+ const onRetry = vi.fn();
24
+ render(<BackendUnavailable onRetry={onRetry} />);
25
+
26
+ expect(screen.getByText("Automations Unavailable")).toBeInTheDocument();
27
+ expect(
28
+ screen.getByText("The automations backend is not available right now."),
29
+ ).toBeInTheDocument();
30
+ });
31
+
32
+ it("displays the retry button", () => {
33
+ const onRetry = vi.fn();
34
+ render(<BackendUnavailable onRetry={onRetry} />);
35
+
36
+ const retryButton = screen.getByRole("button", { name: "Retry" });
37
+ expect(retryButton).toBeInTheDocument();
38
+ });
39
+
40
+ it("calls onRetry when retry button is clicked", () => {
41
+ const onRetry = vi.fn();
42
+ render(<BackendUnavailable onRetry={onRetry} />);
43
+
44
+ const retryButton = screen.getByRole("button", { name: "Retry" });
45
+ fireEvent.click(retryButton);
46
+
47
+ expect(onRetry).toHaveBeenCalledTimes(1);
48
+ });
49
+ });
__tests__/components/automations/build-automation-pills.test.tsx ADDED
@@ -0,0 +1,82 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import { render, screen } from "@testing-library/react";
2
+ import { describe, expect, it } from "vitest";
3
+ import { buildAutomationMetadataPills } from "#/components/features/automations/build-automation-pills";
4
+ import type { SkillCardPill } from "#/components/features/skills/skill-card-pill-row";
5
+ import type { Automation } from "#/types/automation";
6
+
7
+ function buildAutomation(overrides: Partial<Automation> = {}): Automation {
8
+ return {
9
+ id: "automation-1",
10
+ name: "Triage",
11
+ prompt: "Triage the issue.",
12
+ enabled: true,
13
+ trigger: { type: "event", on: "issue.updated", source: "linear" },
14
+ created_at: "2026-01-01T00:00:00Z",
15
+ updated_at: "2026-01-01T00:00:00Z",
16
+ ...overrides,
17
+ };
18
+ }
19
+
20
+ function renderPills(pills: SkillCardPill[]) {
21
+ render(
22
+ <div>
23
+ {pills.map((pill) => (
24
+ <span key={pill.id} data-testid={`pill-${pill.id}`}>
25
+ {pill.node}
26
+ </span>
27
+ ))}
28
+ </div>,
29
+ );
30
+ }
31
+
32
+ describe("buildAutomationMetadataPills", () => {
33
+ it("puts the event and source on separate pills", () => {
34
+ const pills = buildAutomationMetadataPills(buildAutomation(), "unused");
35
+
36
+ expect(pills.map((pill) => pill.id)).toEqual([
37
+ "event-trigger",
38
+ "event-source",
39
+ ]);
40
+
41
+ renderPills(pills);
42
+
43
+ expect(screen.getByTestId("pill-event-trigger")).toHaveTextContent(
44
+ "issue.updated",
45
+ );
46
+ expect(screen.getByTestId("pill-event-trigger")).not.toHaveTextContent(
47
+ "linear",
48
+ );
49
+ expect(screen.getByTestId("pill-event-source")).toHaveTextContent("Linear");
50
+ expect(screen.getByTestId("pill-event-source").firstElementChild).toHaveClass(
51
+ "py-0.5",
52
+ );
53
+ expect(screen.getByTestId("automation-source-logo")).toBeInTheDocument();
54
+ });
55
+
56
+ it("renders a fallback icon when the source is not in the catalog", () => {
57
+ renderPills(
58
+ buildAutomationMetadataPills(
59
+ buildAutomation({
60
+ trigger: { type: "event", on: "alert.fired", source: "custom-pager" },
61
+ }),
62
+ "unused",
63
+ ),
64
+ );
65
+
66
+ expect(screen.getByTestId("pill-event-source")).toHaveTextContent(
67
+ "Custom-Pager",
68
+ );
69
+ expect(screen.getByTestId("automation-source-logo")).toBeInTheDocument();
70
+ });
71
+
72
+ it("omits the source pill when the event has no source", () => {
73
+ const pills = buildAutomationMetadataPills(
74
+ buildAutomation({
75
+ trigger: { type: "event", on: "pull_request.opened" },
76
+ }),
77
+ "unused",
78
+ );
79
+
80
+ expect(pills.map((pill) => pill.id)).toEqual(["event-trigger"]);
81
+ });
82
+ });
__tests__/components/automations/create-instructions.test.tsx ADDED
@@ -0,0 +1,123 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import React from "react";
2
+ import { render, screen, waitFor } from "@testing-library/react";
3
+ import userEvent from "@testing-library/user-event";
4
+ import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
5
+ import {
6
+ NavigationProvider,
7
+ type NavigationContextValue,
8
+ } from "#/context/navigation-context";
9
+ import { CreateInstructions } from "#/components/features/automations/create-instructions";
10
+ import { I18nKey } from "#/i18n/declaration";
11
+ import { useConversationStore } from "#/stores/conversation-store";
12
+ import * as telemetry from "#/services/telemetry";
13
+
14
+ vi.mock("#/hooks/query/use-settings", () => ({
15
+ useSettings: () => ({ data: { user_consents_to_analytics: true } }),
16
+ }));
17
+
18
+ vi.mock("react-i18next", () => ({
19
+ useTranslation: () => ({
20
+ t: (key: string) => {
21
+ const translations: Record<string, string> = {
22
+ [I18nKey.AUTOMATIONS$CREATE_AUTOMATION_BUTTON]: "Create Automation",
23
+ [I18nKey.AUTOMATIONS$CREATE_AUTOMATION_PROMPT]: "Create an automation",
24
+ [I18nKey.AUTOMATIONS$CREATE_INSTRUCTIONS_GUIDANCE]:
25
+ "Include what the automation should do, when it should run, and where to send the results.",
26
+ };
27
+ return translations[key] || key;
28
+ },
29
+ }),
30
+ Trans: ({
31
+ i18nKey,
32
+ components,
33
+ }: {
34
+ i18nKey: string;
35
+ components?: Record<string, React.ReactElement>;
36
+ }) => {
37
+ if (i18nKey !== I18nKey.AUTOMATIONS$EMPTY_OPTION_CONVERSATION_DESC) {
38
+ return i18nKey;
39
+ }
40
+
41
+ return (
42
+ <>
43
+ Start a new conversation and tell OpenHands to{" "}
44
+ {components?.example
45
+ ? React.cloneElement(
46
+ components.example,
47
+ {},
48
+ <>
49
+ {components.cmd
50
+ ? React.cloneElement(
51
+ components.cmd,
52
+ {},
53
+ "Create an automation",
54
+ )
55
+ : null}
56
+ {components.punct
57
+ ? React.cloneElement(components.punct, {}, ".")
58
+ : null}
59
+ </>,
60
+ )
61
+ : null}
62
+ </>
63
+ );
64
+ },
65
+ }));
66
+
67
+ function renderCreateInstructions() {
68
+ const value: NavigationContextValue = {
69
+ currentPath: "/automations",
70
+ conversationId: null,
71
+ isNavigating: false,
72
+ navigate: vi.fn(),
73
+ };
74
+
75
+ const result = render(
76
+ <NavigationProvider value={value}>
77
+ <CreateInstructions />
78
+ </NavigationProvider>,
79
+ );
80
+
81
+ return { ...result, navigate: value.navigate };
82
+ }
83
+
84
+ describe("CreateInstructions", () => {
85
+ let captureMock: ReturnType<typeof vi.spyOn>;
86
+
87
+ beforeEach(() => {
88
+ captureMock = vi
89
+ .spyOn(telemetry, "trackEvent")
90
+ .mockResolvedValue(undefined);
91
+ useConversationStore.setState({ messageToSend: null });
92
+ });
93
+
94
+ afterEach(() => {
95
+ captureMock.mockRestore();
96
+ });
97
+
98
+ it("captures automation_created_button with the active backend kind when Create Automation is clicked", async () => {
99
+ const user = userEvent.setup();
100
+ renderCreateInstructions();
101
+
102
+ await user.click(screen.getByTestId("automations-create-automation"));
103
+
104
+ expect(captureMock).toHaveBeenCalledWith(
105
+ "automation_created_button",
106
+ expect.objectContaining({ backend_kind: "local" }),
107
+ );
108
+ });
109
+
110
+ it("navigates to conversations with a prefilled prompt when Create Automation is clicked", async () => {
111
+ const user = userEvent.setup();
112
+ const setMessageToSend = vi.fn();
113
+ useConversationStore.setState({ setMessageToSend });
114
+ const { navigate } = renderCreateInstructions();
115
+
116
+ await user.click(screen.getByTestId("automations-create-automation"));
117
+
118
+ expect(navigate).toHaveBeenCalledWith("/conversations");
119
+ await waitFor(() => {
120
+ expect(setMessageToSend).toHaveBeenCalledWith("Create an automation");
121
+ });
122
+ });
123
+ });