mirror of
https://github.com/multica-ai/multica.git
synced 2026-07-31 00:40:46 +02:00
* feat(server): custom issue properties — definitions, typed values, CLI (MUL-4463)
Workspace-level property definitions (issue_property table; 7 types:
text/number/select/multi_select/date/checkbox/url) plus a typed value bag
on each issue (issue.properties JSONB keyed by definition UUID, mirroring
the metadata machinery: single-key atomic writes, 16KB cap, GIN index).
- Definitions: owner/admin only; agent actors rejected (agents propose,
humans confirm). 20 active per workspace, 50 options per select,
reserved built-in names blocked, archive instead of delete.
- Values: any member or agent; per-type validation with self-correcting
error messages that enumerate legal option ids.
- API: /api/properties CRUD + PUT/DELETE /api/issues/{id}/properties/{propertyId};
issue responses always emit the properties bag.
- CLI: multica property list/get/create/update/archive/unarchive and
multica issue property list/set/unset with name→id translation.
- Events: property:created/updated, issue_properties:changed.
Co-authored-by: multica-agent <github@multica.ai>
* feat(web): custom properties settings tab + issue sidebar editors (MUL-4463)
- Settings → Properties: definition management mirroring the Labels tab
(list with type badges/option chips/usage counts, create/edit dialog
with option editor, archive/restore, 20-cap indicator). Admin-gated;
members see a read-only catalog.
- Issue detail sidebar: custom properties join the built-in optional
props' progressive disclosure — set values render as rows with
type-appropriate editors (select/multi-select pickers, calendar,
yes/no, inline input for text/number/url), unset ones live in the
same '+ Add property' menu behind a separator. Archived definitions
render read-only until cleared.
- Core: property types, zod schemas (lenient type strings for forward
compat), api client methods, React Query hooks with optimistic
single-key value writes, ws-updaters + realtime wiring for
property:created/updated and issue_properties:changed.
- Locales: en/zh-Hans/ja/ko strings; Issue fixtures gain properties: {}.
Co-authored-by: multica-agent <github@multica.ai>
* fix(properties): address MUL-4463 review round 1 — mobile CI, option guard, mutation safety, schema tolerance
- mobile: EMPTY_ISSUE_FALLBACK gains the required properties field (mobile
typecheck was the red CI check).
- server: PATCH /api/properties/{id} rejects config updates that remove
select options still referenced by issues (409 with a per-option usage
census via jsonb ?); renames keep ids and pass. Integration test included.
- core: property value mutations are serialized per workspace via mutation
scope, snapshot the bag from detail OR list caches (board surfaces have no
detail cache — the old path overwrote whole bags with one key), roll back
to the snapshot or invalidate on error, and the last settled mutation does
an authoritative detail+catalog invalidate (usage counts reconcile).
- schemas: unknown-shaped property values (future server types) are dropped
per-entry in a preprocess step instead of failing the whole IssueSchema
and blanking lists through parseWithFallback; test updated to lock the
tolerant behavior.
- realtime: reconnect invalidation covers the property catalog; every
issue_properties:changed event also refreshes catalog usage counts.
- ui: number editor accepts decimals (step=any); settings usage count
pluralizes (issue/issues) with CJK-safe plural keys.
Co-authored-by: multica-agent <github@multica.ai>
* fix(migrations): renumber issue properties to 179 and build the GIN index concurrently
main's migration sequence advanced twice under this PR (167 collision, then
an upstream renumber wave that claimed 178), so issue properties now sits at
179 — verified against main's current tip by the prefix-uniqueness lint.
The properties GIN index moves to its own single-statement migration (180)
using CREATE INDEX CONCURRENTLY — a plain CREATE INDEX on the hot issue
table would block writes for the duration of the build. Mirrors the
119_user_created_at_index pattern; full-chain dry-run on a fresh database
passes through 180.
Co-authored-by: multica-agent <github@multica.ai>
* feat(web): custom-property list surfaces — filter, cards, sort, board grouping (MUL-4463 M2)
Brings custom properties to the issue list surfaces on top of the M1
definitions/values core:
- Filter: per-definition sections in the Filter dropdown (select /
multi_select options with color dots and counts; checkbox as Yes/No
pseudo-options). OR within a definition, AND across definitions;
client-side in applyIssueFilters, mirrored into filterAssigneeGroups
for the assignee-grouped board. Included in active-filter count and
Clear all.
- Cards: per-property Display toggles (cardPropertyIds) render value
chips on board cards and list rows via CustomPropertyValueDisplay.
- Sort: SortField gains property:<id> for number/date definitions.
Server keeps position order (fixed sort enum); the surface controller
re-sorts client-side, swimlane/gantt reuse the same comparator.
Date-only strings compare lexically; missing values sort last.
- Board grouping: IssueGrouping gains property:<id> for select
definitions — one column per option (definition order) plus a
trailing No-value column, option-colored headings. Drag-drop moves
position via UpdateIssue and applies the value through
useSetIssueProperty/useUnsetIssueProperty (properties are not part
of UpdateIssueRequest). Stale persisted property groupings fall back
to status columns.
View-store: propertyFilters + cardPropertyIds persisted via the
partialize allowlist; clearFilters resets property filters; new fields
deep-merge cleanly into pre-existing persisted snapshots.
Co-authored-by: multica-agent <github@multica.ai>
* fix(properties): address MUL-4463 review round 2 — desc sort, option bucketing, archived-state reconciliation
- sort: direction now applies to value comparison only; issues without a
value sort last in BOTH directions (the whole-array reverse flipped them
to the front on desc). Test covers the desc+missing case.
- board: values referencing an option removed from the definition bucket
into the No-value column instead of vanishing (unmatched column ids
dropped the issue entirely). Defense-in-depth behind the new server-side
in-use guard; drag-utils test locks both behaviors.
- controller: persisted propertyFilters keyed by archived/deleted
definitions are stripped before reaching the filter predicates, and a
persisted property sort on a non-active definition degrades to manual
order — previously both kept silently applying while the header claimed
otherwise. The filter badge counts only active-definition filters.
Co-authored-by: multica-agent <github@multica.ai>
* feat(properties): server-side property filtering and sorting on list endpoints
Property filter/sort now execute in the database, so results are correct
across the full issue set — not just the loaded 50-per-status window
(closes MUL-4493 item 1's filter/sort half; requested on MUL-4463).
- New `properties` query param on ListIssues and ListGroupedIssues:
JSON {definitionId: [values]} compiled to an AND-of-ORs containment
check (double NOT EXISTS over jsonb_array_elements). One value expands
to every storage shape it could match — string (select), array element
(multi_select), boolean (checkbox) — so the handler stays type-agnostic.
Guarded at 20 definitions / 50 values.
- `sort=property:<definitionId>` resolves the definition and orders by a
typed expression (numeric CASE cast for number, NULLIF text for
date/text/url); missing values sort last in both directions. Malformed
ids 400; unknown/archived definitions degrade to position order instead
of breaking stale clients.
- Frontend: the property filter and property sort ride the IssueSortParam
window bag, so every surface (workspace + my-issues variants), query
key, and per-status load-more page carries them automatically. The
client-side re-sort layer is gone; applyIssueFilters keeps its property
predicate as an optimistic-update backstop.
- Regression test seeds 55 issues and proves a match at position 55 is
returned by a filtered 50-row page, plus sort order/missing-last,
AND-across-definitions, and the 400/fallback sort paths.
Co-authored-by: multica-agent <github@multica.ai>
* fix(properties): address review round 3 — cache reconcile, merged-scope order, GIN-indexable filter, pool loader
- Cache reconciliation: property value writes (mutation settle + WS event)
now invalidate every issue window whose server-side shape depends on
property values — queries filtered by `properties` or sorted by
`property:<id>` (detected via query-key predicate), covering flat lists,
assignee groups, and my-issues variants. Windows without property params
keep the cheap in-place patch. Fixes stale ordering/membership/counts
under staleTime:Infinity.
- My Issues "All" scope: merged assigned/created/involves results are
re-sorted with a comparator mirroring the server ORDER BY semantics
(including property sorts and missing-last, created_at DESC tiebreak) in
both the flat and assignee-grouped merge paths — relation concatenation
no longer overrides the user's sort.
- Filter predicate rebuilt as plain bind-parameter containment ORs
(AND across definitions): EXPLAIN now shows BitmapOr over
idx_issue_properties_gin (the correlated jsonb_array_elements form
defeated the index). Alternatives capped at 256 bind params.
- Property-grouped board gains a pool loader strip: one sentinel per
status that still has server rows, keeping every issue reachable until
per-column pagination lands (MUL-4493).
- Windowing regression test hardened: explicit positions + an assertion
that the unfiltered first page excludes the target (the old fixture tied
at position 0 and the created_at DESC tiebreak put the target on page
one, proving nothing).
- Rollback safety: /api/properties 404 (old server) degrades to an empty
catalog instead of a query error, which also keeps property params from
ever being sent to pre-property servers; migration 179's CHECK
constraints switch to NOT VALID + VALIDATE so the exclusive lock is
instantaneous.
Co-authored-by: multica-agent <github@multica.ai>
* fix(properties): harden concurrency and cache coordination from clean-room review
Backend (MUL-4762 F1/F4/F5):
- withPropertyLock: pg_advisory_xact_lock helper; definition create/update
and value writes now serialize config-vs-value and cap-vs-insert races
(workspace-level 'props:' lock + per-definition 'prop:' lock, ordered).
- propertySortExpr degrades archived definitions to position sort.
Frontend (F2/F3/F6):
- onIssuePropertiesChanged invalidates plain assignee-group caches too.
- Property value mutations cancel list refetches in onMutate and roll back
only the touched key against the current bag (concurrent WS writes to
other keys survive a failed write).
- useUpdateIssue reconcile drops the stale properties bag from the server
snapshot; the property pipeline owns that field.
- Surface controller passes persisted property filters/sorts through
until the catalog query settles (cold cache no longer strips them).
Co-authored-by: multica-agent <github@multica.ai>
* fix(properties): open_only branch honors the properties filter
ListOpenIssues takes the parsed AND-of-ORs containment groups as a single
jsonb properties_filter param and unrolls them with a static double
NOT EXISTS; previously the open_only path parsed the properties param and
silently dropped it (clean-room review F7a).
Co-authored-by: multica-agent <github@multica.ai>
* fix(properties): toast on failed board drag to a property column
Property-column drags rolled the card back silently on failure; mirror
the status/assignee drag path (use-issue-surface-actions) so the
snap-back is explained (clean-room review F3, drag half).
Co-authored-by: multica-agent <github@multica.ai>
---------
Co-authored-by: Lambda <lambda@multica.ai>
Co-authored-by: multica-agent <github@multica.ai>
861 lines
30 KiB
TypeScript
861 lines
30 KiB
TypeScript
import { describe, expect, it } from "vitest";
|
||
import {
|
||
AppConfigSchema,
|
||
AgentTaskListSchema,
|
||
AutopilotRunSchema,
|
||
FALLBACK_AUTOPILOT_RUN,
|
||
CommentTriggerPreviewSchema,
|
||
DashboardAgentRunTimeListSchema,
|
||
DashboardUsageByAgentListSchema,
|
||
DashboardUsageDailyListSchema,
|
||
ChatDraftRestoresResponseSchema,
|
||
CreateFeedbackResponseSchema,
|
||
DuplicateIssueErrorBodySchema,
|
||
EMPTY_CHAT_DRAFT_RESTORES,
|
||
EMPTY_CREATE_FEEDBACK_RESPONSE,
|
||
EMPTY_INBOX_UNREAD_SUMMARY,
|
||
EMPTY_SEARCH_PROJECTS_RESPONSE,
|
||
EMPTY_USER,
|
||
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: "",
|
||
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");
|
||
});
|
||
|
||
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({});
|
||
});
|
||
});
|
||
|
||
// 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(RuntimeUsageListSchema.safeParse({ rows: [] }).success).toBe(false);
|
||
});
|
||
|
||
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("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"]);
|
||
});
|
||
});
|