mirror of
https://github.com/multica-ai/multica.git
synced 2026-08-12 19:06:06 +02:00
* feat(drafts): unified draft lifecycle + upload ownership inversion (MUL-5181) Unify how every composer preserves unsent work, sends, and handles uploads. L1 foundation (packages/core/drafts): - createDraftStore factory + self-registering cleanup-registry replacing the hand-maintained WORKSPACE_SCOPED_KEYS list; register-all-drafts guarantees registration completeness. Fixes the confirmed cross-user draft leak (persistence + in-memory) on logout / workspace delete. L3 send paradigm: - useComposerSubmit: one await-then-render contract (lock/spin, keep-on-fail, clear-on-success, single-flight, submit-time upload-gate), adopted by comment/reply/edit, create-issue, quick-create, and chat. Per-surface: - Comment/Reply/Edit: attachments moved into the persisted draft. - Create Issue: draft split into shared/manual/agent/activeMode with non-destructive mode switching + migration for old flat drafts. - Chat: optimistic send converted to await-then-render (kept server-driven cancel restore_to_input); chat draft keys registered for cleanup. L2 upload coordinator (ownership inversion, Linear-validated shape): - upload-coordinator + DraftUpload placeholder: uploads owned by a module coordinator that outlives the component, state persisted in the draft; AbortController + abort-on-logout; interrupted-on-reload. Comment surface fully wired. Create-issue/chat upload wiring is a documented residual. Verified: core + views typecheck clean; core 1064 + views 2928 tests pass. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * fix(drafts): close three review gaps in the unified draft lifecycle (MUL-5181) 1. Logout resurrection: reset in-memory draft stores BEFORE removing their persisted keys — each reset is a setState and persist writes it straight back under the still-active slug, so the old order re-created the deleted keys. The issue draft store's reset is now a full reset including lastAssignee, which clearDraft deliberately re-seeds and would otherwise hand the previous user's last-picked assignee to the next login. 2. Submit gate blind spot: the composer gate now also reads the draft's coordinator-owned upload placeholders (hasUploadingDraft). A composer reopened over a still-in-flight upload could previously send past the editor-only gate, clearing the draft out from under the settling upload. 3. Attachment binding returns to reference-filtering: a submit binds only uploads the body references, so deleting an inline image really unbinds it. An upload that settles after its mount died gets its markdown link written back into the body instead — via the reopened composer's live editor (new ContentEditorRef.insertMarkdownAtEnd) or appended to the persisted draft (new appendToDraftContent) — so close-surviving files stay visible, deletable, and honestly bound. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * fix(drafts): harden upload write-back delivery after independent review Review of the previous commit (fresh-context reviewer + probe against real @tiptap/react) found the write-back could still lose a file: - insertMarkdownAtEnd now returns a boolean: the imperative handle exists from first commit but the Tiptap instance arrives in a passive effect, so an insert in that window (or after destroy) no-ops. Callers previously assumed it landed. - Write-back is now confirmed delivery (deliverFinishedUpload): insert into the live editor and, on success, persist the same body as insurance against the debounced emit being dropped by a quick unmount; append to the store only when NO composer is mounted (a mounted editor's first emit would erase a store-only append); retry while a mounted composer's instance is still warming up. Every attempt re-checks the generation guard and the body reference. - mountedRef flips in a layout effect: React nulls the child editor ref in the unmount commit, and a settle in the gap before passive cleanup saw "mounted" with no editor left to swap. - uploadAndInsertFile guards editor.isDestroyed after the await: now that uploads outlive mounts, the swap/remove paths could dispatch against a destroyed EditorView and escape as an unhandled rejection. - Tests: the reopened-composer test now asserts the editor actually received the insert (it previously passed with liveEditors disabled), plus a warming-up retry case; the mock editor mirrors isDestroyed. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * feat(drafts): roll coordinated uploads out to issue-create and chat (MUL-5181 L2) Completes the upload-ownership layer for every composer surface. The generic engine is extracted from the comment implementation into editor/use-coordinated-uploads (UploadDraftBinding adapter: store-backed accessors + registry key + body append), and use-comment-uploads becomes a thin binding over it — behavior unchanged, all comment tests green. Issue-create (manual + agent panels): - shared.attachments migrates Attachment[] -> DraftUpload[]; load normalizes legacy bare rows to `uploaded` and coerces stale `uploading` to `interrupted`. - Uploads are coordinator-owned: placeholder at pick time, survives dialog close, aborts on logout, chips for uploading/failed/interrupted, combined gate on Create and both mode-switch actions. - Write-back targets the body of the MODE that started the upload (manual description vs agent prompt); mount-time prune keeps placeholders and drops only unreferenced `uploaded` entries. Chat (tab + floating window): - inputDraftAttachments migrates to DraftUpload[] with load-time normalization; new store ops (add/settle/fail/remove upload, append-to- draft) mirror the comment store. - ChatInput adopts the engine; the upload target is snapshotted at pick time via resolveUploadTarget so a file dropped while the editor is pinned to a previous session's document files under THAT draft. - uploadMapRef is gone — the draft's uploads are the single binding source, reference-filtered at send. Hosts no longer own transport: onUploadFile prop becomes uploadEnabled, and the controller/window drop uploadWithToast. - commitDraft prunes only `uploaded` entries the body no longer references; placeholders survive keystrokes (chips are their only UI). Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * fix(drafts): harden L2 rollout after independent review - attachmentToDraftUpload now strips the response-scoped signed download_url before the row is persisted (draft uploads survive restarts; a stale signature 403s the preview on reopen). Covers comments, issue-create, and chat in one place; issue-create's settle reuses the helper, and the Signature assertion the rollout had dropped is restored. - chat's live-editor registry follows the LOADED draft key (reactive mirror of editorDraftKeyRef): a settle for draft B must not insert into an editor still pinned to draft A's document. - removeUpload aborts an in-flight request before dropping its placeholder. - issue-create hasDraft counts only uploaded/uploading entries so a failed remnant can't pin the sidebar draft dot forever. - Tests: mutation-proof coverage for the two placeholder-preservation rules (create-issue mount prune, chat commitDraft prune) — both previously survived rule inversion; direct core tests for the five new chat store upload ops incl. persistence and signed-URL stripping; quick-create test gets the editor i18n namespace; dead uploadWithToast scaffolding removed from both modal tests; chat-input mock aligned with the real append semantics. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * fix(drafts): close third-round review gaps in the upload engine - The live-editor registry registers in a layout effect: chat's adopt swaps the editor's document and loaded key synchronously during commit, and a passive re-registration one task later left a settle window where the old key mapped to an editor already holding another draft's document. The registry key is also built only when a binding exists. - removeUpload aborts only a request THIS surface tracks as `uploading` (guarded before the abort), with the comment now honest about the path being defensive — no current chip exposes ✕ mid-upload. - Mutation-proof test for the loaded-key registry rule: a dead mount's settle for a pinned draft must insert into the editor HOLDING it, not the selected one (verified to fail with the registry keyed by selection). - hasDraft upload semantics pinned by tests (uploaded/uploading count; failed/interrupted remnants don't pin the sidebar dot). - Dead scaffolding dropped: identity use-file-upload mocks and a redundant assertion in the modal tests. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * fix(drafts): stale-submit draft guard + registry layout timing (review BLOCKED items) Blocker 1 — a submit that outlives its composer may only consume the draft it submitted (MUL-5181 P0). Every accepted-submit clear is now guarded: - create-issue / quick-create snapshot the singleton draft's object identity at submit; a dead panel clears (and records last-assignee/mode) only if the draft is untouched, and never runs close/reset effects. A replaced draft B typed after close survives a late success of draft A. - comment / reply / edit snapshot the per-key draft entry; a dead composer clears only the exact entry it submitted. - chat snapshots the sent slot's value; a dead mount's commitInput clears only an unreplaced draft. Mutation-verified tests for the create panels and comments (guard inverted => tests fail), plus untouched-draft control cases. Blocker 2 — the live-editor registry is now genuinely registered in a layout effect. The prior commit claimed this fix but a test-time `git checkout --` discarded the unstaged engine edits before committing; re-applied: layout registration, binding-gated registry key, and the tracked-only abort in removeUpload. New registry timing test captures the registry from a parent layout effect across a key switch — verified to fail with passive registration. Also: `multica:chat:selectedProjectId` joins the workspace-scoped cleanup list (was leaking across logout; flagged as a pre-existing risk). Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * fix(drafts): mounted submits also clear only the draft they submitted The stale-submit snapshot guard previously protected only dead composers; a mounted one cleared unconditionally on success. But the editor stays interactive during a request (Tiptap cannot toggle editable post-mount), so text typed while draft A was in flight was wiped by A's success. The guard is now unconditional across every surface: success consumes exactly the submitted snapshot, and any later edit survives. - create-issue / quick-create: the editor's pending debounce is flushed into the store BEFORE snapshotting (a late flush of pre-submit typing must not read as a mid-flight edit); a touched draft skips clear AND close/reset — the dialog stays open on the newer work. Untouched behavior unchanged. - comment / reply / edit: same flush + snapshot; a touched entry keeps both the store draft and the editor content (edit mode stays open on it). - chat: commitInput's value compare now applies while mounted too, and the editor is scrubbed only for an untouched draft. - use-composer-submit docs no longer claim "editor locked": they state the real contract — send affordance locks, edits after submit survive. Regression tests: mounted mid-flight-edit cases for manual create (incl. "dialog must not close over draft B"), quick create, comment, and chat, plus mounted-untouched controls. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * fix(drafts): idempotent draft writes so a tab switch cannot resurrect a posted comment Final-review blocker: the comment/reply visibilitychange/pagehide flush re-writes IDENTICAL content on every tab switch, and writeDraft minted a new entry object each call — the stale-submit guard's identity compare then read a mid-flight tab switch as "edited during the request", kept the posted comment's draft alive, and left Send enabled for a duplicate. - writeDraft is now a no-op when content and uploads are unchanged (also kills a spurious persist write per tab switch). Regression tests: entry identity preserved on identical setDraft (core), and the reproduced tab-switch-mid-send scenario clears the posted draft (views) — verified to fail with the idempotence removed. - onAccepted now flushes the editor's pending debounce before judging `untouched` on every surface, so typing still inside the debounce window counts as a mid-flight edit instead of being scrubbed. - create-issue records last-assignee/mode from the SUBMITTED values, outside the untouched gate — a created issue updates the preference even when the dialog stays open on newer edits. - Stale guard comments corrected in both create panels; the use-composer-submit docstring no longer claims project/feedback were migrated (they still hand-roll await-then-clear; registered debt). Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
1502 lines
48 KiB
TypeScript
1502 lines
48 KiB
TypeScript
import { afterEach, describe, expect, it, vi } from "vitest";
|
|
import { ApiClient, ApiError, CHAT_DRAFT_RESTORE_CAPABILITY } from "./client";
|
|
|
|
afterEach(() => {
|
|
vi.unstubAllGlobals();
|
|
});
|
|
|
|
describe("ApiClient pull-request response schema", () => {
|
|
const validPR = {
|
|
id: "pr-1",
|
|
provider: "github",
|
|
workspace_id: "ws-1",
|
|
repo_owner: "acme",
|
|
repo_name: "widget",
|
|
number: 7,
|
|
title: "MUL-1: fix",
|
|
state: "open",
|
|
html_url: "https://github.example/acme/widget/pull/7",
|
|
branch: "fix/mul-1",
|
|
author_login: "octocat",
|
|
author_avatar_url: null,
|
|
merged_at: null,
|
|
closed_at: null,
|
|
pr_created_at: "2026-01-01T00:00:00Z",
|
|
pr_updated_at: "2026-01-01T00:00:00Z",
|
|
snapshot_available: true,
|
|
checks_rollup: "failure",
|
|
failed_check_names: ["backend"],
|
|
};
|
|
|
|
it("parses and defaults a valid pull-request list", async () => {
|
|
vi.stubGlobal(
|
|
"fetch",
|
|
vi.fn().mockResolvedValue(
|
|
new Response(JSON.stringify({ pull_requests: [validPR] }), {
|
|
status: 200,
|
|
headers: { "Content-Type": "application/json" },
|
|
}),
|
|
),
|
|
);
|
|
|
|
const result = await new ApiClient("https://api.example.test").listIssuePullRequests("issue-1");
|
|
expect(result.pull_requests[0]).toMatchObject({
|
|
id: "pr-1",
|
|
failed_check_names: ["backend"],
|
|
checks_total: 0,
|
|
});
|
|
});
|
|
|
|
it("falls back safely when failed_check_names is malformed", async () => {
|
|
vi.stubGlobal(
|
|
"fetch",
|
|
vi.fn().mockResolvedValue(
|
|
new Response(
|
|
JSON.stringify({
|
|
pull_requests: [{ ...validPR, failed_check_names: "backend" }],
|
|
}),
|
|
{
|
|
status: 200,
|
|
headers: { "Content-Type": "application/json" },
|
|
},
|
|
),
|
|
),
|
|
);
|
|
|
|
await expect(
|
|
new ApiClient("https://api.example.test").listIssuePullRequests("issue-1"),
|
|
).resolves.toEqual({ pull_requests: [] });
|
|
});
|
|
});
|
|
|
|
describe("ApiClient server Table query", () => {
|
|
it("posts the canonical query to the group and branch endpoints", async () => {
|
|
const fetchMock = vi
|
|
.fn()
|
|
.mockResolvedValueOnce(
|
|
new Response(
|
|
JSON.stringify({
|
|
query_fingerprint: "sha256:query",
|
|
total: 1001,
|
|
groups: [
|
|
{
|
|
key: "status:todo",
|
|
value: { kind: "status", status: "todo" },
|
|
count: 1001,
|
|
},
|
|
],
|
|
next_cursor: null,
|
|
}),
|
|
{ status: 200, headers: { "Content-Type": "application/json" } },
|
|
),
|
|
)
|
|
.mockResolvedValueOnce(
|
|
new Response(
|
|
JSON.stringify({
|
|
query_fingerprint: "sha256:query",
|
|
group_key: "status:todo",
|
|
parent_id: null,
|
|
total: 0,
|
|
rows: [],
|
|
branch_total: 0,
|
|
next_cursor: "next-page",
|
|
}),
|
|
{ status: 200, headers: { "Content-Type": "application/json" } },
|
|
),
|
|
)
|
|
.mockResolvedValueOnce(
|
|
new Response(
|
|
JSON.stringify({
|
|
query_fingerprint: "sha256:query",
|
|
total: 1001,
|
|
facets: [
|
|
{
|
|
kind: "status",
|
|
values: [
|
|
{ key: "todo", count: 501 },
|
|
{ key: "done", count: 500 },
|
|
],
|
|
},
|
|
],
|
|
}),
|
|
{ status: 200, headers: { "Content-Type": "application/json" } },
|
|
),
|
|
);
|
|
vi.stubGlobal("fetch", fetchMock);
|
|
|
|
const client = new ApiClient("https://api.example.test");
|
|
const query = {
|
|
scope: { kind: "workspace" as const },
|
|
filters: { priorities: ["high" as const] },
|
|
sort: { field: "title" as const, direction: "asc" as const },
|
|
};
|
|
|
|
await expect(
|
|
client.listIssueTableGroups({
|
|
query,
|
|
group: { kind: "status" },
|
|
page: { limit: 100, cursor: null },
|
|
}),
|
|
).resolves.toMatchObject({ total: 1001, groups: [{ count: 1001 }] });
|
|
await expect(
|
|
client.listIssueTableRows({
|
|
query,
|
|
group: { kind: "status" },
|
|
group_key: "status:todo",
|
|
hierarchy: { enabled: true },
|
|
parent_id: null,
|
|
page: { limit: 50, cursor: null },
|
|
}),
|
|
).resolves.toMatchObject({ branch_total: 0, next_cursor: "next-page" });
|
|
await expect(
|
|
client.listIssueTableFacets({
|
|
query,
|
|
facets: [{ kind: "status" }],
|
|
}),
|
|
).resolves.toMatchObject({
|
|
total: 1001,
|
|
facets: [{ values: [{ key: "todo", count: 501 }, { key: "done", count: 500 }] }],
|
|
});
|
|
|
|
expect(fetchMock.mock.calls.map(([url]) => url)).toEqual([
|
|
"https://api.example.test/api/issues/table/groups",
|
|
"https://api.example.test/api/issues/table/rows",
|
|
"https://api.example.test/api/issues/table/facets",
|
|
]);
|
|
expect(fetchMock.mock.calls[0]?.[1]).toMatchObject({
|
|
method: "POST",
|
|
body: expect.stringContaining('"kind":"status"'),
|
|
});
|
|
});
|
|
|
|
it("falls back safely when Table responses are malformed", async () => {
|
|
const fetchMock = vi.fn().mockImplementation(() =>
|
|
Promise.resolve(
|
|
new Response(JSON.stringify({ total: "not-a-number" }), {
|
|
status: 200,
|
|
headers: { "Content-Type": "application/json" },
|
|
}),
|
|
),
|
|
);
|
|
vi.stubGlobal("fetch", fetchMock);
|
|
|
|
const client = new ApiClient("https://api.example.test");
|
|
const query = {
|
|
scope: { kind: "workspace" as const },
|
|
filters: {},
|
|
sort: { field: "position" as const, direction: "asc" as const },
|
|
};
|
|
|
|
await expect(
|
|
client.listIssueTableGroups({
|
|
query,
|
|
group: { kind: "status" },
|
|
page: { limit: 100, cursor: null },
|
|
}),
|
|
).resolves.toEqual({
|
|
query_fingerprint: "",
|
|
total: 0,
|
|
groups: [],
|
|
next_cursor: null,
|
|
});
|
|
await expect(
|
|
client.listIssueTableRows({
|
|
query,
|
|
group: { kind: "none" },
|
|
group_key: null,
|
|
hierarchy: { enabled: true },
|
|
parent_id: null,
|
|
page: { limit: 50, cursor: null },
|
|
}),
|
|
).resolves.toEqual({
|
|
query_fingerprint: "",
|
|
group_key: null,
|
|
parent_id: null,
|
|
total: 0,
|
|
rows: [],
|
|
branch_total: 0,
|
|
next_cursor: null,
|
|
});
|
|
await expect(
|
|
client.listIssueTableFacets({
|
|
query,
|
|
facets: [{ kind: "status" }],
|
|
}),
|
|
).resolves.toEqual({
|
|
query_fingerprint: "",
|
|
total: 0,
|
|
facets: [],
|
|
});
|
|
});
|
|
|
|
it("preserves future Table status and actor enum values", async () => {
|
|
const responses = [
|
|
{
|
|
query_fingerprint: "sha256:future-status",
|
|
total: 1,
|
|
groups: [
|
|
{
|
|
key: "status:paused",
|
|
value: { kind: "status", status: "paused" },
|
|
count: 1,
|
|
},
|
|
],
|
|
next_cursor: null,
|
|
},
|
|
{
|
|
query_fingerprint: "sha256:future-actor",
|
|
total: 1,
|
|
groups: [
|
|
{
|
|
key: "service:bot-1",
|
|
value: {
|
|
kind: "assignee",
|
|
actor: { type: "service", id: "bot-1" },
|
|
},
|
|
count: 1,
|
|
},
|
|
],
|
|
next_cursor: null,
|
|
},
|
|
];
|
|
const fetchMock = vi.fn().mockImplementation(() =>
|
|
Promise.resolve(
|
|
new Response(JSON.stringify(responses.shift()), {
|
|
status: 200,
|
|
headers: { "Content-Type": "application/json" },
|
|
}),
|
|
),
|
|
);
|
|
vi.stubGlobal("fetch", fetchMock);
|
|
|
|
const client = new ApiClient("https://api.example.test");
|
|
const query = {
|
|
scope: { kind: "workspace" as const },
|
|
filters: {},
|
|
sort: { field: "position" as const, direction: "asc" as const },
|
|
};
|
|
|
|
await expect(
|
|
client.listIssueTableGroups({ query, group: { kind: "status" } }),
|
|
).resolves.toMatchObject({
|
|
total: 1,
|
|
groups: [{ value: { kind: "status", status: "paused" } }],
|
|
});
|
|
await expect(
|
|
client.listIssueTableGroups({ query, group: { kind: "assignee" } }),
|
|
).resolves.toMatchObject({
|
|
total: 1,
|
|
groups: [
|
|
{ value: { kind: "assignee", actor: { type: "service", id: "bot-1" } } },
|
|
],
|
|
});
|
|
});
|
|
|
|
it("parses compound lane descriptors and posts the additive union", async () => {
|
|
const fetchMock = vi.fn().mockResolvedValue(
|
|
new Response(
|
|
JSON.stringify({
|
|
query_fingerprint: "sha256:compound",
|
|
total: 2,
|
|
groups: [
|
|
{
|
|
key: "parent:parent-1",
|
|
value: {
|
|
kind: "parent",
|
|
parent_id: "parent-1",
|
|
parent: {
|
|
id: "parent-1",
|
|
number: 10,
|
|
identifier: "MUL-10",
|
|
title: "Parent",
|
|
status: "todo",
|
|
},
|
|
value_state: "value",
|
|
},
|
|
count: 2,
|
|
secondary_groups: [
|
|
{
|
|
key: "compound:opaque:status:todo",
|
|
value: { kind: "status", status: "todo" },
|
|
count: 2,
|
|
},
|
|
],
|
|
},
|
|
],
|
|
next_cursor: null,
|
|
}),
|
|
{ status: 200, headers: { "Content-Type": "application/json" } },
|
|
),
|
|
);
|
|
vi.stubGlobal("fetch", fetchMock);
|
|
const client = new ApiClient("https://api.example.test");
|
|
const query = {
|
|
scope: { kind: "workspace" as const },
|
|
filters: {},
|
|
sort: { field: "position" as const, direction: "asc" as const },
|
|
};
|
|
|
|
await expect(
|
|
client.listIssueTableGroups({
|
|
query,
|
|
group: {
|
|
kind: "compound",
|
|
primary: "parent",
|
|
secondary: "status",
|
|
secondary_values: ["todo"],
|
|
},
|
|
}),
|
|
).resolves.toMatchObject({
|
|
groups: [
|
|
{
|
|
value: { kind: "parent", parent: { title: "Parent" } },
|
|
secondary_groups: [
|
|
{ value: { kind: "status", status: "todo" }, count: 2 },
|
|
],
|
|
},
|
|
],
|
|
});
|
|
expect(fetchMock.mock.calls[0]?.[1]).toMatchObject({
|
|
body: expect.stringContaining(
|
|
'"kind":"compound","primary":"parent","secondary":"status","secondary_values":["todo"]',
|
|
),
|
|
});
|
|
});
|
|
});
|
|
|
|
describe("ApiClient issue move intent", () => {
|
|
it("posts relative anchors without a client-authored position", async () => {
|
|
const fetchMock = vi.fn().mockResolvedValue(
|
|
new Response(JSON.stringify({ id: "issue-1", position: 15 }), {
|
|
status: 200,
|
|
headers: { "Content-Type": "application/json" },
|
|
}),
|
|
);
|
|
vi.stubGlobal("fetch", fetchMock);
|
|
const client = new ApiClient("https://api.example.test");
|
|
|
|
await client.moveIssue("issue-1", {
|
|
status: "in_progress",
|
|
before_id: "issue-0",
|
|
after_id: "issue-2",
|
|
});
|
|
|
|
expect(fetchMock).toHaveBeenCalledWith(
|
|
"https://api.example.test/api/issues/issue-1/move",
|
|
expect.objectContaining({
|
|
method: "POST",
|
|
body: JSON.stringify({
|
|
status: "in_progress",
|
|
before_id: "issue-0",
|
|
after_id: "issue-2",
|
|
}),
|
|
}),
|
|
);
|
|
});
|
|
});
|
|
|
|
describe("ApiClient workspace working agents", () => {
|
|
it("supports an optional source-type filter", async () => {
|
|
const payload = [
|
|
{
|
|
id: "agent-1",
|
|
name: "Agent 1",
|
|
avatar_url: null,
|
|
running_task_count: 2,
|
|
issue_ids: ["issue-1"],
|
|
},
|
|
];
|
|
const fetchMock = vi.fn().mockImplementation(() =>
|
|
Promise.resolve(
|
|
new Response(JSON.stringify(payload), {
|
|
status: 200,
|
|
headers: { "Content-Type": "application/json" },
|
|
}),
|
|
),
|
|
);
|
|
vi.stubGlobal("fetch", fetchMock);
|
|
|
|
const client = new ApiClient("https://api.example.test");
|
|
await expect(
|
|
client.getWorkspaceWorkingAgents("issue", "assigned"),
|
|
).resolves.toEqual(payload);
|
|
await expect(
|
|
client.getWorkspaceWorkingAgents("issue"),
|
|
).resolves.toEqual(payload);
|
|
await expect(client.getWorkspaceWorkingAgents()).resolves.toEqual(payload);
|
|
expect(fetchMock.mock.calls.map(([url]) => url)).toEqual([
|
|
"https://api.example.test/api/working-agents?type=issue&scope=mine&relation=assigned",
|
|
"https://api.example.test/api/working-agents?type=issue",
|
|
"https://api.example.test/api/working-agents",
|
|
]);
|
|
});
|
|
});
|
|
|
|
describe("ApiClient label response schemas", () => {
|
|
it("falls back safely for malformed label catalog, label, and resource responses", async () => {
|
|
const fetchMock = vi.fn().mockImplementation(() =>
|
|
Promise.resolve(
|
|
new Response(JSON.stringify({ labels: "not-an-array", total: "not-a-number" }), {
|
|
status: 200,
|
|
headers: { "Content-Type": "application/json" },
|
|
}),
|
|
),
|
|
);
|
|
vi.stubGlobal("fetch", fetchMock);
|
|
|
|
const client = new ApiClient("https://api.example.test");
|
|
|
|
await expect(client.listLabels("agent")).resolves.toEqual({ labels: [], total: 0 });
|
|
await expect(client.getLabel("label-1")).resolves.toMatchObject({ id: "" });
|
|
await expect(
|
|
client.createLabel({ resource_type: "agent", name: "Ops", color: "#3b82f6" }),
|
|
).resolves.toMatchObject({ id: "" });
|
|
await expect(
|
|
client.updateLabel("label-1", { name: "Operations" }),
|
|
).resolves.toMatchObject({ id: "" });
|
|
|
|
await expect(client.listLabelsForIssue("issue-1")).resolves.toEqual({ labels: [] });
|
|
await expect(client.attachLabel("issue-1", "label-1")).resolves.toEqual({ labels: [] });
|
|
await expect(client.detachLabel("issue-1", "label-1")).resolves.toEqual({ labels: [] });
|
|
|
|
await expect(client.listLabelsForResource("agent", "agent-1")).resolves.toEqual({ labels: [] });
|
|
await expect(
|
|
client.attachLabelToResource("agent", "agent-1", "label-1"),
|
|
).resolves.toEqual({ labels: [] });
|
|
await expect(
|
|
client.detachLabelFromResource("agent", "agent-1", "label-1"),
|
|
).resolves.toEqual({ labels: [] });
|
|
|
|
expect(fetchMock).toHaveBeenCalledTimes(10);
|
|
});
|
|
});
|
|
|
|
describe("ApiClient agent builder runtime switch", () => {
|
|
it("PATCHes the session runtime endpoint and returns the runtime the server bound", async () => {
|
|
const fetchMock = vi.fn().mockResolvedValue(
|
|
new Response(JSON.stringify({ runtime_id: "runtime-b" }), {
|
|
status: 200,
|
|
headers: { "Content-Type": "application/json" },
|
|
}),
|
|
);
|
|
vi.stubGlobal("fetch", fetchMock);
|
|
|
|
const client = new ApiClient("https://api.example.test");
|
|
await expect(
|
|
client.switchAgentBuilderRuntime("session-1", { runtime_id: "runtime-b" }),
|
|
).resolves.toEqual({ runtime_id: "runtime-b" });
|
|
|
|
const call = fetchMock.mock.calls[0] as [string, RequestInit];
|
|
expect(call[0]).toContain("/api/agent-builder/sessions/session-1/runtime");
|
|
expect(call[1].method).toBe("PATCH");
|
|
expect(JSON.parse(String(call[1].body))).toEqual({ runtime_id: "runtime-b" });
|
|
});
|
|
|
|
it("falls back to the requested runtime id for a malformed success body", async () => {
|
|
// A 2xx means the rebind committed onto the runtime we asked for, so the
|
|
// fallback must say so. Reporting "unknown" here would leave the picker on
|
|
// the old runtime while the conversation executes on the new one — the very
|
|
// split this endpoint exists to close.
|
|
const fetchMock = vi.fn().mockResolvedValue(
|
|
new Response(JSON.stringify({ runtime_id: 42 }), {
|
|
status: 200,
|
|
headers: { "Content-Type": "application/json" },
|
|
}),
|
|
);
|
|
vi.stubGlobal("fetch", fetchMock);
|
|
|
|
const client = new ApiClient("https://api.example.test");
|
|
await expect(
|
|
client.switchAgentBuilderRuntime("session-1", { runtime_id: "runtime-b" }),
|
|
).resolves.toEqual({ runtime_id: "runtime-b" });
|
|
});
|
|
|
|
it("rejects without a fallback when the switch is refused", async () => {
|
|
// 409 (a reply in flight) means nothing was committed, so the caller must
|
|
// see a rejection and keep the old runtime selected.
|
|
const fetchMock = vi.fn().mockResolvedValue(
|
|
new Response(JSON.stringify({ error: "stop the current reply before switching runtime" }), {
|
|
status: 409,
|
|
headers: { "Content-Type": "application/json" },
|
|
}),
|
|
);
|
|
vi.stubGlobal("fetch", fetchMock);
|
|
|
|
const client = new ApiClient("https://api.example.test");
|
|
await expect(
|
|
client.switchAgentBuilderRuntime("session-1", { runtime_id: "runtime-b" }),
|
|
).rejects.toBeInstanceOf(ApiError);
|
|
});
|
|
});
|
|
|
|
describe("ApiClient notification preferences", () => {
|
|
it("sends atomic preference updates with PATCH", async () => {
|
|
const fetchMock = vi.fn().mockResolvedValue(
|
|
new Response(
|
|
JSON.stringify({
|
|
workspace_id: "workspace-1",
|
|
preferences: {
|
|
status_changes: "muted",
|
|
comments: "muted",
|
|
},
|
|
}),
|
|
{ status: 200, headers: { "Content-Type": "application/json" } },
|
|
),
|
|
);
|
|
vi.stubGlobal("fetch", fetchMock);
|
|
|
|
const client = new ApiClient("https://api.example.test");
|
|
await expect(
|
|
client.updateNotificationPreferences(
|
|
{ comments: "muted" },
|
|
"workspace-one",
|
|
),
|
|
).resolves.toEqual({
|
|
workspace_id: "workspace-1",
|
|
preferences: {
|
|
status_changes: "muted",
|
|
comments: "muted",
|
|
},
|
|
});
|
|
|
|
expect(fetchMock).toHaveBeenCalledTimes(1);
|
|
expect(fetchMock.mock.calls[0]?.[0]).toBe(
|
|
"https://api.example.test/api/notification-preferences",
|
|
);
|
|
expect(fetchMock.mock.calls[0]?.[1]).toEqual(
|
|
expect.objectContaining({
|
|
method: "PATCH",
|
|
headers: expect.objectContaining({
|
|
"X-Workspace-Slug": "workspace-one",
|
|
}),
|
|
body: JSON.stringify({ preferences: { comments: "muted" } }),
|
|
}),
|
|
);
|
|
});
|
|
|
|
it("falls back safely when a preference response is malformed", async () => {
|
|
vi.stubGlobal(
|
|
"fetch",
|
|
vi.fn().mockResolvedValue(
|
|
new Response(
|
|
JSON.stringify({ workspace_id: "workspace-1", preferences: [] }),
|
|
{ status: 200, headers: { "Content-Type": "application/json" } },
|
|
),
|
|
),
|
|
);
|
|
|
|
const client = new ApiClient("https://api.example.test");
|
|
await expect(client.getNotificationPreferences()).resolves.toEqual({
|
|
workspace_id: "",
|
|
preferences: {},
|
|
});
|
|
});
|
|
});
|
|
|
|
describe("ApiClient", () => {
|
|
it("preserves HTTP status on failed requests", async () => {
|
|
vi.stubGlobal(
|
|
"fetch",
|
|
vi.fn().mockResolvedValue(
|
|
new Response(JSON.stringify({ error: "workspace slug already exists" }), {
|
|
status: 409,
|
|
statusText: "Conflict",
|
|
headers: { "Content-Type": "application/json" },
|
|
}),
|
|
),
|
|
);
|
|
|
|
const client = new ApiClient("https://api.example.test");
|
|
|
|
try {
|
|
await client.createWorkspace({ name: "Test", slug: "test" });
|
|
throw new Error("expected createWorkspace to fail");
|
|
} catch (error) {
|
|
expect(error).toBeInstanceOf(ApiError);
|
|
expect(error).toMatchObject({
|
|
message: "workspace slug already exists",
|
|
status: 409,
|
|
statusText: "Conflict",
|
|
});
|
|
}
|
|
});
|
|
|
|
it("preserves planned and delivered comment coverage from issue task runs", async () => {
|
|
vi.stubGlobal(
|
|
"fetch",
|
|
vi.fn().mockResolvedValue(
|
|
new Response(
|
|
JSON.stringify([
|
|
{
|
|
id: "task-1",
|
|
status: "queued",
|
|
trigger_comment_id: "comment-3",
|
|
coalesced_comment_ids: ["comment-1", "comment-2"],
|
|
delivered_comment_ids: ["comment-1", "comment-2", "comment-3"],
|
|
},
|
|
]),
|
|
{ status: 200, headers: { "Content-Type": "application/json" } },
|
|
),
|
|
),
|
|
);
|
|
|
|
const client = new ApiClient("https://api.example.test");
|
|
const tasks = await client.listTasksByIssue("issue-1");
|
|
|
|
expect(tasks[0]?.trigger_comment_id).toBe("comment-3");
|
|
expect(tasks[0]?.coalesced_comment_ids).toEqual([
|
|
"comment-1",
|
|
"comment-2",
|
|
]);
|
|
expect(tasks[0]?.delivered_comment_ids).toEqual([
|
|
"comment-1",
|
|
"comment-2",
|
|
"comment-3",
|
|
]);
|
|
});
|
|
|
|
it("keeps task runs when optional comment coverage is malformed", async () => {
|
|
vi.stubGlobal(
|
|
"fetch",
|
|
vi.fn().mockResolvedValue(
|
|
new Response(
|
|
JSON.stringify([
|
|
{
|
|
id: "task-1",
|
|
status: "queued",
|
|
coalesced_comment_ids: ["comment-1", 2],
|
|
delivered_comment_ids: "not-an-array",
|
|
},
|
|
{
|
|
id: "task-2",
|
|
status: "completed",
|
|
delivered_comment_ids: ["comment-2", "comment-3"],
|
|
},
|
|
]),
|
|
{ status: 200, headers: { "Content-Type": "application/json" } },
|
|
),
|
|
),
|
|
);
|
|
|
|
const client = new ApiClient("https://api.example.test");
|
|
const tasks = await client.listTasksByIssue("issue-1");
|
|
|
|
expect(tasks).toHaveLength(2);
|
|
expect(tasks[0]?.coalesced_comment_ids).toBeUndefined();
|
|
expect(tasks[0]?.delivered_comment_ids).toBeUndefined();
|
|
expect(tasks[1]?.delivered_comment_ids).toEqual([
|
|
"comment-2",
|
|
"comment-3",
|
|
]);
|
|
});
|
|
|
|
it("uses the expected HTTP contract for autopilot endpoints", async () => {
|
|
const fetchMock = vi.fn().mockImplementation(() => Promise.resolve(
|
|
new Response(JSON.stringify({ autopilots: [], runs: [], total: 0 }), {
|
|
status: 200,
|
|
headers: { "Content-Type": "application/json" },
|
|
}),
|
|
));
|
|
vi.stubGlobal("fetch", fetchMock);
|
|
|
|
const client = new ApiClient("https://api.example.test");
|
|
|
|
await client.listAutopilots({ status: "active" });
|
|
await client.getAutopilot("ap-1");
|
|
await client.createAutopilot({
|
|
title: "Daily triage",
|
|
project_id: "project-1",
|
|
assignee_id: "agent-1",
|
|
execution_mode: "create_issue",
|
|
});
|
|
await client.updateAutopilot("ap-1", { status: "paused", project_id: null });
|
|
await client.deleteAutopilot("ap-1");
|
|
await client.triggerAutopilot("ap-1");
|
|
await client.listAutopilotRuns("ap-1", { limit: 10, offset: 20 });
|
|
await client.createAutopilotTrigger("ap-1", {
|
|
kind: "schedule",
|
|
cron_expression: "0 9 * * *",
|
|
timezone: "UTC",
|
|
});
|
|
await client.updateAutopilotTrigger("ap-1", "tr-1", { enabled: false });
|
|
await client.deleteAutopilotTrigger("ap-1", "tr-1");
|
|
await client.rotateAutopilotTriggerWebhookToken("ap-1", "tr-1");
|
|
|
|
const calls = fetchMock.mock.calls.map(([url, init]) => ({
|
|
url,
|
|
method: init?.method ?? "GET",
|
|
body: init?.body,
|
|
}));
|
|
|
|
expect(calls).toMatchObject([
|
|
{ url: "https://api.example.test/api/autopilots?status=active", method: "GET" },
|
|
{ url: "https://api.example.test/api/autopilots/ap-1", method: "GET" },
|
|
{
|
|
url: "https://api.example.test/api/autopilots",
|
|
method: "POST",
|
|
body: JSON.stringify({
|
|
title: "Daily triage",
|
|
project_id: "project-1",
|
|
assignee_id: "agent-1",
|
|
execution_mode: "create_issue",
|
|
}),
|
|
},
|
|
{
|
|
url: "https://api.example.test/api/autopilots/ap-1",
|
|
method: "PATCH",
|
|
body: JSON.stringify({ status: "paused", project_id: null }),
|
|
},
|
|
{ url: "https://api.example.test/api/autopilots/ap-1", method: "DELETE" },
|
|
{ url: "https://api.example.test/api/autopilots/ap-1/trigger", method: "POST" },
|
|
{ url: "https://api.example.test/api/autopilots/ap-1/runs?limit=10&offset=20", method: "GET" },
|
|
{
|
|
url: "https://api.example.test/api/autopilots/ap-1/triggers",
|
|
method: "POST",
|
|
body: JSON.stringify({
|
|
kind: "schedule",
|
|
cron_expression: "0 9 * * *",
|
|
timezone: "UTC",
|
|
}),
|
|
},
|
|
{
|
|
url: "https://api.example.test/api/autopilots/ap-1/triggers/tr-1",
|
|
method: "PATCH",
|
|
body: JSON.stringify({ enabled: false }),
|
|
},
|
|
{ url: "https://api.example.test/api/autopilots/ap-1/triggers/tr-1", method: "DELETE" },
|
|
{
|
|
url: "https://api.example.test/api/autopilots/ap-1/triggers/tr-1/rotate-webhook-token",
|
|
method: "POST",
|
|
},
|
|
]);
|
|
});
|
|
|
|
it("emits X-Client-* headers when identity is configured", async () => {
|
|
const fetchMock = vi.fn().mockResolvedValue(
|
|
new Response(JSON.stringify([]), {
|
|
status: 200,
|
|
headers: { "Content-Type": "application/json" },
|
|
}),
|
|
);
|
|
vi.stubGlobal("fetch", fetchMock);
|
|
|
|
const client = new ApiClient("https://api.example.test", {
|
|
identity: { platform: "desktop", version: "1.2.3", os: "macos" },
|
|
});
|
|
await client.listWorkspaces();
|
|
|
|
const headers = fetchMock.mock.calls[0]![1]!.headers as Record<string, string>;
|
|
expect(headers["X-Client-Platform"]).toBe("desktop");
|
|
expect(headers["X-Client-Version"]).toBe("1.2.3");
|
|
expect(headers["X-Client-OS"]).toBe("macos");
|
|
});
|
|
|
|
it("omits X-Client-* headers when identity is not configured", async () => {
|
|
const fetchMock = vi.fn().mockResolvedValue(
|
|
new Response(JSON.stringify([]), {
|
|
status: 200,
|
|
headers: { "Content-Type": "application/json" },
|
|
}),
|
|
);
|
|
vi.stubGlobal("fetch", fetchMock);
|
|
|
|
const client = new ApiClient("https://api.example.test");
|
|
await client.listWorkspaces();
|
|
|
|
const headers = fetchMock.mock.calls[0]![1]!.headers as Record<string, string>;
|
|
expect(headers["X-Client-Platform"]).toBeUndefined();
|
|
expect(headers["X-Client-Version"]).toBeUndefined();
|
|
expect(headers["X-Client-OS"]).toBeUndefined();
|
|
});
|
|
|
|
it("posts feedback kind and parses the response through the schema", async () => {
|
|
const fetchMock = vi.fn().mockResolvedValue(
|
|
new Response(JSON.stringify({ id: "feedback-1", created_at: "2026-06-26T00:00:00Z" }), {
|
|
status: 201,
|
|
headers: { "Content-Type": "application/json" },
|
|
}),
|
|
);
|
|
vi.stubGlobal("fetch", fetchMock);
|
|
|
|
const client = new ApiClient("https://api.example.test");
|
|
const response = await client.createFeedback({
|
|
message: "Desktop route crashed",
|
|
url: "app://desktop/acme/issues",
|
|
workspace_id: "ws-1",
|
|
kind: "bug",
|
|
});
|
|
|
|
expect(response).toEqual({
|
|
id: "feedback-1",
|
|
created_at: "2026-06-26T00:00:00Z",
|
|
});
|
|
expect(fetchMock).toHaveBeenCalledWith(
|
|
"https://api.example.test/api/feedback",
|
|
expect.objectContaining({
|
|
method: "POST",
|
|
body: JSON.stringify({
|
|
message: "Desktop route crashed",
|
|
url: "app://desktop/acme/issues",
|
|
workspace_id: "ws-1",
|
|
kind: "bug",
|
|
}),
|
|
}),
|
|
);
|
|
});
|
|
|
|
it("falls back to an empty feedback response when the server shape drifts", async () => {
|
|
vi.stubGlobal(
|
|
"fetch",
|
|
vi.fn().mockResolvedValue(
|
|
new Response(JSON.stringify({ id: 42, created_at: "2026-06-26T00:00:00Z" }), {
|
|
status: 201,
|
|
headers: { "Content-Type": "application/json" },
|
|
}),
|
|
),
|
|
);
|
|
|
|
const client = new ApiClient("https://api.example.test");
|
|
await expect(client.createFeedback({ message: "hello" })).resolves.toEqual({
|
|
id: "",
|
|
created_at: "",
|
|
});
|
|
});
|
|
|
|
it("uses the expected HTTP contract for comment trigger preview and suppress", async () => {
|
|
const fetchMock = vi.fn()
|
|
.mockResolvedValueOnce(
|
|
new Response(JSON.stringify({ agents: [] }), {
|
|
status: 200,
|
|
headers: { "Content-Type": "application/json" },
|
|
}),
|
|
)
|
|
.mockResolvedValueOnce(
|
|
new Response(JSON.stringify({
|
|
id: "comment-1",
|
|
issue_id: "issue-1",
|
|
author_type: "member",
|
|
author_id: "user-1",
|
|
content: "hello",
|
|
type: "comment",
|
|
parent_id: null,
|
|
reactions: [],
|
|
attachments: [],
|
|
created_at: "2026-06-05T00:00:00Z",
|
|
updated_at: "2026-06-05T00:00:00Z",
|
|
}), {
|
|
status: 201,
|
|
headers: { "Content-Type": "application/json" },
|
|
}),
|
|
)
|
|
.mockResolvedValueOnce(
|
|
new Response(JSON.stringify({
|
|
id: "comment-1",
|
|
issue_id: "issue-1",
|
|
author_type: "member",
|
|
author_id: "user-1",
|
|
content: "updated",
|
|
type: "comment",
|
|
parent_id: null,
|
|
reactions: [],
|
|
attachments: [],
|
|
created_at: "2026-06-05T00:00:00Z",
|
|
updated_at: "2026-06-05T00:01:00Z",
|
|
}), {
|
|
status: 200,
|
|
headers: { "Content-Type": "application/json" },
|
|
}),
|
|
);
|
|
vi.stubGlobal("fetch", fetchMock);
|
|
|
|
const client = new ApiClient("https://api.example.test");
|
|
await client.previewCommentTriggers("issue-1", "hello", "parent-1", "comment-1");
|
|
await client.createComment(
|
|
"issue-1",
|
|
"hello",
|
|
"comment",
|
|
"parent-1",
|
|
["attachment-1"],
|
|
["agent-1"],
|
|
);
|
|
await client.updateComment("comment-1", "updated", ["attachment-1"], ["agent-1"]);
|
|
|
|
expect(fetchMock.mock.calls.map(([url, init]) => ({
|
|
url,
|
|
method: init?.method,
|
|
body: init?.body,
|
|
}))).toMatchObject([
|
|
{
|
|
url: "https://api.example.test/api/issues/issue-1/comments/trigger-preview",
|
|
method: "POST",
|
|
body: JSON.stringify({ content: "hello", parent_id: "parent-1", editing_comment_id: "comment-1" }),
|
|
},
|
|
{
|
|
url: "https://api.example.test/api/issues/issue-1/comments",
|
|
method: "POST",
|
|
body: JSON.stringify({
|
|
content: "hello",
|
|
type: "comment",
|
|
parent_id: "parent-1",
|
|
attachment_ids: ["attachment-1"],
|
|
suppress_agent_ids: ["agent-1"],
|
|
}),
|
|
},
|
|
{
|
|
url: "https://api.example.test/api/comments/comment-1",
|
|
method: "PUT",
|
|
body: JSON.stringify({
|
|
content: "updated",
|
|
attachment_ids: ["attachment-1"],
|
|
suppress_agent_ids: ["agent-1"],
|
|
}),
|
|
},
|
|
]);
|
|
});
|
|
|
|
it("uses the Cloud Runtime node API contract", async () => {
|
|
const node = {
|
|
id: "node-1",
|
|
owner_id: "user-1",
|
|
instance_id: "i-0123456789abcdef0",
|
|
region: "us-west-2",
|
|
instance_type: "g5.xlarge",
|
|
image_id: "ami-1",
|
|
subnet_id: "subnet-1",
|
|
name: "gpu-dev-01",
|
|
status: "launching",
|
|
tags: {},
|
|
metadata: {},
|
|
created_at: "2026-05-21T08:30:00Z",
|
|
updated_at: "2026-05-21T08:30:00Z",
|
|
};
|
|
const fetchMock = vi
|
|
.fn()
|
|
.mockResolvedValueOnce(
|
|
new Response(JSON.stringify([]), {
|
|
status: 200,
|
|
headers: { "Content-Type": "application/json" },
|
|
}),
|
|
)
|
|
.mockResolvedValueOnce(
|
|
new Response(JSON.stringify(node), {
|
|
status: 201,
|
|
headers: { "Content-Type": "application/json" },
|
|
}),
|
|
);
|
|
vi.stubGlobal("fetch", fetchMock);
|
|
|
|
const client = new ApiClient("https://api.example.test");
|
|
await client.listCloudRuntimeNodes({ limit: 20, offset: 5 });
|
|
await client.createCloudRuntimeNode(
|
|
{ instance_type: "g5.xlarge", name: "gpu-dev-01" },
|
|
);
|
|
|
|
const listCall = fetchMock.mock.calls[0]!;
|
|
const createCall = fetchMock.mock.calls[1]!;
|
|
expect(listCall[0]).toBe(
|
|
"https://api.example.test/api/cloud-runtime/nodes?limit=20&offset=5",
|
|
);
|
|
expect(createCall[0]).toBe(
|
|
"https://api.example.test/api/cloud-runtime/nodes",
|
|
);
|
|
expect(createCall[1]).toMatchObject({
|
|
method: "POST",
|
|
body: JSON.stringify({
|
|
instance_type: "g5.xlarge",
|
|
name: "gpu-dev-01",
|
|
}),
|
|
});
|
|
});
|
|
|
|
it("falls back when Cloud Runtime node responses drift", async () => {
|
|
const fetchMock = vi
|
|
.fn()
|
|
.mockResolvedValueOnce(
|
|
new Response(JSON.stringify([{ id: 123 }]), {
|
|
status: 200,
|
|
headers: { "Content-Type": "application/json" },
|
|
}),
|
|
)
|
|
.mockResolvedValueOnce(
|
|
new Response(JSON.stringify({ id: 123 }), {
|
|
status: 201,
|
|
headers: { "Content-Type": "application/json" },
|
|
}),
|
|
);
|
|
vi.stubGlobal("fetch", fetchMock);
|
|
|
|
const client = new ApiClient("https://api.example.test");
|
|
|
|
await expect(client.listCloudRuntimeNodes()).resolves.toEqual([]);
|
|
await expect(
|
|
client.createCloudRuntimeNode({ instance_type: "g5.xlarge" }),
|
|
).resolves.toMatchObject({ id: "", status: "" });
|
|
});
|
|
|
|
it("deleteCloudRuntimeNode sends DELETE with JSON body containing instance id", async () => {
|
|
const fetchMock = vi.fn().mockResolvedValueOnce(
|
|
new Response(null, { status: 204 }),
|
|
);
|
|
vi.stubGlobal("fetch", fetchMock);
|
|
|
|
const client = new ApiClient("https://api.example.test");
|
|
await client.deleteCloudRuntimeNode("i-0123456789abcdef0");
|
|
|
|
expect(fetchMock).toHaveBeenCalledTimes(1);
|
|
const [url, opts] = fetchMock.mock.calls[0]!;
|
|
expect(url).toBe("https://api.example.test/api/cloud-runtime/nodes");
|
|
expect(opts).toMatchObject({
|
|
method: "DELETE",
|
|
body: JSON.stringify({ instance_id: "i-0123456789abcdef0" }),
|
|
});
|
|
expect((opts.headers as Record<string, string>)["Content-Type"]).toBe(
|
|
"application/json",
|
|
);
|
|
});
|
|
|
|
describe("getAttachment", () => {
|
|
it("returns the parsed attachment for a well-formed response", async () => {
|
|
vi.stubGlobal(
|
|
"fetch",
|
|
vi.fn().mockResolvedValue(
|
|
new Response(
|
|
JSON.stringify({
|
|
id: "att-1",
|
|
workspace_id: "ws-1",
|
|
issue_id: null,
|
|
comment_id: null,
|
|
uploader_type: "member",
|
|
uploader_id: "u-1",
|
|
filename: "report.md",
|
|
url: "https://static.example.test/ws/att-1.md",
|
|
download_url:
|
|
"https://static.example.test/ws/att-1.md?Policy=p&Signature=s&Key-Pair-Id=k",
|
|
content_type: "text/markdown",
|
|
size_bytes: 123,
|
|
created_at: "2026-05-11T00:00:00Z",
|
|
}),
|
|
{ status: 200, headers: { "Content-Type": "application/json" } },
|
|
),
|
|
),
|
|
);
|
|
|
|
const client = new ApiClient("https://api.example.test");
|
|
const att = await client.getAttachment("att-1");
|
|
|
|
expect(att.id).toBe("att-1");
|
|
expect(att.download_url).toContain("Policy=");
|
|
});
|
|
|
|
it("falls back to an empty attachment when the response is missing download_url", async () => {
|
|
vi.stubGlobal(
|
|
"fetch",
|
|
vi.fn().mockResolvedValue(
|
|
new Response(JSON.stringify({ id: "att-1" }), {
|
|
status: 200,
|
|
headers: { "Content-Type": "application/json" },
|
|
}),
|
|
),
|
|
);
|
|
|
|
const client = new ApiClient("https://api.example.test");
|
|
const att = await client.getAttachment("att-1");
|
|
|
|
// parseWithFallback returns the EMPTY_ATTACHMENT record so callers can
|
|
// safely read `download_url` without crashing — they'll see "" and
|
|
// surface a user-facing error instead of opening `undefined`.
|
|
expect(att.id).toBe("");
|
|
expect(att.download_url).toBe("");
|
|
});
|
|
});
|
|
|
|
describe("getAttachmentTextContent", () => {
|
|
it("returns body text and the original content type from the X-* header", async () => {
|
|
vi.stubGlobal(
|
|
"fetch",
|
|
vi.fn().mockResolvedValue(
|
|
new Response("# heading\n\nbody\n", {
|
|
status: 200,
|
|
headers: {
|
|
"Content-Type": "text/plain; charset=utf-8",
|
|
"X-Original-Content-Type": "text/markdown",
|
|
},
|
|
}),
|
|
),
|
|
);
|
|
|
|
const client = new ApiClient("https://api.example.test");
|
|
const { text, originalContentType } =
|
|
await client.getAttachmentTextContent("att-1");
|
|
|
|
expect(text).toBe("# heading\n\nbody\n");
|
|
expect(originalContentType).toBe("text/markdown");
|
|
});
|
|
|
|
it("throws PreviewTooLargeError on 413", async () => {
|
|
const { PreviewTooLargeError } = await import("./client");
|
|
vi.stubGlobal(
|
|
"fetch",
|
|
vi.fn().mockResolvedValue(
|
|
new Response("", { status: 413, statusText: "Payload Too Large" }),
|
|
),
|
|
);
|
|
|
|
const client = new ApiClient("https://api.example.test");
|
|
await expect(client.getAttachmentTextContent("att-1")).rejects.toBeInstanceOf(
|
|
PreviewTooLargeError,
|
|
);
|
|
});
|
|
|
|
it("throws PreviewUnsupportedError on 415", async () => {
|
|
const { PreviewUnsupportedError } = await import("./client");
|
|
vi.stubGlobal(
|
|
"fetch",
|
|
vi.fn().mockResolvedValue(
|
|
new Response("", { status: 415, statusText: "Unsupported Media Type" }),
|
|
),
|
|
);
|
|
|
|
const client = new ApiClient("https://api.example.test");
|
|
await expect(client.getAttachmentTextContent("att-1")).rejects.toBeInstanceOf(
|
|
PreviewUnsupportedError,
|
|
);
|
|
});
|
|
});
|
|
|
|
describe("listChatMessagesPage deployment-order fallback", () => {
|
|
const jsonResponse = (body: unknown, status: number, statusText = "") =>
|
|
new Response(JSON.stringify(body), {
|
|
status,
|
|
statusText,
|
|
headers: { "Content-Type": "application/json" },
|
|
});
|
|
|
|
it("falls back to the legacy full-list endpoint when the paged route 404s", async () => {
|
|
const legacy = [
|
|
{ id: "m1", role: "user", content: "hi", created_at: "2026-06-01T00:00:00Z" },
|
|
{ id: "m2", role: "assistant", content: "yo", created_at: "2026-06-01T00:00:01Z" },
|
|
];
|
|
const fetchMock = vi
|
|
.fn()
|
|
.mockResolvedValueOnce(jsonResponse({ error: "not found" }, 404, "Not Found"))
|
|
.mockResolvedValueOnce(jsonResponse(legacy, 200));
|
|
vi.stubGlobal("fetch", fetchMock);
|
|
|
|
const client = new ApiClient("https://api.example.test");
|
|
const page = await client.listChatMessagesPage("session-1", { limit: 50 });
|
|
|
|
expect(fetchMock).toHaveBeenCalledTimes(2);
|
|
expect(fetchMock.mock.calls[0]![0]).toBe(
|
|
"https://api.example.test/api/chat/sessions/session-1/messages/page?limit=50",
|
|
);
|
|
expect(fetchMock.mock.calls[1]![0]).toBe(
|
|
"https://api.example.test/api/chat/sessions/session-1/messages",
|
|
);
|
|
expect(page).toEqual({ messages: legacy, limit: 50, has_more: false, next_cursor: null });
|
|
});
|
|
|
|
it("does NOT fall back on a cursor request — a 404 there propagates", async () => {
|
|
const fetchMock = vi
|
|
.fn()
|
|
.mockResolvedValue(jsonResponse({ error: "not found" }, 404, "Not Found"));
|
|
vi.stubGlobal("fetch", fetchMock);
|
|
|
|
const client = new ApiClient("https://api.example.test");
|
|
await expect(
|
|
client.listChatMessagesPage("session-1", {
|
|
before: { created_at: "2026-06-01T00:00:00Z", id: "m1" },
|
|
}),
|
|
).rejects.toBeInstanceOf(ApiError);
|
|
// Only the paged request fires; no legacy full-list call that would duplicate messages.
|
|
expect(fetchMock).toHaveBeenCalledTimes(1);
|
|
});
|
|
|
|
it("propagates non-404 errors instead of masking them with the legacy list", async () => {
|
|
const fetchMock = vi
|
|
.fn()
|
|
.mockResolvedValue(jsonResponse({ error: "boom" }, 500, "Internal Server Error"));
|
|
vi.stubGlobal("fetch", fetchMock);
|
|
|
|
const client = new ApiClient("https://api.example.test");
|
|
await expect(client.listChatMessagesPage("session-1")).rejects.toMatchObject({
|
|
status: 500,
|
|
});
|
|
expect(fetchMock).toHaveBeenCalledTimes(1);
|
|
});
|
|
});
|
|
|
|
describe("cancelTaskById response parsing", () => {
|
|
const taskResponse = {
|
|
id: "task-1",
|
|
agent_id: "agent-1",
|
|
runtime_id: "runtime-1",
|
|
issue_id: "",
|
|
status: "cancelled",
|
|
priority: 0,
|
|
dispatched_at: null,
|
|
started_at: null,
|
|
completed_at: "2026-06-12T06:40:00Z",
|
|
result: null,
|
|
error: null,
|
|
created_at: "2026-06-12T06:39:00Z",
|
|
};
|
|
|
|
it("parses the cancelled chat message payload", async () => {
|
|
const fetchMock = vi.fn().mockResolvedValue(
|
|
new Response(JSON.stringify({
|
|
...taskResponse,
|
|
cancelled_chat_message: {
|
|
chat_session_id: "session-1",
|
|
message_id: "message-1",
|
|
content: "restore me",
|
|
restore_to_input: true,
|
|
},
|
|
}), {
|
|
status: 200,
|
|
headers: { "Content-Type": "application/json" },
|
|
}),
|
|
);
|
|
vi.stubGlobal("fetch", fetchMock);
|
|
|
|
const client = new ApiClient("https://api.example.test");
|
|
const result = await client.cancelTaskById("task-1");
|
|
|
|
expect(fetchMock.mock.calls[0]).toMatchObject([
|
|
"https://api.example.test/api/tasks/task-1/cancel",
|
|
{ method: "POST" },
|
|
]);
|
|
expect(result.cancelled_chat_message).toEqual({
|
|
chat_session_id: "session-1",
|
|
message_id: "message-1",
|
|
content: "restore me",
|
|
restore_to_input: true,
|
|
});
|
|
});
|
|
|
|
it("parses task attribution when the backend enriches it", async () => {
|
|
vi.stubGlobal(
|
|
"fetch",
|
|
vi.fn().mockResolvedValue(
|
|
new Response(JSON.stringify({
|
|
...taskResponse,
|
|
attribution: {
|
|
source: "direct_human",
|
|
precise: true,
|
|
initiator: { id: "user-1", name: "Ada", avatar_url: "https://x/a.png" },
|
|
originator: { id: "user-1", name: "Ada" },
|
|
evidence: { kind: "comment", ref_id: "comment-1" },
|
|
},
|
|
}), {
|
|
status: 200,
|
|
headers: { "Content-Type": "application/json" },
|
|
}),
|
|
),
|
|
);
|
|
|
|
const client = new ApiClient("https://api.example.test");
|
|
const result = await client.cancelTaskById("task-1");
|
|
|
|
expect(result.attribution).toEqual({
|
|
source: "direct_human",
|
|
precise: true,
|
|
initiator: { id: "user-1", name: "Ada", avatar_url: "https://x/a.png" },
|
|
originator: { id: "user-1", name: "Ada" },
|
|
evidence: { kind: "comment", ref_id: "comment-1" },
|
|
});
|
|
});
|
|
|
|
it("leaves attribution absent on servers that predate it", async () => {
|
|
vi.stubGlobal(
|
|
"fetch",
|
|
vi.fn().mockResolvedValue(
|
|
new Response(JSON.stringify(taskResponse), {
|
|
status: 200,
|
|
headers: { "Content-Type": "application/json" },
|
|
}),
|
|
),
|
|
);
|
|
|
|
const client = new ApiClient("https://api.example.test");
|
|
const result = await client.cancelTaskById("task-1");
|
|
|
|
expect(result.attribution).toBeUndefined();
|
|
});
|
|
|
|
// The server only defers the empty-transcript judgment — and so only
|
|
// withholds the synchronous restore — for clients that advertise this
|
|
// capability (#5219). Drop the header and this client is treated as a
|
|
// pre-#5219 build, quietly losing the deferred path it actually implements.
|
|
it("advertises the durable draft-restore capability", async () => {
|
|
const fetchMock = vi.fn().mockResolvedValue(
|
|
new Response(JSON.stringify(taskResponse), {
|
|
status: 200,
|
|
headers: { "Content-Type": "application/json" },
|
|
}),
|
|
);
|
|
vi.stubGlobal("fetch", fetchMock);
|
|
|
|
await new ApiClient("https://api.example.test").cancelTaskById("task-1");
|
|
|
|
const init = fetchMock.mock.calls[0]?.[1] as { headers: Record<string, string> };
|
|
expect(init.headers["X-Client-Capabilities"]).toBe(CHAT_DRAFT_RESTORE_CAPABILITY);
|
|
});
|
|
|
|
it("treats a null cancelled chat message as absent", async () => {
|
|
vi.stubGlobal(
|
|
"fetch",
|
|
vi.fn().mockResolvedValue(
|
|
new Response(JSON.stringify({
|
|
...taskResponse,
|
|
cancelled_chat_message: null,
|
|
}), {
|
|
status: 200,
|
|
headers: { "Content-Type": "application/json" },
|
|
}),
|
|
),
|
|
);
|
|
|
|
const client = new ApiClient("https://api.example.test");
|
|
const result = await client.cancelTaskById("task-1");
|
|
|
|
expect(result.id).toBe("task-1");
|
|
expect(result.cancelled_chat_message).toBeUndefined();
|
|
});
|
|
|
|
it.each([
|
|
["a missing task id", { ...taskResponse, id: undefined }],
|
|
[
|
|
"a malformed cancelled chat message",
|
|
{
|
|
...taskResponse,
|
|
cancelled_chat_message: {
|
|
chat_session_id: "session-1",
|
|
message_id: "message-1",
|
|
content: "restore me",
|
|
restore_to_input: "true",
|
|
},
|
|
},
|
|
],
|
|
["a null body", null],
|
|
])("falls back for %s", async (_label, body) => {
|
|
vi.stubGlobal(
|
|
"fetch",
|
|
vi.fn().mockResolvedValue(
|
|
new Response(JSON.stringify(body), {
|
|
status: 200,
|
|
headers: { "Content-Type": "application/json" },
|
|
}),
|
|
),
|
|
);
|
|
|
|
const client = new ApiClient("https://api.example.test");
|
|
const result = await client.cancelTaskById("task-1");
|
|
|
|
expect(result.id).toBe("");
|
|
expect(result.cancelled_chat_message).toBeUndefined();
|
|
});
|
|
});
|
|
|
|
describe("chat attachment wiring", () => {
|
|
it("uploadFile includes chat_session_id in the FormData body", async () => {
|
|
const fetchMock = vi.fn().mockResolvedValue(
|
|
new Response(JSON.stringify({ id: "att-1", url: "https://cdn/x" }), {
|
|
status: 200,
|
|
headers: { "Content-Type": "application/json" },
|
|
}),
|
|
);
|
|
vi.stubGlobal("fetch", fetchMock);
|
|
|
|
const client = new ApiClient("https://api.example.test");
|
|
const file = new File(["hi"], "hi.png", { type: "image/png" });
|
|
await client.uploadFile(file, { chatSessionId: "session-123" });
|
|
|
|
expect(fetchMock).toHaveBeenCalledTimes(1);
|
|
const [url, init] = fetchMock.mock.calls[0]!;
|
|
expect(url).toBe("https://api.example.test/api/upload-file");
|
|
expect(init?.method).toBe("POST");
|
|
const body = init?.body as FormData;
|
|
expect(body).toBeInstanceOf(FormData);
|
|
expect(body.get("chat_session_id")).toBe("session-123");
|
|
expect(body.get("issue_id")).toBeNull();
|
|
expect(body.get("comment_id")).toBeNull();
|
|
});
|
|
|
|
it("threads an AbortSignal into fetch so the coordinator can cancel it (MUL-5181)", async () => {
|
|
const fetchMock = vi.fn().mockResolvedValue(
|
|
new Response(JSON.stringify({ id: "att-1", url: "https://cdn/x" }), {
|
|
status: 200,
|
|
headers: { "Content-Type": "application/json" },
|
|
}),
|
|
);
|
|
vi.stubGlobal("fetch", fetchMock);
|
|
|
|
const client = new ApiClient("https://api.example.test");
|
|
const controller = new AbortController();
|
|
const file = new File(["hi"], "hi.png", { type: "image/png" });
|
|
await client.uploadFile(file, { issueId: "issue-1" }, controller.signal);
|
|
|
|
const [, init] = fetchMock.mock.calls[0]!;
|
|
expect(init?.signal).toBe(controller.signal);
|
|
});
|
|
|
|
it("rejects with the fetch AbortError when the signal is already aborted", async () => {
|
|
const fetchMock = vi.fn().mockImplementation((_url, init?: RequestInit) => {
|
|
if (init?.signal?.aborted) {
|
|
const err = new Error("The operation was aborted");
|
|
err.name = "AbortError";
|
|
return Promise.reject(err);
|
|
}
|
|
return Promise.resolve(new Response("{}", { status: 200 }));
|
|
});
|
|
vi.stubGlobal("fetch", fetchMock);
|
|
|
|
const client = new ApiClient("https://api.example.test");
|
|
const controller = new AbortController();
|
|
controller.abort();
|
|
const file = new File(["hi"], "hi.png", { type: "image/png" });
|
|
|
|
await expect(
|
|
client.uploadFile(file, undefined, controller.signal),
|
|
).rejects.toMatchObject({ name: "AbortError" });
|
|
});
|
|
|
|
it("sendChatMessage serialises attachment_ids onto the JSON body when present", async () => {
|
|
const fetchMock = vi.fn().mockResolvedValue(
|
|
new Response(JSON.stringify({ message_id: "m1", task_id: "t1", created_at: "" }), {
|
|
status: 201,
|
|
headers: { "Content-Type": "application/json" },
|
|
}),
|
|
);
|
|
vi.stubGlobal("fetch", fetchMock);
|
|
|
|
const client = new ApiClient("https://api.example.test");
|
|
await client.sendChatMessage("session-1", "hello", ["att-1", "att-2"]);
|
|
|
|
const [, init] = fetchMock.mock.calls[0]!;
|
|
expect(JSON.parse(init?.body as string)).toEqual({
|
|
content: "hello",
|
|
attachment_ids: ["att-1", "att-2"],
|
|
});
|
|
});
|
|
|
|
it("sendChatMessage omits attachment_ids when the list is empty or undefined", async () => {
|
|
const fetchMock = vi.fn().mockImplementation(() =>
|
|
Promise.resolve(
|
|
new Response(JSON.stringify({ message_id: "m1", task_id: "t1", created_at: "" }), {
|
|
status: 201,
|
|
headers: { "Content-Type": "application/json" },
|
|
}),
|
|
),
|
|
);
|
|
vi.stubGlobal("fetch", fetchMock);
|
|
|
|
const client = new ApiClient("https://api.example.test");
|
|
await client.sendChatMessage("session-1", "hello");
|
|
await client.sendChatMessage("session-1", "again", []);
|
|
|
|
expect(JSON.parse(fetchMock.mock.calls[0]![1]?.body as string)).toEqual({ content: "hello" });
|
|
expect(JSON.parse(fetchMock.mock.calls[1]![1]?.body as string)).toEqual({ content: "again" });
|
|
});
|
|
});
|
|
});
|