Files
multica/packages/core/api/schemas.test.ts
Bohan Jiang 4d0475ce89 feat(usage): error/failure charts on the Usage page (MUL-5352) (#5991)
* feat(usage): add error/failure visibility to the Usage dashboard

The Usage page could only answer "how much did we spend"; nothing on it
showed how often agents fail, what kind of failure it was, or which agent
is responsible. Operators had to open failed tasks one at a time to spot a
pattern.

`agent_task_queue.failure_reason` already carries the refined 21-value
taxonomy from server/pkg/taskfailure, so this is a read path over data that
already exists.

Backend — two rollups, both scoped by workspace/project/window like the
existing dashboard endpoints:

  GET /api/dashboard/failures/daily     per-(date, failure_reason)
  GET /api/dashboard/failures/by-agent  per-(agent, failure_reason)

They return every terminal task, not just failures: the `failure_reason: ""`
row carries the succeeded count. That is what makes the error rate's
denominator share filters with its numerator. The run-time rollups can't
serve as that denominator — they require `started_at IS NOT NULL`, so a task
that expired in the queue (the signature of a runtime outage) contributes
nothing to their failed_count. A failed row with an empty reason column
lands in an `unclassified` bucket rather than being mistaken for a success.

Frontend:
- "Errors" joins the trend toggle, daily and weekly, stacked by failure
  class with the bucket's error rate in the tooltip.
- An Errors card breaks the window down by class and by agent, with the raw
  failure_reason strings behind a disclosure (unlocalised — an operator
  pastes them into a log search). Each agent row links to its Work tab,
  which lists the actual failed runs.
- The 21 backend reasons fold into 7 display classes in
  @multica/core/dashboard. Unknown reasons — including ones from a backend
  newer than the client — land in "other" instead of being dropped, so the
  class totals always reconcile with the failure count.

The Tasks KPI tile is deliberately left alone: its value counts started
tasks only, so quoting the failure rollup's larger count there would put two
denominators in one tile. The Errors card states its rate with the
denominator spelled out instead.

Migration 225 adds a partial index on agent_task_queue(completed_at) for
terminal statuses. The table had no completed_at index at all, so the two
pre-existing run-time rollups were already scanning it; these two new
queries would have doubled that.

Closes #4429 (MUL-5352)

Co-authored-by: multica-agent <github@multica.ai>

* fix(usage): correct the Errors drill-down, window and agent exposure

Review findings on PR #5991.

1. The drill-down pointed at the wrong page. `?view=work` renders
   ActorIssuesPanel — the issues assigned to the agent — while its runs live
   in the Overview pane's ActivityTab. Link to Overview.

   That page also could not show why a run failed: `failureReasonLabel` was a
   `Record<TaskFailureReason, string>` indexed with a cast to the old 6-value
   coarse enum, so every refined reason the backend has written since
   MUL-1949 resolved to `undefined`. It is now a function over the full
   21-value taxonomy plus the legacy coarse values, falling back to the raw
   wire string for anything newer than the client. Fixes the issue execution
   log too, which had the same cast.

2. The Errors card covered one more calendar day than the chart above it.
   `parseSinceParamInTZ` returns N+1 days of headroom on purpose and the
   dashboard trims the surplus client-side — but only a series carrying a
   date can be trimmed that way. Totals / classes / reasons now derive from
   the date-bucketed rollup after that trim, and the per-agent rollup (which
   has no date to trim on) closes its window server-side via a new
   `parseExactSinceParamInTZ`. At days=1 the card previously reported
   yesterday's failures beside a chart showing none.

3. The top-offenders list leaked agents the viewer cannot see. The failure
   rollups are workspace-scoped and deliberately skip per-agent visibility,
   but the agent list they are joined against does not — members only see a
   private agent when they own it or are owner/admin. `name ?? row.agentId`
   therefore rendered a bare UUID along with that agent's failure count,
   rate and dominant error class. Unresolvable agents now fold into one
   anonymous row, and the renderer never falls back to an id. Stricter than
   `bucketUnknownAgentRows` while the agent list loads: a transient flash of
   UUIDs is the leak, not a cosmetic glitch.

Also from the review: the Errors tooltip echoed the raw Recharts dataKey
("rate_limit") instead of the translated label the legend already carries.

Not changed — the schema's `failure_reason` default stays `""`. Defaulting a
missing field to a failure bucket guards against a deflated rate, but the
realistic drift is `omitempty` on the Go struct tag, which would strip the
field from exactly the SUCCESS rows and read as a 100% error rate. Added
TestDashboardFailureWireContractKeepsEmptyReason to pin that the server
always emits the field, which is the assumption the default rests on.

Co-authored-by: multica-agent <github@multica.ai>

* fix(usage): renumber migration and fix the anonymous bucket's failure class

Review findings on PR #5991, round 2.

1. Migration prefix 225 collided with `225_chat_message_channel_media_pending`,
   which landed on main while this branch was open — backend CI failed on
   TestMigrationNumericPrefixesStayUniqueAfterLegacySet. Merged main and
   renumbered to 231; main now carries 225 through 230, so 226 is taken too.

2. The anonymous "Other agents" bucket could announce the wrong failure class.
   It merged rows that had ALREADY collapsed to one dominant class per agent,
   then credited each agent's entire failure count to that class. An agent
   failing auth 6 / timeout 5 contributed 11 to auth and 0 to timeout, so a
   bucket whose real composition was timeout 15 / auth 6 rendered as Auth.

   Fixed by anonymizing the raw per-(agent, reason) rows instead: the sentinel
   becomes just another agent_id and `aggregateAgentFailures` computes its
   classes from real counts. That also deletes the parallel bucketing pass —
   one identity rewrite replaces it. `knownAgentIds` moves up to where both
   consumers can see it.

Also from the review:
- The wire-contract test decoded both payloads into one map. json.Unmarshal
  merges into a non-nil map rather than resetting it, so a residual
  failure_reason from the first case could have masked an omitempty
  regression in the second — exactly what the test is meant to catch. Now
  table-driven with a fresh map per case.
- A test comment still described the drill-down as pointing at the Work tab.

Co-authored-by: multica-agent <github@multica.ai>

---------

Co-authored-by: Bohan-J <bohan@devv.ai>
Co-authored-by: multica-agent <github@multica.ai>
2026-07-27 18:40:48 +08:00

992 lines
34 KiB
TypeScript
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import { describe, expect, it } from "vitest";
import {
AppConfigSchema,
AgentTaskListSchema,
AutopilotRunSchema,
FALLBACK_AUTOPILOT_RUN,
CommentTriggerPreviewSchema,
DashboardAgentRunTimeListSchema,
DashboardFailureByAgentListSchema,
DashboardFailureDailyListSchema,
DashboardUsageByAgentListSchema,
DashboardUsageDailyListSchema,
ChatDraftRestoresResponseSchema,
CreateFeedbackResponseSchema,
DuplicateIssueErrorBodySchema,
EMPTY_CHAT_DRAFT_RESTORES,
EMPTY_CREATE_FEEDBACK_RESPONSE,
EMPTY_INBOX_ITEMS,
EMPTY_INBOX_UNREAD_SUMMARY,
EMPTY_SEARCH_PROJECTS_RESPONSE,
EMPTY_USER,
InboxItemListSchema,
InboxUnreadSummarySchema,
IssueTriggerPreviewSchema,
ListIssuesResponseSchema,
ListPropertiesResponseSchema,
SearchProjectsResponseSchema,
RuntimeHourlyActivityListSchema,
RuntimeUsageByAgentListSchema,
RuntimeUsageByHourListSchema,
RuntimeUsageListSchema,
SquadListSchema,
SquadSchema,
TimelineEntriesSchema,
UserSchema,
} from "./schemas";
import { parseWithFallback } from "./schema";
const baseIssue = {
id: "11111111-1111-1111-1111-111111111111",
workspace_id: "ws-1",
number: 1,
identifier: "MUL-1",
title: "Test",
description: null,
status: "todo",
priority: "medium",
assignee_type: null,
assignee_id: null,
creator_type: "member",
creator_id: "user-1",
parent_issue_id: null,
project_id: null,
position: 0,
stage: null,
start_date: null,
due_date: null,
metadata: {},
created_at: "2026-01-01T00:00:00Z",
updated_at: "2026-01-01T00:00:00Z",
};
describe("IssueSchema (via ListIssuesResponseSchema)", () => {
it("accepts a primitive metadata KV map", () => {
const payload = {
issues: [
{
...baseIssue,
metadata: { pipeline_status: "waiting", pr_number: 3, is_blocked: true },
},
],
total: 1,
};
const parsed = ListIssuesResponseSchema.parse(payload);
expect(parsed.issues[0]?.metadata).toEqual({
pipeline_status: "waiting",
pr_number: 3,
is_blocked: true,
});
});
it("defaults metadata to {} when the server omits it (older backend)", () => {
const { metadata: _omit, ...issueWithoutMetadata } = baseIssue;
const payload = { issues: [issueWithoutMetadata], total: 1 };
const parsed = ListIssuesResponseSchema.parse(payload);
expect(parsed.issues[0]?.metadata).toEqual({});
});
it("rejects metadata with non-primitive values (nested object)", () => {
const payload = {
issues: [{ ...baseIssue, metadata: { nested: { x: 1 } } }],
total: 1,
};
expect(ListIssuesResponseSchema.safeParse(payload).success).toBe(false);
});
it("accepts a numeric stage", () => {
const payload = { issues: [{ ...baseIssue, stage: 2 }], total: 1 };
const parsed = ListIssuesResponseSchema.parse(payload);
expect(parsed.issues[0]?.stage).toBe(2);
});
it("defaults stage to null when the server omits it (older backend)", () => {
const { stage: _omit, ...issueWithoutStage } = baseIssue;
const payload = { issues: [issueWithoutStage], total: 1 };
const parsed = ListIssuesResponseSchema.parse(payload);
expect(parsed.issues[0]?.stage).toBeNull();
});
it("accepts custom property values including multi_select arrays", () => {
const payload = {
issues: [
{
...baseIssue,
properties: { "def-1": "opt-a", "def-2": ["opt-x", "opt-y"], "def-3": 3.5, "def-4": true },
},
],
total: 1,
};
const parsed = ListIssuesResponseSchema.parse(payload);
expect(parsed.issues[0]?.properties).toEqual({
"def-1": "opt-a",
"def-2": ["opt-x", "opt-y"],
"def-3": 3.5,
"def-4": true,
});
});
it("defaults properties to {} when the server omits it (older backend)", () => {
const parsed = ListIssuesResponseSchema.parse({ issues: [baseIssue], total: 1 });
expect(parsed.issues[0]?.properties).toEqual({});
});
it("drops unknown-shaped property values instead of failing the issue parse", () => {
// Forward compat: a future server type (actor/relation) may ship object
// values. That one entry must disappear; the issue and its other
// properties must survive — a full parse failure would blank the whole
// list through parseWithFallback on installed desktop builds.
const payload = {
issues: [
{
...baseIssue,
properties: { "def-1": { nested: 1 }, "def-2": "opt-a" },
},
],
total: 1,
};
const parsed = ListIssuesResponseSchema.parse(payload);
expect(parsed.issues[0]?.properties).toEqual({ "def-2": "opt-a" });
});
});
describe("IssuePropertySchema (via ListPropertiesResponseSchema)", () => {
const baseProperty = {
id: "22222222-2222-2222-2222-222222222222",
workspace_id: "ws-1",
name: "Severity",
type: "select",
description: "",
icon: "flag",
config: { options: [{ id: "opt-1", name: "Critical", color: "#ef4444" }] },
position: 1,
archived: false,
archived_at: null,
usage_count: 2,
created_at: "2026-01-01T00:00:00Z",
updated_at: "2026-01-01T00:00:00Z",
};
it("parses a full definition", () => {
const parsed = ListPropertiesResponseSchema.parse({ properties: [baseProperty], total: 1 });
expect(parsed.properties[0]?.config.options?.[0]?.name).toBe("Critical");
expect(parsed.properties[0]?.icon).toBe("flag");
});
it("survives a malformed response by defaulting the list", () => {
const parsed = ListPropertiesResponseSchema.parse({});
expect(parsed.properties).toEqual([]);
expect(parsed.total).toBe(0);
});
it("keeps unknown property types as strings (forward compat)", () => {
const parsed = ListPropertiesResponseSchema.parse({
properties: [{ ...baseProperty, type: "relation", config: {} }],
total: 1,
});
expect(parsed.properties[0]?.type).toBe("relation");
});
it("defaults config when the server sends none", () => {
const { config: _omit, ...withoutConfig } = baseProperty;
const parsed = ListPropertiesResponseSchema.parse({ properties: [withoutConfig], total: 1 });
expect(parsed.properties[0]?.config).toEqual({});
});
it("defaults icon for an older server response", () => {
const { icon: _omit, ...withoutIcon } = baseProperty;
const parsed = ListPropertiesResponseSchema.parse({ properties: [withoutIcon], total: 1 });
expect(parsed.properties[0]?.icon).toBe("");
});
});
// POST /api/issues/preview-trigger feeds this schema through parseWithFallback
// in client.previewIssueTrigger with fallback { triggers: [], total_count: 0 }
// (MUL-3375). The four entry points read it to decide "will this start a run",
// so malformed / missing / null drift must degrade to "nothing will start"
// rather than throw into the picker/modal.
const PREVIEW_FALLBACK = { triggers: [], total_count: 0 };
const PREVIEW_ENDPOINT = { endpoint: "POST /api/issues/preview-trigger" };
describe("IssueTriggerPreviewSchema", () => {
it("parses a well-formed response", () => {
const parsed = IssueTriggerPreviewSchema.parse({
triggers: [
{ issue_id: "i1", agent_id: "a1", source: "assign", handoff_supported: true },
{ issue_id: "i2", agent_id: "a2", source: "status", handoff_supported: false },
],
total_count: 2,
});
expect(parsed.total_count).toBe(2);
expect(parsed.triggers).toHaveLength(2);
expect(parsed.triggers[0]).toMatchObject({ issue_id: "i1", agent_id: "a1", source: "assign", handoff_supported: true });
});
it("defaults missing top-level fields (empty / older backend)", () => {
const parsed = IssueTriggerPreviewSchema.parse({});
expect(parsed.triggers).toEqual([]);
expect(parsed.total_count).toBe(0);
});
it("defaults missing optional item fields, keeping required issue_id", () => {
const parsed = IssueTriggerPreviewSchema.parse({ triggers: [{ issue_id: "i1" }], total_count: 1 });
expect(parsed.triggers[0]).toEqual({
issue_id: "i1",
agent_id: "",
source: "",
handoff_supported: false,
});
});
it("parseWithFallback returns the fallback for a malformed shape (triggers not an array)", () => {
const parsed = parseWithFallback(
{ triggers: "nope", total_count: 1 },
IssueTriggerPreviewSchema,
PREVIEW_FALLBACK,
PREVIEW_ENDPOINT,
);
expect(parsed).toEqual(PREVIEW_FALLBACK);
});
it("parseWithFallback returns the fallback when an item drops the required issue_id", () => {
const parsed = parseWithFallback(
{ triggers: [{ agent_id: "a1", source: "assign" }], total_count: 1 },
IssueTriggerPreviewSchema,
PREVIEW_FALLBACK,
PREVIEW_ENDPOINT,
);
expect(parsed).toEqual(PREVIEW_FALLBACK);
});
it("parseWithFallback returns the fallback for a wrong-typed total_count", () => {
const parsed = parseWithFallback(
{ triggers: [], total_count: "5" },
IssueTriggerPreviewSchema,
PREVIEW_FALLBACK,
PREVIEW_ENDPOINT,
);
expect(parsed).toEqual(PREVIEW_FALLBACK);
});
it("parseWithFallback returns the fallback for null / non-object bodies", () => {
expect(parseWithFallback(null, IssueTriggerPreviewSchema, PREVIEW_FALLBACK, PREVIEW_ENDPOINT)).toEqual(PREVIEW_FALLBACK);
expect(parseWithFallback("oops", IssueTriggerPreviewSchema, PREVIEW_FALLBACK, PREVIEW_ENDPOINT)).toEqual(PREVIEW_FALLBACK);
});
});
describe("TimelineEntriesSchema", () => {
it("preserves source_task_id for agent failure comments", () => {
const parsed = TimelineEntriesSchema.parse([
{
type: "comment",
id: "comment-1",
actor_type: "agent",
actor_id: "agent-1",
created_at: "2026-01-01T00:00:00Z",
content: "API Error: 500 Internal server error",
comment_type: "system",
source_task_id: "task-1",
},
]);
expect(parsed[0]?.source_task_id).toBe("task-1");
});
});
describe("AgentTaskListSchema", () => {
const task = {
id: "task-1",
agent_id: "agent-1",
runtime_id: "runtime-1",
issue_id: "issue-1",
status: "queued",
priority: 0,
dispatched_at: null,
started_at: null,
completed_at: null,
result: null,
error: null,
created_at: "2026-07-10T00:00:00Z",
trigger_comment_id: "comment-3",
};
it("preserves planned and delivered comment IDs for a task run", () => {
const parsed = AgentTaskListSchema.parse([
{
...task,
coalesced_comment_ids: ["comment-1", "comment-2"],
delivered_comment_ids: ["comment-1", "comment-2", "comment-3"],
},
]);
expect(parsed[0]?.trigger_comment_id).toBe("comment-3");
expect(parsed[0]?.coalesced_comment_ids).toEqual([
"comment-1",
"comment-2",
]);
expect(parsed[0]?.delivered_comment_ids).toEqual([
"comment-1",
"comment-2",
"comment-3",
]);
});
it("accepts task payloads from older backends without comment coverage", () => {
const parsed = AgentTaskListSchema.parse([task]);
expect(parsed[0]?.coalesced_comment_ids).toBeUndefined();
expect(parsed[0]?.delivered_comment_ids).toBeUndefined();
});
it("degrades malformed optional coverage without dropping task rows", () => {
const parsed = AgentTaskListSchema.parse([
{
...task,
coalesced_comment_ids: ["comment-1", 2],
delivered_comment_ids: "not-an-array",
},
{
...task,
id: "task-2",
delivered_comment_ids: ["comment-2", "comment-3"],
},
]);
expect(parsed).toHaveLength(2);
expect(parsed[0]?.coalesced_comment_ids).toBeUndefined();
expect(parsed[0]?.delivered_comment_ids).toBeUndefined();
expect(parsed[1]?.delivered_comment_ids).toEqual([
"comment-2",
"comment-3",
]);
});
});
describe("ChatDraftRestoresResponseSchema", () => {
it("parses a well-formed response with attachments", () => {
const parsed = parseWithFallback(
{
restores: [
{
id: "msg-1",
chat_session_id: "s-1",
task_id: "t-1",
content: "run the thing",
attachments: [{ id: "att-1", filename: "notes.txt" }],
created_at: "2026-07-01T00:00:00Z",
},
],
},
ChatDraftRestoresResponseSchema,
EMPTY_CHAT_DRAFT_RESTORES,
{ endpoint: "test" },
);
expect(parsed.restores).toHaveLength(1);
expect(parsed.restores[0]?.content).toBe("run the thing");
expect(parsed.restores[0]?.attachments?.[0]?.id).toBe("att-1");
});
it("defaults a missing restores array instead of crashing the composer", () => {
const parsed = parseWithFallback(
{},
ChatDraftRestoresResponseSchema,
EMPTY_CHAT_DRAFT_RESTORES,
{ endpoint: "test" },
);
expect(parsed.restores).toEqual([]);
});
it("falls back to the empty response on a malformed row", () => {
// A row without the consume key (id) is unusable — the whole response
// falls back and the durable rows simply stay pending server-side.
const parsed = parseWithFallback(
{ restores: [{ chat_session_id: "s-1", content: 42 }] },
ChatDraftRestoresResponseSchema,
EMPTY_CHAT_DRAFT_RESTORES,
{ endpoint: "test" },
);
expect(parsed).toEqual(EMPTY_CHAT_DRAFT_RESTORES);
});
});
describe("CreateFeedbackResponseSchema", () => {
const ENDPOINT = { endpoint: "POST /api/feedback" };
it("parses a well-formed response and preserves extra fields", () => {
const parsed = parseWithFallback(
{ id: "feedback-1", created_at: "2026-06-26T00:00:00Z", future_field: true },
CreateFeedbackResponseSchema,
EMPTY_CREATE_FEEDBACK_RESPONSE,
ENDPOINT,
);
expect(parsed).toMatchObject({
id: "feedback-1",
created_at: "2026-06-26T00:00:00Z",
future_field: true,
});
});
it("returns the empty fallback for malformed feedback responses", () => {
expect(
parseWithFallback(
{ id: 123, created_at: "2026-06-26T00:00:00Z" },
CreateFeedbackResponseSchema,
EMPTY_CREATE_FEEDBACK_RESPONSE,
ENDPOINT,
),
).toBe(EMPTY_CREATE_FEEDBACK_RESPONSE);
expect(
parseWithFallback(null, CreateFeedbackResponseSchema, EMPTY_CREATE_FEEDBACK_RESPONSE, ENDPOINT),
).toBe(EMPTY_CREATE_FEEDBACK_RESPONSE);
});
});
// The duplicate-issue branch in create-issue.tsx feeds ApiError.body
// (typed as `unknown`) through this schema. Any future server drift that
// loses the contract MUST fail the parse so the UI falls back to a normal
// error toast instead of rendering an empty / partial duplicate card.
describe("DuplicateIssueErrorBodySchema", () => {
const valid = {
code: "active_duplicate_issue",
error: "An active issue with this title already exists: MUL-12 Login bug",
issue: {
id: "11111111-1111-1111-1111-111111111111",
identifier: "MUL-12",
title: "Login bug",
},
};
it("accepts a well-formed body", () => {
expect(DuplicateIssueErrorBodySchema.safeParse(valid).success).toBe(true);
});
it("accepts unknown extra fields via .loose()", () => {
const forwardCompat = {
...valid,
hint: "Try a different title",
issue: { ...valid.issue, workspace_id: "ws-1", status: "todo" },
};
expect(DuplicateIssueErrorBodySchema.safeParse(forwardCompat).success).toBe(true);
});
it("rejects a renamed code (so renames degrade to the generic toast)", () => {
const renamed = { ...valid, code: "duplicate_issue" };
expect(DuplicateIssueErrorBodySchema.safeParse(renamed).success).toBe(false);
});
it("rejects a missing issue object", () => {
const { issue: _omit, ...without } = valid;
expect(DuplicateIssueErrorBodySchema.safeParse(without).success).toBe(false);
});
it("rejects a non-string issue.id", () => {
const broken = { ...valid, issue: { ...valid.issue, id: 42 } };
expect(DuplicateIssueErrorBodySchema.safeParse(broken).success).toBe(false);
});
it("accepts a missing error field (it is optional)", () => {
const { error: _omit, ...without } = valid;
expect(DuplicateIssueErrorBodySchema.safeParse(without).success).toBe(true);
});
});
// `user.timezone` (Viewing tz) was added in the timezone-architecture RFC.
// A desktop build older than the server — or a server predating the
// `user.timezone` migration — will return a `/api/me` body with no
// `timezone` key. The schema must not fail closed on that: the field
// defaults to `null`, which the frontend resolves to the browser-detected
// tz at render time.
describe("UserSchema timezone drift", () => {
const base = {
id: "11111111-1111-1111-1111-111111111111",
name: "Ada",
email: "ada@example.com",
};
it("defaults timezone to null when the field is absent", () => {
const parsed = UserSchema.parse(base);
expect(parsed.timezone).toBe(null);
});
it("preserves an explicit IANA timezone", () => {
const parsed = UserSchema.parse({ ...base, timezone: "Asia/Tokyo" });
expect(parsed.timezone).toBe("Asia/Tokyo");
});
it("accepts an explicit null timezone", () => {
const parsed = UserSchema.parse({ ...base, timezone: null });
expect(parsed.timezone).toBe(null);
});
// Wrong-type drift: a future server bug sending `timezone` as a number
// must not throw into the UI. parseWithFallback degrades the whole user
// object to the explicit fallback (EMPTY_USER) so /api/me callers keep a
// valid shape instead of white-screening.
it("falls back to EMPTY_USER when timezone is the wrong type", () => {
const parsed = parseWithFallback(
{ ...base, timezone: 42 },
UserSchema,
EMPTY_USER,
{ endpoint: "GET /api/me" },
);
expect(parsed).toBe(EMPTY_USER);
});
});
describe("SquadListSchema member preview drift", () => {
const baseSquad = {
id: "squad-1",
workspace_id: "ws-1",
name: "Frontend Squad",
description: "",
instructions: "",
avatar_url: null,
leader_id: "agent-1",
creator_id: "user-1",
created_at: "2026-05-01T00:00:00Z",
updated_at: "2026-05-01T00:00:00Z",
archived_at: null,
archived_by: null,
};
it("defaults preview fields when an older backend omits them", () => {
const parsed = SquadListSchema.parse([baseSquad]);
expect(parsed[0]?.member_count).toBe(0);
expect(parsed[0]?.member_preview).toEqual([]);
});
it("defaults preview fields on a single squad response", () => {
const parsed = SquadSchema.parse(baseSquad);
expect(parsed.member_count).toBe(0);
expect(parsed.member_preview).toEqual([]);
});
it("preserves lightweight member preview rows", () => {
const parsed = SquadListSchema.parse([
{
...baseSquad,
member_count: 2,
member_preview: [
{ member_type: "agent", member_id: "agent-1", role: "leader" },
{ member_type: "member", member_id: "user-2", role: "member" },
],
},
]);
expect(parsed[0]?.member_count).toBe(2);
expect(parsed[0]?.member_preview).toHaveLength(2);
expect(parsed[0]?.member_preview?.[0]?.role).toBe("leader");
});
});
// The workspace dashboard and runtime-detail pages were re-pointed at the
// unified `task_usage_hourly` rollup. Every numeric field drives chart /
// KPI math, and string keys (date / agent_id / model) bucket the series.
// The contract these schemas must hold: a row missing a field degrades
// that field to a sane default rather than dropping the WHOLE array to
// the `[]` fallback — one drifted row must not blank the entire chart.
describe("dashboard + runtime usage schema drift", () => {
it("coerces a missing numeric field to 0 instead of dropping the array", () => {
const parsed = DashboardUsageDailyListSchema.parse([
{ date: "2026-05-19", model: "claude-opus-4-7", input_tokens: 100 },
]);
expect(parsed).toHaveLength(1);
expect(parsed[0]?.output_tokens).toBe(0);
expect(parsed[0]?.cache_read_tokens).toBe(0);
expect(parsed[0]?.cache_write_tokens).toBe(0);
});
it("coerces a missing date key to \"\" so the rest of the series survives", () => {
const parsed = DashboardUsageDailyListSchema.parse([
{ model: "claude-opus-4-7", input_tokens: 5 },
]);
expect(parsed).toHaveLength(1);
expect(parsed[0]?.date).toBe("");
});
it("coerces a missing agent_id key to \"\" for the agent-runtime panel", () => {
const parsed = DashboardAgentRunTimeListSchema.parse([
{ total_seconds: 42, task_count: 3, failed_count: 0 },
]);
expect(parsed).toHaveLength(1);
expect(parsed[0]?.agent_id).toBe("");
});
it("coerces a missing agent_id key to \"\" for the usage-by-agent panel", () => {
const parsed = DashboardUsageByAgentListSchema.parse([
{ model: "claude-opus-4-7", input_tokens: 7 },
]);
expect(parsed[0]?.agent_id).toBe("");
});
it("coerces missing fields on every runtime usage schema", () => {
expect(RuntimeUsageListSchema.parse([{ date: "2026-05-19" }])[0]?.input_tokens).toBe(0);
expect(RuntimeHourlyActivityListSchema.parse([{ hour: 9 }])[0]?.count).toBe(0);
expect(RuntimeUsageByAgentListSchema.parse([{ model: "x" }])[0]?.agent_id).toBe("");
expect(RuntimeUsageByHourListSchema.parse([{ hour: 9 }])[0]?.model).toBe("");
});
it("defaults a missing provider to \"\" so an older server's rows still price by bare model", () => {
// provider was added for cross-provider model disambiguation; a server
// predating it omits the field. The schema must fill "" (→ bare-model
// pricing lookup) rather than drop the row.
expect(
DashboardUsageDailyListSchema.parse([{ date: "2026-05-19", model: "claude-opus-4-7" }])[0]
?.provider,
).toBe("");
expect(
DashboardUsageByAgentListSchema.parse([{ model: "claude-opus-4-7" }])[0]?.provider,
).toBe("");
expect(RuntimeUsageByAgentListSchema.parse([{ model: "x" }])[0]?.provider).toBe("");
});
it("rejects a non-array body so parseWithFallback can return its fallback", () => {
expect(DashboardUsageDailyListSchema.safeParse(null).success).toBe(false);
expect(DashboardFailureDailyListSchema.safeParse(null).success).toBe(false);
expect(DashboardFailureByAgentListSchema.safeParse({ rows: [] }).success).toBe(
false,
);
expect(RuntimeUsageListSchema.safeParse({ rows: [] }).success).toBe(false);
});
it("keeps a failure_reason the client build has never heard of", () => {
// failure_reason is an open string, not an enum: the backend taxonomy
// grows, and an installed desktop client must still count a reason its
// build predates rather than dropping the row (and with it the day's
// error total).
const parsed = DashboardFailureDailyListSchema.parse([
{ date: "2026-05-19", failure_reason: "agent_error.brand_new", task_count: 3 },
]);
expect(parsed).toHaveLength(1);
expect(parsed[0]?.failure_reason).toBe("agent_error.brand_new");
expect(parsed[0]?.task_count).toBe(3);
});
it("coerces a missing failure row field without dropping the array", () => {
const daily = DashboardFailureDailyListSchema.parse([{ date: "2026-05-19" }]);
expect(daily).toHaveLength(1);
// "" is the succeeded bucket, so a reason-less row lands in the
// denominator instead of inventing a failure that never happened.
//
// Defaulting to a failure bucket instead was considered and rejected: the
// realistic drift here is someone adding `omitempty` to the Go struct
// tag, which would strip the field from exactly the SUCCESS rows and turn
// every window into a 100% error rate. Deflating a rate under drift is
// the milder failure. TestDashboardFailureWireContractKeepsEmptyReason
// (server/internal/handler/dashboard_test.go) guards the other side by
// pinning that the server always emits the field.
expect(daily[0]?.failure_reason).toBe("");
expect(daily[0]?.task_count).toBe(0);
const byAgent = DashboardFailureByAgentListSchema.parse([
{ failure_reason: "timeout", task_count: 2 },
]);
expect(byAgent[0]?.agent_id).toBe("");
});
it("keeps unknown server-side fields via .loose()", () => {
const parsed = RuntimeUsageListSchema.parse([
{ date: "2026-05-19", region: "us-east" },
]);
expect((parsed[0] as Record<string, unknown>).region).toBe("us-east");
});
});
describe("AppConfigSchema cdn_signed drift", () => {
it("defaults cdn_signed to false when the server omits it (pre-MUL-3254 servers)", () => {
const parsed = AppConfigSchema.parse({ cdn_domain: "cdn.example.com" });
expect(parsed.cdn_signed).toBe(false);
});
it("coerces a malformed cdn_signed to false instead of failing the whole config", () => {
const parsed = AppConfigSchema.parse({
cdn_domain: "cdn.example.com",
cdn_signed: "yes",
});
expect(parsed.cdn_signed).toBe(false);
expect(parsed.cdn_domain).toBe("cdn.example.com");
});
it("keeps cdn_signed=true from a signing-enabled server", () => {
const parsed = AppConfigSchema.parse({ cdn_signed: true });
expect(parsed.cdn_signed).toBe(true);
});
it("parses frontend feature flag decisions", () => {
const parsed = AppConfigSchema.parse({
feature_flags: {
composio_mcp_apps: true,
malformed_future_flag: "yes",
},
});
expect(parsed.feature_flags).toEqual({
composio_mcp_apps: true,
malformed_future_flag: false,
});
});
it("defaults malformed feature_flags to an empty object", () => {
const parsed = AppConfigSchema.parse({ feature_flags: ["not", "an", "object"] });
expect(parsed.feature_flags).toEqual({});
});
it("parses server_version and leaves it undefined when the server omits it", () => {
expect(AppConfigSchema.parse({ server_version: "1.2.3" }).server_version).toBe("1.2.3");
expect(AppConfigSchema.parse({}).server_version).toBeUndefined();
});
});
describe("InboxUnreadSummarySchema", () => {
const ENDPOINT = { endpoint: "GET /api/inbox/unread-summary" };
it("parses a well-formed summary and tolerates extra fields", () => {
const parsed = parseWithFallback(
[
{ workspace_id: "ws-1", count: 2 },
{ workspace_id: "ws-2", count: 0, future_field: "ignored" },
],
InboxUnreadSummarySchema,
EMPTY_INBOX_UNREAD_SUMMARY,
ENDPOINT,
);
expect(parsed).toEqual([
{ workspace_id: "ws-1", count: 2 },
{ workspace_id: "ws-2", count: 0, future_field: "ignored" },
]);
});
it("returns the empty fallback (dot hidden) for a non-array body", () => {
expect(
parseWithFallback({ rows: [] }, InboxUnreadSummarySchema, EMPTY_INBOX_UNREAD_SUMMARY, ENDPOINT),
).toBe(EMPTY_INBOX_UNREAD_SUMMARY);
expect(
parseWithFallback(null, InboxUnreadSummarySchema, EMPTY_INBOX_UNREAD_SUMMARY, ENDPOINT),
).toBe(EMPTY_INBOX_UNREAD_SUMMARY);
});
it("returns the empty fallback when an entry has a wrong-typed count", () => {
expect(
parseWithFallback(
[{ workspace_id: "ws-1", count: "lots" }],
InboxUnreadSummarySchema,
EMPTY_INBOX_UNREAD_SUMMARY,
ENDPOINT,
),
).toBe(EMPTY_INBOX_UNREAD_SUMMARY);
});
});
describe("InboxItemListSchema", () => {
const ENDPOINT = { endpoint: "GET /api/inbox/archived" };
const row = (overrides: Record<string, unknown> = {}) => ({
id: "inbox-1",
workspace_id: "ws-1",
recipient_type: "member",
recipient_id: "member-1",
type: "new_comment",
severity: "info",
issue_id: "issue-1",
title: "Issue title",
body: null,
read: false,
archived: true,
created_at: "2026-06-15T08:00:00Z",
...overrides,
});
it("parses a well-formed archived list and tolerates extra fields", () => {
const parsed = parseWithFallback(
[row({ issue_status: "in_progress", details: { comment_id: "c-1" }, future_field: 1 })],
InboxItemListSchema,
EMPTY_INBOX_ITEMS,
ENDPOINT,
);
expect(parsed).toHaveLength(1);
expect(parsed[0]).toMatchObject({ id: "inbox-1", archived: true });
});
it("keeps a notification type this client doesn't know yet", () => {
// Enums stay lenient on purpose: a backend that ships a new inbox type
// must not blank the whole archived list on older clients.
const parsed = parseWithFallback(
[row({ type: "some_future_type", severity: "future_severity" })],
InboxItemListSchema,
EMPTY_INBOX_ITEMS,
ENDPOINT,
);
expect(parsed).toHaveLength(1);
});
it("accepts rows that omit the nullable optional fields", () => {
const { body, issue_id, ...withoutOptionals } = row();
void body;
void issue_id;
expect(
parseWithFallback([withoutOptionals], InboxItemListSchema, EMPTY_INBOX_ITEMS, ENDPOINT),
).toHaveLength(1);
});
it("returns the empty fallback for a non-array body", () => {
expect(
parseWithFallback({ items: [] }, InboxItemListSchema, EMPTY_INBOX_ITEMS, ENDPOINT),
).toBe(EMPTY_INBOX_ITEMS);
expect(
parseWithFallback(null, InboxItemListSchema, EMPTY_INBOX_ITEMS, ENDPOINT),
).toBe(EMPTY_INBOX_ITEMS);
});
it("returns the empty fallback when a row is missing a required field", () => {
const { id, ...withoutId } = row();
void id;
expect(
parseWithFallback([withoutId], InboxItemListSchema, EMPTY_INBOX_ITEMS, ENDPOINT),
).toBe(EMPTY_INBOX_ITEMS);
});
it("returns the empty fallback when `archived` is wrong-typed", () => {
expect(
parseWithFallback(
[row({ archived: "yes" })],
InboxItemListSchema,
EMPTY_INBOX_ITEMS,
ENDPOINT,
),
).toBe(EMPTY_INBOX_ITEMS);
});
});
describe("SearchProjectsResponseSchema date drift", () => {
const ENDPOINT = { endpoint: "GET /api/projects/search" };
const baseProject = {
id: "p-1",
workspace_id: "ws-1",
title: "Launch",
description: null,
icon: null,
status: "in_progress",
priority: "high",
lead_type: null,
lead_id: null,
created_at: "2026-01-01T00:00:00Z",
updated_at: "2026-01-01T00:00:00Z",
issue_count: 0,
done_count: 0,
resource_count: 0,
match_source: "title",
};
it("parses start_date / due_date when the backend returns them", () => {
const parsed = parseWithFallback(
{ projects: [{ ...baseProject, start_date: "2026-03-01", due_date: "2026-03-31" }], total: 1 },
SearchProjectsResponseSchema,
EMPTY_SEARCH_PROJECTS_RESPONSE,
ENDPOINT,
);
expect(parsed.projects[0]?.start_date).toBe("2026-03-01");
expect(parsed.projects[0]?.due_date).toBe("2026-03-31");
});
// Frontend deploys before backend: an older backend omits the new keys. The
// .default(null) must keep the whole batch parseable (→ null), not degrade
// it to the empty fallback and blank the search results.
it("defaults missing start_date / due_date to null without dropping results", () => {
const parsed = parseWithFallback(
{ projects: [baseProject], total: 1 },
SearchProjectsResponseSchema,
EMPTY_SEARCH_PROJECTS_RESPONSE,
ENDPOINT,
);
expect(parsed).not.toBe(EMPTY_SEARCH_PROJECTS_RESPONSE);
expect(parsed.projects).toHaveLength(1);
expect(parsed.projects[0]?.start_date).toBeNull();
expect(parsed.projects[0]?.due_date).toBeNull();
});
});
// The "run now" flow branches on run.status/reason_code to avoid a false-success
// toast (MUL-4525), so the trigger response must survive backend drift.
describe("AutopilotRunSchema", () => {
const ENDPOINT = { endpoint: "POST /api/autopilots/:id/trigger" };
const baseRun = {
id: "run-1",
autopilot_id: "ap-1",
trigger_id: null,
source: "manual",
status: "issue_created",
issue_id: "issue-1",
task_id: null,
triggered_at: "2026-07-14T00:00:00Z",
completed_at: null,
failure_reason: null,
trigger_payload: null,
result: null,
created_at: "2026-07-14T00:00:00Z",
};
it("preserves a blocked run's status and reason_code", () => {
const parsed = parseWithFallback(
{ ...baseRun, status: "skipped", failure_reason: "you are not allowed to trigger this autopilot's assignee agent", reason_code: "invocation_not_allowed" },
AutopilotRunSchema,
FALLBACK_AUTOPILOT_RUN,
ENDPOINT,
);
expect(parsed.status).toBe("skipped");
expect(parsed.reason_code).toBe("invocation_not_allowed");
});
it("tolerates an older server omitting reason_code", () => {
const parsed = parseWithFallback(baseRun, AutopilotRunSchema, FALLBACK_AUTOPILOT_RUN, ENDPOINT);
expect(parsed.status).toBe("issue_created");
expect(parsed.reason_code).toBeUndefined();
});
it("degrades a malformed response to a non-success fallback (never a false success)", () => {
const parsed = parseWithFallback("not-an-object", AutopilotRunSchema, FALLBACK_AUTOPILOT_RUN, ENDPOINT);
expect(parsed).toBe(FALLBACK_AUTOPILOT_RUN);
expect(parsed.status).toBe("failed");
});
});
// The comment composer branches on preview.blocked to warn before sending
// (MUL-4525 §2), so the additive field must parse and degrade gracefully.
describe("CommentTriggerPreviewSchema.blocked", () => {
it("parses blocked mention outcomes alongside agents", () => {
const parsed = CommentTriggerPreviewSchema.parse({
agents: [{ id: "a1", source: "mention_agent", reason: "" }],
blocked: [
{ target_type: "squad", target_id: "s1", status: "blocked", reason_code: "invocation_not_allowed" },
],
});
expect(parsed.agents).toHaveLength(1);
expect(parsed.blocked).toEqual([
{ target_type: "squad", target_id: "s1", status: "blocked", reason_code: "invocation_not_allowed" },
]);
});
it("defaults blocked to [] when an older server omits it", () => {
const parsed = CommentTriggerPreviewSchema.parse({ agents: [] });
expect(parsed.blocked).toEqual([]);
});
it("degrades a malformed blocked field to [] without dropping agents", () => {
const parsed = CommentTriggerPreviewSchema.parse({
agents: [{ id: "a1", source: "mention_agent", reason: "" }],
blocked: "nope",
});
expect(parsed.agents).toHaveLength(1);
expect(parsed.blocked).toEqual([]);
});
it("drops a single malformed blocked entry without discarding the valid ones", () => {
const parsed = CommentTriggerPreviewSchema.parse({
agents: [],
blocked: [
{ target_type: "squad", target_id: "s1", status: "blocked", reason_code: "invocation_not_allowed" },
{ status: "blocked" }, // missing target_id → dropped individually
{ target_type: "agent", target_id: "a1", status: "blocked", reason_code: "runtime_offline" },
],
});
expect(parsed.blocked.map((b) => b.target_id)).toEqual(["s1", "a1"]);
});
});