mirror of
https://github.com/multica-ai/multica.git
synced 2026-08-05 17:40:11 +02:00
* feat(chat): add agent-generated quick actions
Co-authored-by: multica-agent <github@multica.ai>
* fix(chat): preserve mid-response quick-action fences
Co-authored-by: multica-agent <github@multica.ai>
* fix(chat): drop quick actions on empty reply to keep no_response fallback
An actions-only completion — a quick-actions footer with no visible text —
wrote an empty-content assistant message (message_kind=message). Older
Desktop/mobile clients ignore the quick_actions field and render that as an
empty bubble, breaking the MUL-4351 contract that an empty turn always gives
old clients a visible no_response fallback.
Drop the quick actions when the visible body is empty so an actions-only turn
falls through to the visible no_response outcome, and revert the completion
switch to gate the message row on visible text only. Update the completion
test to pin the corrected behavior.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Co-authored-by: multica-agent <github@multica.ai>
* feat(chat): generate quick actions via daemon suggestion pass
Replace the in-band runtime-brief instruction with a dedicated post-completion
provider turn: after a direct chat reply finishes, the daemon resumes the same
session with a JSON-only suggest prompt and forwards the raw output on the
complete callback. The server parses it leniently and reuses the existing
sanitize/redact/store/broadcast pipeline; the stripped in-band footer stays as
a fallback for older daemons and pre-upgrade sessions. The footer strip now
covers every chat completion, fixing the intro-turn protocol leak. Adds a
Settings → Chat toggle (client-persisted, default on) that hides the chips.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* feat(chat): deliver quick actions async with skeleton placeholders
Decouple suggestion generation from the turn: the daemon reports completion
immediately (chat:done carries quick_actions_pending as a per-turn capability
signal) and runs the suggestion pass in the background, delivering results
through a new supplement endpoint + chat:quick_actions broadcast. A new turn
on the same session cancels the stale pass. Clients render pill skeletons
under the finished reply until the supplement resolves them (entrance
animation on arrival, 30s safety timeout); older daemons never raise the flag
so no skeleton dangles. Suggest usage re-reports merged totals because
task_usage upserts replace per (task, provider, model). Prompt now asks for
exactly 3 actions.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* feat(chat): make the quick-actions toggle stop generation, not hide pills
The Settings → Chat toggle previously only hid rendered pills while the
daemon kept burning a suggestion call every turn. It now travels with each
send (quick_actions_enabled, absent = enabled for older clients), is stamped
on the chat task (migration 213), forwarded on the claim, and gates the
daemon's suggestion pass at the source — no call, no pending flag, no
skeleton. Existing suggestions stay visible; settings copy now says
'generate' instead of 'show'.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* fix(migrations): renumber quick-action migrations onto current main
Merging current origin/main brought the vcs migrations to their canonical
216-221 prefixes, which collided with the quick-action migrations that were
sitting at 219/220 (backend CI red in
TestMigrationNumericPrefixesStayUniqueAfterLegacySet). Renumber them to the
next unused prefixes:
- 219_chat_message_quick_actions -> 222_chat_message_quick_actions
- 220_agent_task_quick_actions_disabled -> 223_agent_task_quick_actions_disabled
Contents are unchanged; sqlc regeneration produces no drift since the added
columns are independent of the vcs tables.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Co-authored-by: multica-agent <github@multica.ai>
* fix(mobile): render async chat quick actions via chat:quick_actions
The daemon generates quick actions in a background pass after the turn
finishes, delivering them on a separate chat:quick_actions event. Mobile
only handled chat:done (which invalidates + refetches an actions-less
message list) and keeps the messages query at staleTime: Infinity, so an
active mobile session never rendered async-generated quick actions until a
manual pull-to-refresh or refocus.
Add applyChatQuickActionsToCache — mirroring web's patcher — which patches
the supplement onto the targeted assistant message in the flat messages
cache, and subscribe to chat:quick_actions in use-chat-session-realtime.
Patch-only (no invalidate), matching web and mobile's cellular
patch-over-invalidate rule; an empty supplement is a terminal no-op. Covered
by chat-ws-updaters.test.ts.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Co-authored-by: multica-agent <github@multica.ai>
* fix(chat): cancel in-flight messages refetch before quick-actions patch
The chat:done invalidate can leave a messages refetch in flight that read the
assistant row before the daemon persisted the quick actions. If that refetch
resolves after the chat:quick_actions setQueryData patch, it overwrites the
freshly-patched actions with an actions-less row. Both message caches are
staleTime: Infinity, so the overwrite never self-heals and the actions vanish
permanently (MUL-5149, Howard review).
applyChatQuickActionsToCache now awaits cancelQueries for the affected caches
(web: flat messages + messagesPage, mobile: flat messages) before patching, so
a stale in-flight refetch is cancelled and cannot land after the patch. Cancel
must precede setQueryData because cancelQueries reverts to the pre-fetch state.
WS handlers call it via `void` (fire-and-forget).
Adds an active-query race regression test on both web and mobile that holds a
refetch open across the supplement and asserts the patched actions survive;
verified to fail without the cancel.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Co-authored-by: multica-agent <github@multica.ai>
* feat(chat): quick-actions refresh/regenerate + review hardening (MUL-5149)
Co-authored-by: multica-agent <github@multica.ai>
* fix(chat): address quick-actions re-review (MUL-5149)
- Ack alignment: refresh request carries the target message_id; server
atomically confirms it is still the session's latest turn (409 stale
otherwise), so the client marker always matches the resolving
chat:quick_actions — no response reconciliation. Adds a regression test.
- Converge the pending marker on every terminal path: HandleFailedTasks
(sweeper/orphan) now resolves it, and the daemon reports a failed supplement
so FailTask resolves it instead of leaving a completed-but-unresolved task.
- Timeout fallback now clears the real query state (useQuickActionsPendingTimeout)
instead of a component-local flag that only masked the UI; drop the skeleton's
and pill row's local timers.
- frontend-test type-scale: text-xs -> text-caption. Strip EOF blank line.
Co-authored-by: multica-agent <github@multica.ai>
* fix(chat): close quick-actions refresh races and failure feedback (MUL-5149)
Third-round review of the refresh button surfaced three issues; all three
are addressed here.
§1/§2 Session-busy race + concurrent-refresh double-spend: a newer reply
that is queued/running but whose assistant row hasn't landed leaves the old
turn as latest-persisted, so the stale check passes and the regen resumes
the newer provider state — attaching suggestions to the wrong turn. And two
concurrent refreshes each enqueue a quota-spending pass. Add
HasActiveChatTaskForSession and refuse a refresh (ErrChatQuickActionsBusy →
409) whenever the session has any task in flight, checked under the same
session lock as the enqueue so no sibling insert slips past.
§3a Timeout re-arm on surface switch: the pending marker now carries an
absolute expires_at deadline instead of a per-mount timer, so switching
between the floating window and the chat tab resumes the same deadline
rather than restarting a fresh 30s window each remount.
§3b Generation failure masked as success: runChatSuggestPass now returns ok
so an explicit refresh distinguishes a failed pass (didn't start / didn't
complete / timed out) from a completed-but-empty one. On failure the regen
task reports failure, resolveFailedRegenerateQuickActions broadcasts a
FAILED chat:quick_actions, and the client resolves the spinner AND toasts
"couldn't refresh" instead of silently stopping on unchanged pills.
Co-authored-by: multica-agent <github@multica.ai>
* fix(chat): count deferred tasks in refresh busy check; solid refresh icon tone (MUL-5149)
Two re-review blockers on 2dc9404d.
§1 (deferred window): HasActiveChatTaskForSession only treated
queued/dispatched/running/waiting_local_directory as in-flight, so a chat
auto-retry armed with a backoff fire_at — inserted 'deferred' by
CreateRetryTask, as provider_network's ~5s final attempt is — slipped past
the busy check. In that window the failed turn has no assistant row yet, so
the old turn is still latest-persisted and refreshable; the regen would then
resume a session the retry is about to advance and pin the new turn's
suggestions onto the old one. Add 'deferred' so the set matches the
canonical in-flight status list the rest of the queries already use
(agent.sql has-active-task checks). New regression test covers a deferred
active turn.
CI (text-contrast gate): the refresh icon button used
text-muted-foreground/70 (transparency standing in for a text tone), which
the frontend-test contrast gate rejects. Switch to the solid
text-faint-foreground token — the tone the gate recommends for icons/glyphs,
already used repo-wide and clearing WCAG 1.4.11.
Co-authored-by: multica-agent <github@multica.ai>
* test(chat): assert quick-actions pending marker carries expires_at (MUL-5149)
The chat:done supplement-flow test still expected the 2-field marker from
before the absolute-deadline change; applyChatDoneToCache now stamps
expires_at, so the deep-equal failed on frontend-test. Assert the deadline is
present (expect.any(Number)) rather than a wall-clock-dependent value — its
timing semantics are covered by the pending-timeout hook.
Co-authored-by: multica-agent <github@multica.ai>
* fix(db): renumber regenerate-quick-actions migration 237 -> 240 (MUL-5149)
main merged Issue Quick Actions (MUL-5465) taking migrations 237/238/239
(quick_action, quick_action_workspace_index, comment_quick_action). This
branch independently took 237 for agent_task_queue.regenerate_quick_actions_for.
The two 237s do not textually conflict (different filenames) so the PR reads
mergeable, but the merged tree would carry two migration 237s. Renumber this
one to 240 so it applies after main's chain. The migration is a standalone
`ALTER TABLE agent_task_queue ADD COLUMN IF NOT EXISTS` — order-independent,
touches a column none of main's migrations reference.
Co-authored-by: multica-agent <github@multica.ai>
---------
Co-authored-by: Lambda <lambda@multica.ai>
Co-authored-by: multica-agent <github@multica.ai>
Co-authored-by: Walt <walt@multica.ai>
Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
Co-authored-by: Naiyuan Qing <145280634+NevilleQingNY@users.noreply.github.com>
Co-authored-by: NevilleQingNY <nevilleqing@gmail.com>
1644 lines
53 KiB
TypeScript
1644 lines
53 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);
|
|
await expect(
|
|
client.getWorkspaceWorkingAgents("issue", undefined, "parent-1"),
|
|
).resolves.toEqual(payload);
|
|
// The server rejects parent alongside scope, so a My Issues relation wins
|
|
// and the parent is dropped rather than sent into a 400.
|
|
await expect(
|
|
client.getWorkspaceWorkingAgents("issue", "assigned", "parent-1"),
|
|
).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",
|
|
"https://api.example.test/api/working-agents?type=issue&parent=parent-1",
|
|
"https://api.example.test/api/working-agents?type=issue&scope=mine&relation=assigned",
|
|
]);
|
|
});
|
|
});
|
|
|
|
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",
|
|
chat_session_id: "session-1",
|
|
role: "user",
|
|
content: "hi",
|
|
task_id: null,
|
|
created_at: "2026-06-01T00:00:00Z",
|
|
quick_actions: [],
|
|
},
|
|
{
|
|
id: "m2",
|
|
chat_session_id: "session-1",
|
|
role: "assistant",
|
|
content: "yo",
|
|
task_id: "task-1",
|
|
created_at: "2026-06-01T00:00:01Z",
|
|
quick_actions: [
|
|
{ label: "Continue", prompt: "Continue with the next step", primary: true },
|
|
],
|
|
},
|
|
];
|
|
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("keeps a valid reply when its optional quick actions are malformed", async () => {
|
|
const malformed = [{
|
|
id: "m1",
|
|
chat_session_id: "session-1",
|
|
role: "assistant",
|
|
content: "safe reply",
|
|
task_id: "task-1",
|
|
created_at: "2026-06-01T00:00:00Z",
|
|
quick_actions: [{ label: 42, prompt: false }],
|
|
}];
|
|
vi.stubGlobal("fetch", vi.fn().mockResolvedValue(jsonResponse(malformed, 200)));
|
|
|
|
const client = new ApiClient("https://api.example.test");
|
|
await expect(client.listChatMessages("session-1")).resolves.toEqual([
|
|
expect.objectContaining({ content: "safe reply", quick_actions: [] }),
|
|
]);
|
|
});
|
|
|
|
it("falls back to an empty page for a malformed paged response", async () => {
|
|
vi.stubGlobal(
|
|
"fetch",
|
|
vi.fn().mockResolvedValue(jsonResponse({ messages: "broken" }, 200)),
|
|
);
|
|
|
|
const client = new ApiClient("https://api.example.test");
|
|
await expect(
|
|
client.listChatMessagesPage("session-1", { limit: 25 }),
|
|
).resolves.toEqual({ messages: [], limit: 25, 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" });
|
|
});
|
|
});
|
|
});
|
|
|
|
describe("ApiClient model discovery response schema", () => {
|
|
const completed = {
|
|
id: "req-1",
|
|
runtime_id: "rt-1",
|
|
status: "completed",
|
|
supported: true,
|
|
created_at: "2026-07-29T00:00:00Z",
|
|
updated_at: "2026-07-29T00:00:01Z",
|
|
models: [{ id: "claude-sonnet-4-6", label: "Claude Sonnet 4.6" }],
|
|
};
|
|
|
|
function stubJSON(body: unknown) {
|
|
vi.stubGlobal(
|
|
"fetch",
|
|
vi.fn().mockResolvedValue(
|
|
new Response(JSON.stringify(body), {
|
|
status: 200,
|
|
headers: { "Content-Type": "application/json" },
|
|
}),
|
|
),
|
|
);
|
|
}
|
|
|
|
it("parses a live completed discovery", async () => {
|
|
stubJSON(completed);
|
|
|
|
const result = await new ApiClient("https://api.example.test")
|
|
.initiateListModels("rt-1");
|
|
|
|
expect(result).toMatchObject({
|
|
status: "completed",
|
|
supported: true,
|
|
models: [{ id: "claude-sonnet-4-6" }],
|
|
});
|
|
});
|
|
|
|
it("keeps the cache markers on a server-cached snapshot", async () => {
|
|
stubJSON({ ...completed, cached: true, cached_at: "2026-07-29T00:00:00Z" });
|
|
|
|
const result = await new ApiClient("https://api.example.test")
|
|
.initiateListModels("rt-1");
|
|
|
|
expect(result.cached).toBe(true);
|
|
expect(result.cached_at).toBe("2026-07-29T00:00:00Z");
|
|
});
|
|
|
|
// The picker drives a state machine off `status`, so a malformed body must
|
|
// become an explicit failure — not a fabricated empty catalog, and not an
|
|
// endless "discovering models" spinner.
|
|
it("degrades a malformed initiate response to an explicit failure", async () => {
|
|
stubJSON({ status: 7, models: "nope" });
|
|
|
|
const result = await new ApiClient("https://api.example.test")
|
|
.initiateListModels("rt-1");
|
|
|
|
expect(result.status).toBe("failed");
|
|
expect(result.supported).toBe(true);
|
|
expect(result.error).toBe("invalid model discovery response");
|
|
expect(result.runtime_id).toBe("rt-1");
|
|
});
|
|
|
|
it("degrades a malformed poll response to an explicit failure", async () => {
|
|
stubJSON("not-an-object");
|
|
|
|
const result = await new ApiClient("https://api.example.test")
|
|
.getListModelsResult("rt-1", "req-9");
|
|
|
|
expect(result.status).toBe("failed");
|
|
expect(result.id).toBe("req-9");
|
|
expect(result.runtime_id).toBe("rt-1");
|
|
});
|
|
|
|
it("stays usable against a backend that omits supported", async () => {
|
|
const { supported: _omitted, ...withoutSupported } = completed;
|
|
stubJSON(withoutSupported);
|
|
|
|
const result = await new ApiClient("https://api.example.test")
|
|
.getListModelsResult("rt-1", "req-1");
|
|
|
|
expect(result.supported).toBe(true);
|
|
expect(result.status).toBe("completed");
|
|
});
|
|
});
|