fix(web): stop new Office tasks from disappearing off the board (#2829)

Co-authored-by: nova28 <17953305+nova28@users.noreply.github.com>
Co-authored-by: Carlos Florencio <carlosmflorencio@gmail.com>
This commit is contained in:
nova28
2026-08-20 20:09:25 +08:00
committed by GitHub
parent 748b0c98de
commit 21336de2b2
28 changed files with 908 additions and 329 deletions
@@ -7,7 +7,7 @@ import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@
import { getTask } from "@/lib/api/domains/office-extended-api";
import type { OfficeTask } from "@/lib/state/slices/office/types";
import { StatusIcon } from "@/app/office/tasks/status-icon";
import { normalizeTaskStatus } from "@/app/office/tasks/normalize-status";
import { normalizeTaskStatus } from "@/lib/api/domains/office-task-normalize";
import { PRIORITY_LABEL_KEYS, STATUS_LABEL_KEYS } from "@/app/office/lib/label-keys";
import { useTranslation } from "react-i18next";
@@ -0,0 +1,33 @@
import { describe, expect, it } from "vitest";
import { mapOfficeTaskToTask } from "./page";
import type { OfficeTask } from "@/lib/state/slices/office/types";
const baseRaw: OfficeTask = {
id: "task-1",
workspaceId: "workspace-1",
identifier: "E2E-1",
title: "Mapper regression task",
status: "todo",
priority: "medium",
createdAt: "2026-05-01T10:00:00Z",
updatedAt: "2026-05-01T10:00:00Z",
};
describe("mapOfficeTaskToTask", () => {
// Regression: the detail page seeds its Task from the store-held OfficeTask
// before the API GET resolves (page.tsx's useIssueData). Dropping rawStatus
// here silently breaks ExecutionIndicator's Live/Ready sub-state distinction
// for however long the seeded task is shown — see task-advanced-mode.test.tsx
// and OfficeSimplePane.test.tsx for the consumer-level regression.
it("carries rawStatus through when the store holds one", () => {
const mapped = mapOfficeTaskToTask({ ...baseRaw, status: "todo", rawStatus: "SCHEDULING" });
expect(mapped.rawStatus).toBe("SCHEDULING");
});
it("falls back to the canonical status when rawStatus is absent", () => {
const mapped = mapOfficeTaskToTask({ ...baseRaw, status: "in_progress" });
expect(mapped.rawStatus).toBe("in_progress");
});
});
+2 -1
View File
@@ -58,7 +58,7 @@ function mapDecisionDTO(d: TaskDecisionDTO): TaskDecision {
};
}
function mapOfficeTaskToTask(raw: OfficeTask): Task {
export function mapOfficeTaskToTask(raw: OfficeTask): Task {
// The server DTO includes reviewers/approvers/decisions even though the
// strongly-typed OfficeTask only declares the cross-cutting fields. We
// read those extra props off the raw object.
@@ -75,6 +75,7 @@ function mapOfficeTaskToTask(raw: OfficeTask): Task {
title: raw.title,
description: raw.description,
status: raw.status as Task["status"],
rawStatus: raw.rawStatus ?? raw.status,
priority: (raw.priority || "medium") as Task["priority"],
labels: (raw.labels ?? []).map((l) =>
typeof l === "string" ? { name: l, color: "#6b7280" } : l,
@@ -0,0 +1,100 @@
import { afterEach, describe, expect, it, vi } from "vitest";
import { cleanup, render, screen } from "@testing-library/react";
import type { Task } from "./types";
vi.mock("@/lib/routing/client-dynamic", () => ({
default: () =>
function MockOfficeDockviewLayout() {
return null;
},
}));
vi.mock("@/components/routing/app-link", () => ({
default: ({ children, href, ...props }: { children: React.ReactNode; href: string }) => (
<a href={href} {...props}>
{children}
</a>
),
}));
vi.mock("./status-icon", () => ({
StatusIcon: () => null,
}));
vi.mock("./use-advanced-session", () => ({
useAdvancedSession: () => ({ sessionId: null, isSessionEnded: false }),
}));
vi.mock("../../components/execution-indicator", () => ({
ExecutionIndicator: ({ status }: { status: string }) => (
<div data-testid="execution-indicator">{status}</div>
),
}));
vi.mock("../../components/office-topbar-portal", () => ({
OfficeTopbarPortal: ({ children }: { children: React.ReactNode }) => <div>{children}</div>,
}));
vi.mock("@kandev/ui/label", () => ({
Label: ({ children }: { children: React.ReactNode }) => <label>{children}</label>,
}));
vi.mock("@kandev/ui/switch", () => ({
Switch: () => null,
}));
import { TaskAdvancedMode } from "./task-advanced-mode";
const baseTask: Task = {
id: "task-1",
workspaceId: "workspace-1",
identifier: "E2E-1",
title: "Advanced mode task",
status: "todo",
priority: "medium",
labels: [],
blockedBy: [],
blocking: [],
children: [],
reviewers: [],
approvers: [],
decisions: [],
createdBy: "",
createdAt: "2026-05-01T10:00:00Z",
updatedAt: "2026-05-01T10:00:00Z",
};
afterEach(() => cleanup());
describe("TaskAdvancedMode ExecutionIndicator wiring", () => {
// Regression: the detail page's store-seeded task must feed ExecutionIndicator
// the raw backend status, not the normalized canonical one, so it still tells
// SCHEDULING (live) apart from WAITING_FOR_INPUT (not live) — the same
// distinction task-row.tsx already preserves for the board/list views.
it("prefers rawStatus over status so a SCHEDULING task still shows Live", () => {
render(
<TaskAdvancedMode
task={{ ...baseTask, status: "todo", rawStatus: "SCHEDULING" } as Task}
onToggleSimple={() => {}}
/>,
);
expect(screen.getByTestId("execution-indicator").textContent).toBe("SCHEDULING");
});
it("prefers rawStatus over status so a WAITING_FOR_INPUT task does not falsely show Live", () => {
render(
<TaskAdvancedMode
task={{ ...baseTask, status: "in_progress", rawStatus: "WAITING_FOR_INPUT" } as Task}
onToggleSimple={() => {}}
/>,
);
expect(screen.getByTestId("execution-indicator").textContent).toBe("WAITING_FOR_INPUT");
});
it("falls back to status when rawStatus is absent", () => {
render(
<TaskAdvancedMode task={{ ...baseTask, status: "in_progress" }} onToggleSimple={() => {}} />,
);
expect(screen.getByTestId("execution-indicator").textContent).toBe("in_progress");
});
});
@@ -33,7 +33,7 @@ export function TaskAdvancedMode({ task, onToggleSimple }: TaskAdvancedModeProps
<span className="text-xs font-mono text-muted-foreground">{task.identifier}</span>
<span className="text-sm font-medium truncate">{task.title}</span>
<span className="flex-1" />
<ExecutionIndicator status={task.status} />
<ExecutionIndicator status={task.rawStatus ?? task.status} />
<div className="flex items-center gap-2">
<Label htmlFor="advanced-toggle" className="text-xs text-muted-foreground cursor-pointer">
{t("office:advanced")}
+4
View File
@@ -135,6 +135,10 @@ export type Task = {
title: string;
description?: string;
status: TaskStatus;
// Pre-normalization backend value (e.g. "SCHEDULING", "WAITING_FOR_INPUT"),
// preserved so ExecutionIndicator can distinguish sub-states that `status`
// collapses to the same canonical bucket. See OfficeTask.rawStatus.
rawStatus?: string;
priority: TaskPriority;
labels: TaskLabelLocal[];
assigneeAgentProfileId?: string;
@@ -1,96 +0,0 @@
import { describe, expect, it } from "vitest";
import { canonicalStatusesToBackend, normalizeTaskStatus } from "./normalize-status";
import type { OfficeTaskStatus } from "@/lib/state/slices/office/types";
describe("normalizeTaskStatus", () => {
it("returns 'backlog' for null/undefined/empty input", () => {
expect(normalizeTaskStatus(undefined)).toBe("backlog");
expect(normalizeTaskStatus(null)).toBe("backlog");
expect(normalizeTaskStatus("")).toBe("backlog");
});
it("maps backend uppercase enums to canonical office statuses", () => {
expect(normalizeTaskStatus("TODO")).toBe("todo");
expect(normalizeTaskStatus("CREATED")).toBe("todo");
expect(normalizeTaskStatus("SCHEDULING")).toBe("todo");
expect(normalizeTaskStatus("IN_PROGRESS")).toBe("in_progress");
expect(normalizeTaskStatus("WAITING_FOR_INPUT")).toBe("in_progress");
expect(normalizeTaskStatus("REVIEW")).toBe("in_review");
expect(normalizeTaskStatus("IN_REVIEW")).toBe("in_review");
expect(normalizeTaskStatus("BLOCKED")).toBe("blocked");
expect(normalizeTaskStatus("FAILED")).toBe("blocked");
expect(normalizeTaskStatus("COMPLETED")).toBe("done");
expect(normalizeTaskStatus("DONE")).toBe("done");
expect(normalizeTaskStatus("CANCELLED")).toBe("cancelled");
expect(normalizeTaskStatus("CANCELED")).toBe("cancelled");
expect(normalizeTaskStatus("BACKLOG")).toBe("backlog");
});
it("accepts already-canonical lowercase office statuses", () => {
const canonical: OfficeTaskStatus[] = [
"todo",
"in_progress",
"in_review",
"blocked",
"done",
"cancelled",
"backlog",
];
for (const s of canonical) {
expect(normalizeTaskStatus(s)).toBe(s);
}
});
it("falls back to 'backlog' for unknown statuses", () => {
expect(normalizeTaskStatus("UNKNOWN_STATE")).toBe("backlog");
expect(normalizeTaskStatus("garbage")).toBe("backlog");
});
});
describe("canonicalStatusesToBackend", () => {
it("returns an empty array for empty input", () => {
expect(canonicalStatusesToBackend([])).toEqual([]);
});
it("expands each canonical status to all backend enum aliases", () => {
expect(canonicalStatusesToBackend(["todo"])).toEqual(["TODO", "CREATED", "SCHEDULING"]);
expect(canonicalStatusesToBackend(["in_progress"])).toEqual([
"IN_PROGRESS",
"WAITING_FOR_INPUT",
]);
expect(canonicalStatusesToBackend(["in_review"])).toEqual(["REVIEW", "IN_REVIEW"]);
expect(canonicalStatusesToBackend(["blocked"])).toEqual(["BLOCKED", "FAILED"]);
expect(canonicalStatusesToBackend(["done"])).toEqual(["COMPLETED", "DONE"]);
expect(canonicalStatusesToBackend(["cancelled"])).toEqual(["CANCELLED", "CANCELED"]);
expect(canonicalStatusesToBackend(["backlog"])).toEqual(["BACKLOG"]);
});
it("concatenates expansions for multiple canonical statuses", () => {
expect(canonicalStatusesToBackend(["todo", "done"])).toEqual([
"TODO",
"CREATED",
"SCHEDULING",
"COMPLETED",
"DONE",
]);
});
it("round-trips: every backend value produced normalises back to the source canonical", () => {
const all: OfficeTaskStatus[] = [
"backlog",
"todo",
"in_progress",
"in_review",
"blocked",
"done",
"cancelled",
];
for (const canonical of all) {
const backendValues = canonicalStatusesToBackend([canonical]);
expect(backendValues.length).toBeGreaterThan(0);
for (const v of backendValues) {
expect(normalizeTaskStatus(v)).toBe(canonical);
}
}
});
});
+1 -1
View File
@@ -2,7 +2,7 @@
import { cn } from "@/lib/utils";
import type { OfficeTaskStatus } from "@/lib/state/slices/office/types";
import { normalizeTaskStatus } from "./normalize-status";
import { normalizeTaskStatus } from "@/lib/api/domains/office-task-normalize";
import { STATUS_LABEL_KEYS } from "../lib/label-keys";
import { useTranslation } from "react-i18next";
@@ -0,0 +1,83 @@
import { describe, expect, it } from "vitest";
import { groupTasksByStatus } from "./task-board";
import type { OfficeTask, OfficeTaskStatus } from "@/lib/state/slices/office/types";
const COLUMN_STATUSES: OfficeTaskStatus[] = [
"backlog",
"todo",
"in_progress",
"in_review",
"blocked",
"done",
"cancelled",
];
function task(id: string, status: OfficeTaskStatus): OfficeTask {
return {
id,
workspaceId: "workspace-1",
identifier: id.toUpperCase(),
title: id,
status,
priority: "none",
createdAt: "2026-01-01T00:00:00.000Z",
updatedAt: "2026-01-01T00:00:00.000Z",
};
}
// office.tasks.items is normalized to the canonical OfficeTaskStatus
// vocabulary at ingestion (office-slice.ts setTasks/appendTasks/
// patchTaskInStore), so groupTasksByStatus only ever sees canonical values —
// coverage for raw-backend-status mapping (e.g. "CREATED" -> "todo") lives in
// office-tasks.test.ts and normalize-status.test.ts instead.
describe("groupTasksByStatus", () => {
it("buckets a task into its matching column", () => {
const todoTask = task("todo-task", "todo");
const grouped = groupTasksByStatus([todoTask], COLUMN_STATUSES);
expect(grouped.get("todo")).toEqual([todoTask]);
});
it("buckets an in_progress task into the in_progress column", () => {
const inProgress = task("in-progress-task", "in_progress");
const grouped = groupTasksByStatus([inProgress], COLUMN_STATUSES);
expect(grouped.get("in_progress")).toEqual([inProgress]);
});
it("buckets a blocked task into the blocked column", () => {
const blocked = task("blocked-task", "blocked");
const grouped = groupTasksByStatus([blocked], COLUMN_STATUSES);
expect(grouped.get("blocked")).toEqual([blocked]);
});
it("drops a task whose status has no matching column", () => {
const garbage = task("garbage-task", "SOME_UNKNOWN_STATE" as OfficeTaskStatus);
const grouped = groupTasksByStatus([garbage], COLUMN_STATUSES);
const totalBucketed = [...grouped.values()].reduce((sum, list) => sum + list.length, 0);
expect(totalBucketed).toBe(0);
});
it("never drops a task with a canonical status: every input task lands in exactly one bucket", () => {
const tasks: OfficeTask[] = [
task("a", "backlog"),
task("b", "todo"),
task("c", "in_progress"),
task("d", "in_review"),
task("e", "blocked"),
task("f", "done"),
task("g", "cancelled"),
];
const grouped = groupTasksByStatus(tasks, COLUMN_STATUSES);
const totalBucketed = [...grouped.values()].reduce((sum, list) => sum + list.length, 0);
expect(totalBucketed).toBe(tasks.length);
});
});
+22 -8
View File
@@ -71,6 +71,24 @@ function BoardColumn({
);
}
// Board columns are keyed on the canonical lowercase OfficeTaskStatus
// vocabulary. `task.status` is normalized to that vocabulary at ingestion
// (office-slice.ts), so no per-consumer normalization is needed here.
export function groupTasksByStatus(
tasks: OfficeTask[],
columnStatuses: OfficeTaskStatus[],
): Map<OfficeTaskStatus, OfficeTask[]> {
const grouped = new Map<OfficeTaskStatus, OfficeTask[]>();
for (const status of columnStatuses) {
grouped.set(status, []);
}
for (const task of tasks) {
const list = grouped.get(task.status);
if (list) list.push(task);
}
return grouped;
}
export function TaskBoard({ tasks }: TaskBoardProps) {
const { t } = useTranslation();
const meta = useAppStore((s) => s.office.meta);
@@ -78,14 +96,10 @@ export function TaskBoard({ tasks }: TaskBoardProps) {
? meta.statuses.map((s) => ({ status: s.id as OfficeTaskStatus, label: s.label }))
: FALLBACK_COLUMNS.map((c) => ({ status: c.status, label: t(c.labelKey) }));
const grouped = new Map<OfficeTaskStatus, OfficeTask[]>();
for (const col of columns) {
grouped.set(col.status, []);
}
for (const task of tasks) {
const list = grouped.get(task.status);
if (list) list.push(task);
}
const grouped = groupTasksByStatus(
tasks,
columns.map((c) => c.status),
);
return (
<div className="flex gap-3 overflow-x-auto pb-4">
+1 -1
View File
@@ -101,7 +101,7 @@ export function TaskRow({
})}
</span>
)}
<ExecutionIndicator status={task.status} />
<ExecutionIndicator status={task.rawStatus ?? task.status} />
{agentName && (
<span className="text-xs text-muted-foreground shrink-0 truncate max-w-[100px]">
{agentName}
@@ -3,7 +3,7 @@ import { toast } from "@/lib/toast/sonner";
import { useAppStore } from "@/components/state-provider";
import { listTasks, type ListTasksParams } from "@/lib/api/domains/office-extended-api";
import type { TaskFilterState, TaskSortDir, TaskSortField } from "@/lib/state/slices/office/types";
import { canonicalStatusesToBackend } from "./normalize-status";
import { canonicalStatusesToBackend } from "@/lib/api/domains/office-task-normalize";
// Module-level `t` (resolved at call time, not import time): these strings are
// error-only and live inside a fetching effect and its callbacks. Putting the
// hook's `t` in those dep arrays would re-issue the task list request on every
@@ -33,13 +33,18 @@ const PRIORITY_ORDER = {
none: 4,
};
function task(id: string, title: string, parentId?: string): OfficeTask {
function task(
id: string,
title: string,
parentId?: string,
status: OfficeTask["status"] = "todo",
): OfficeTask {
return {
id,
workspaceId: "workspace-1",
identifier: id.toUpperCase(),
title,
status: "todo",
status,
priority: "none",
parentId,
createdAt: "2026-01-01T00:00:00.000Z",
@@ -52,10 +57,11 @@ function buildNodes(
expandedIds: Set<string>,
sortField: TaskSortField = "title",
sortDir: TaskSortDir = "asc",
filters: TaskFilterState = FILTERS,
): FlatTaskNode[] {
return buildTaskTreeNodes({
tasks,
filters: FILTERS,
filters,
sortField,
sortDir,
nestingEnabled: true,
@@ -125,4 +131,31 @@ describe("office task tree", () => {
[grandchild.id, 1, false],
]);
});
// office.tasks.items is normalized to the canonical OfficeTaskStatus
// vocabulary at ingestion (office-slice.ts setTasks/appendTasks/
// patchTaskInStore), so buildTaskTreeNodes only ever sees canonical
// values — coverage for the raw-backend-status case (e.g. "CREATED")
// lives in office-tasks.test.ts instead.
it("filters to the todo status", () => {
const todo = task("todo-task", "Todo task", undefined, "todo");
const done = task("done-task", "Done task", undefined, "done");
const nodes = buildNodes([todo, done], new Set(), "title", "asc", {
...FILTERS,
statuses: ["todo"],
});
expect(nodes.map((node) => node.task.id)).toEqual([todo.id]);
});
it("sorts by status order", () => {
const todo = task("todo-task", "Todo task", undefined, "todo");
const inProgress = task("in-progress", "In progress task", undefined, "in_progress");
const cancelled = task("cancelled", "Cancelled task", undefined, "cancelled");
const nodes = buildNodes([cancelled, inProgress, todo], new Set(), "status", "asc");
expect(nodes.map((node) => node.task.id)).toEqual([todo.id, inProgress.id, cancelled.id]);
});
});
@@ -31,7 +31,9 @@ vi.mock("@kandev/ui/tooltip", () => ({
}));
vi.mock("@/app/office/components/execution-indicator", () => ({
ExecutionIndicator: () => <div data-testid="execution-indicator" />,
ExecutionIndicator: ({ status }: { status: string }) => (
<div data-testid="execution-indicator">{status}</div>
),
}));
vi.mock("./components/topbar-working-indicator", () => ({
@@ -176,3 +178,27 @@ describe("OfficeSimplePane comment composer", () => {
expect(screen.getByTestId(CHAT_READONLY_TEST_ID).textContent).toBe(CHAT_READONLY);
});
});
describe("OfficeSimplePane ExecutionIndicator wiring", () => {
// Regression: the detail page's store-seeded task must feed ExecutionIndicator
// the raw backend status, not the normalized canonical one, so it still tells
// SCHEDULING (live) apart from WAITING_FOR_INPUT (not live) — the same
// distinction task-row.tsx already preserves for the board/list views.
it("prefers rawStatus over status so a SCHEDULING task still shows Live", () => {
renderPane({ ...baseTask, status: "todo", rawStatus: "SCHEDULING" }, []);
expect(screen.getByTestId("execution-indicator").textContent).toBe("SCHEDULING");
});
it("prefers rawStatus over status so a WAITING_FOR_INPUT task does not falsely show Live", () => {
renderPane({ ...baseTask, status: "in_progress", rawStatus: "WAITING_FOR_INPUT" }, []);
expect(screen.getByTestId("execution-indicator").textContent).toBe("WAITING_FOR_INPUT");
});
it("falls back to status when rawStatus is absent", () => {
renderPane({ ...baseTask, status: "in_progress" }, []);
expect(screen.getByTestId("execution-indicator").textContent).toBe("in_progress");
});
});
@@ -477,7 +477,7 @@ export function OfficeSimplePane({
<TaskBreadcrumb task={task} />
<TopbarWorkingIndicator taskId={task.id} />
<span className="flex-1" />
<ExecutionIndicator status={task.status} />
<ExecutionIndicator status={task.rawStatus ?? task.status} />
{onToggleAdvanced && (
<div className="flex items-center gap-2">
<Label
@@ -9,7 +9,7 @@ import { ApiError } from "@/lib/api/client";
import { updateTask } from "@/lib/api/domains/office-extended-api";
import type { Task, TaskStatus } from "@/app/office/tasks/[id]/types";
import { StatusIcon } from "@/app/office/tasks/[id]/status-icon";
import { normalizeTaskStatus } from "@/app/office/tasks/normalize-status";
import { normalizeTaskStatus } from "@/lib/api/domains/office-task-normalize";
import { useTranslation } from "react-i18next";
import { t } from "@/lib/i18n";
@@ -10,6 +10,7 @@ import { test, expect } from "../../fixtures/office-fixture";
* - Comment input hidden on office tasks
* - Topbar disappears on /office/tasks/:id
* - Role-aware avatar shown on agent detail page
* - Fresh (raw "CREATED"-status) task missing from Board/Todo filter
*/
test.describe("Office regression net", () => {
@@ -164,4 +165,52 @@ test.describe("Office regression net", () => {
// agent detail page.
await expect(testPage.locator('svg[class*="tabler-icon-robot"]')).toHaveCount(0);
});
test("board and list views place a freshly created task under Todo", async ({
testPage,
apiClient,
officeSeed,
}) => {
// A freshly created task's tasks.state DB column defaults to
// "CREATED" (buildTask in service_tasks.go) and stays there until
// something acts on it — assignment, a session start, or an explicit
// status change. dbStateToOfficeStatus only maps 7 known DB states;
// "CREATED" falls through its default branch unmapped, so the API
// returns the raw string "CREATED" rather than the canonical "todo".
// Pre-fix, task-board.tsx and use-tasks-tree.ts compared that raw
// value directly against the canonical lowercase vocabulary, so the
// task silently vanished from every board column and from the Todo
// filter in list view — reported as "task in Todo status not shown
// in Board view".
const title = "Status Normalization Regression Task";
await apiClient.createTask(officeSeed.workspaceId, title, {
workflow_id: officeSeed.workflowId,
});
await testPage.goto("/office/tasks");
// Sanity: the unfiltered list always shows it — matchesFilters
// short-circuits its status check when no status filter is active,
// so this alone would pass even pre-fix.
await expect(testPage.getByText(title)).toBeVisible({ timeout: 10_000 });
// --- Board view: pre-fix groupTasksByStatus keyed the column map on
// the raw task.status, so "CREATED" matched no column and the task
// disappeared from the board entirely.
await testPage.locator("button:has(svg.tabler-icon-columns-3)").click();
const todoColumn = testPage
.getByText("Todo", { exact: true })
.locator("xpath=ancestor::div[contains(@class,'min-w-[240px]')]");
await expect(todoColumn.getByText(title)).toBeVisible({ timeout: 10_000 });
// --- List view + Todo status filter: the server-side query already
// maps canonical "todo" to the backend states {TODO, CREATED,
// SCHEDULING} and returns the row correctly, but pre-fix the
// client-side re-filter in use-tasks-tree.ts's matchesFilters
// compared the raw status again and dropped the task a second time.
await testPage.locator("button:has(svg.tabler-icon-list)").click();
await testPage.locator("button:has(svg.tabler-icon-filter)").click();
await testPage.getByText("Todo", { exact: true }).click();
await testPage.keyboard.press("Escape");
await expect(testPage.getByText(title)).toBeVisible({ timeout: 10_000 });
});
});
@@ -1,5 +1,6 @@
import { fetchJson, fetchJsonWithRetry, type ApiRequestOptions } from "../client";
import type { DashboardData, OfficeTask } from "@/lib/state/slices/office/types";
import type { DashboardData } from "@/lib/state/slices/office/types";
import { normalizeOfficeTask, type OfficeTaskWire } from "./office-task-normalize";
const BASE = "/api/v1/office";
@@ -161,10 +162,10 @@ export type TimelineEvent = {
};
export function getTask(taskId: string, options?: ApiRequestOptions) {
return fetchJson<{ task: OfficeTask; timeline?: TimelineEvent[] }>(
return fetchJson<{ task: OfficeTaskWire; timeline?: TimelineEvent[] }>(
`${BASE}/tasks/${taskId}`,
options,
);
).then((response) => ({ ...response, task: normalizeOfficeTask(response.task) }));
}
// --- Task mutations (PATCH /tasks/:id) ---
@@ -378,10 +379,13 @@ export function searchTasks(
options?: ApiRequestOptions,
) {
const params = new URLSearchParams({ q: query, limit: String(limit) });
return fetchJson<{ tasks: OfficeTask[] }>(
return fetchJson<{ tasks: OfficeTaskWire[] }>(
`${BASE}/workspaces/${workspaceId}/tasks/search?${params.toString()}`,
options,
);
).then((response) => ({
...response,
tasks: response.tasks.map(normalizeOfficeTask),
}));
}
// --- Instructions ---
@@ -0,0 +1,83 @@
import { describe, expect, it } from "vitest";
import {
canonicalStatusesToBackend,
normalizeOfficeTask,
normalizeTaskStatus,
} from "./office-task-normalize";
import type { OfficeTask, OfficeTaskStatus } from "@/lib/state/slices/office/types";
function makeTask(status: string): OfficeTask {
return {
id: "task-1",
workspaceId: "ws-1",
identifier: "OFC-1",
title: "Test task",
status: status as OfficeTask["status"],
priority: "medium",
createdAt: "2026-01-01T00:00:00Z",
updatedAt: "2026-01-01T00:00:00Z",
};
}
describe("normalizeTaskStatus", () => {
it("returns backlog for missing input", () => {
expect(normalizeTaskStatus(undefined)).toBe("backlog");
expect(normalizeTaskStatus(null)).toBe("backlog");
expect(normalizeTaskStatus("")).toBe("backlog");
});
it("maps backend aliases to canonical Office statuses", () => {
const aliases: Record<string, OfficeTaskStatus> = {
TODO: "todo",
CREATED: "todo",
SCHEDULING: "todo",
IN_PROGRESS: "in_progress",
WAITING_FOR_INPUT: "in_progress",
REVIEW: "in_review",
IN_REVIEW: "in_review",
BLOCKED: "blocked",
FAILED: "blocked",
COMPLETED: "done",
DONE: "done",
CANCELLED: "cancelled",
CANCELED: "cancelled",
BACKLOG: "backlog",
};
for (const [raw, canonical] of Object.entries(aliases)) {
expect(normalizeTaskStatus(raw)).toBe(canonical);
}
});
it("falls back to backlog for unknown statuses", () => {
expect(normalizeTaskStatus("UNKNOWN_STATE")).toBe("backlog");
});
});
describe("normalizeOfficeTask", () => {
it("normalizes a wire task and preserves its raw status", () => {
const task = normalizeOfficeTask(makeTask("CREATED"));
expect(task.status).toBe("todo");
expect(task.rawStatus).toBe("CREATED");
});
it("normalizes nested children", () => {
const task = normalizeOfficeTask({
...makeTask("TODO"),
children: [makeTask("REVIEW")],
});
expect(task.children?.[0].status).toBe("in_review");
expect(task.children?.[0].rawStatus).toBe("REVIEW");
});
});
describe("canonicalStatusesToBackend", () => {
it("expands canonical statuses to backend aliases", () => {
expect(canonicalStatusesToBackend(["todo", "done"])).toEqual([
"TODO",
"CREATED",
"SCHEDULING",
"COMPLETED",
"DONE",
]);
});
});
@@ -1,14 +1,15 @@
import type { OfficeTaskStatus } from "@/lib/state/slices/office/types";
import type { OfficeTask, OfficeTaskStatus } from "@/lib/state/slices/office/types";
/** The task shape received before the Office status contract is applied. */
export type OfficeTaskWire = Omit<OfficeTask, "status" | "rawStatus" | "children"> & {
status: string;
rawStatus?: string | null;
children?: OfficeTaskWire[];
};
/**
* Normalises a task status string from any source (backend uppercase enum
* like "REVIEW", lowercase office canonical "in_review", or local
* detail-page TaskStatus) into the canonical OfficeTaskStatus union.
*
* Backend `task.state` uses the core task enum (TODO, REVIEW, IN_PROGRESS,
* COMPLETED, …) while office's display layer wants the lowercase
* Linear-style names (todo, in_review, done, …). This function bridges
* them so every status display can look itself up in a single map.
* Normalises a task status from any backend or local source into the
* canonical OfficeTaskStatus union.
*/
const STATUS_MAP: Record<string, OfficeTaskStatus> = {
todo: "todo",
@@ -32,10 +33,18 @@ export function normalizeTaskStatus(status: string | undefined | null): OfficeTa
return STATUS_MAP[status.toLowerCase()] ?? "backlog";
}
// Inverse of STATUS_MAP — maps the canonical office status to the set of
// backend `task.state` enum values that normalise to it. Used when sending
// status filters to the backend's filtered-task-list endpoint, which
// matches against raw task.state strings via SQL `IN (...)`.
/** Converts a wire task into the canonical Office store shape. */
export function normalizeOfficeTask(task: OfficeTaskWire): OfficeTask {
const rawStatus = task.rawStatus ?? task.status;
return {
...task,
status: normalizeTaskStatus(rawStatus),
rawStatus,
children: task.children?.map(normalizeOfficeTask),
};
}
// Inverse of STATUS_MAP. It expands canonical values for backend SQL filters.
const CANONICAL_TO_BACKEND: Record<OfficeTaskStatus, string[]> = {
backlog: ["BACKLOG"],
todo: ["TODO", "CREATED", "SCHEDULING"],
@@ -48,6 +57,6 @@ const CANONICAL_TO_BACKEND: Record<OfficeTaskStatus, string[]> = {
export function canonicalStatusesToBackend(statuses: OfficeTaskStatus[]): string[] {
const out: string[] = [];
for (const s of statuses) out.push(...(CANONICAL_TO_BACKEND[s] ?? []));
for (const status of statuses) out.push(...(CANONICAL_TO_BACKEND[status] ?? []));
return out;
}
@@ -5,6 +5,7 @@ vi.mock("@/lib/config", () => ({
getBackendConfig: () => ({ apiBaseUrl: "http://api.test" }),
}));
import { searchTasks } from "./office-extended-api";
import { listTasks, type ListTasksParams } from "./office-tasks-api";
type FetchInput = Parameters<typeof fetch>[0];
@@ -34,6 +35,7 @@ function lastUrl(): string {
}
const BASE = "http://api.test/api/v1/office/workspaces/ws-1/tasks";
const SEARCH_BASE = "http://api.test/api/v1/office/workspaces/ws-1/tasks/search";
describe("listTasks — query string building", () => {
it("emits no query string when no params are passed", async () => {
@@ -103,3 +105,40 @@ describe("listTasks — query string building", () => {
expect(lastUrl()).toContain("status=DONE");
});
});
describe("Office task API status normalization", () => {
it("normalizes raw statuses before returning paginated tasks", async () => {
fetchSpy.mockResolvedValueOnce(
new Response(JSON.stringify({ tasks: [{ id: "task-1", status: "CREATED" }] }), {
status: 200,
headers: { "Content-Type": "application/json" },
}),
);
const response = await listTasks("ws-1");
expect(response.tasks[0]).toMatchObject({
id: "task-1",
status: "todo",
rawStatus: "CREATED",
});
});
it("normalizes raw statuses before returning search results", async () => {
fetchSpy.mockResolvedValueOnce(
new Response(JSON.stringify({ tasks: [{ id: "task-1", status: "CREATED" }] }), {
status: 200,
headers: { "Content-Type": "application/json" },
}),
);
const response = await searchTasks("ws-1", "created task");
expect(lastUrl()).toBe(`${SEARCH_BASE}?q=created+task&limit=50`);
expect(response.tasks[0]).toMatchObject({
id: "task-1",
status: "todo",
rawStatus: "CREATED",
});
});
});
+13 -1
View File
@@ -4,6 +4,7 @@
// budget.
import { fetchJson, type ApiRequestOptions } from "../client";
import type { OfficeTask } from "@/lib/state/slices/office/types";
import { normalizeOfficeTask, type OfficeTaskWire } from "./office-task-normalize";
const BASE = "/api/v1/office";
@@ -33,6 +34,12 @@ export type ListTasksResponse = {
next_id?: string;
};
type ListTasksWireResponse = {
tasks: OfficeTaskWire[];
next_cursor?: string;
next_id?: string;
};
function buildTaskListQuery(params: ListTasksParams | undefined): string {
if (!params) return "";
const usp = new URLSearchParams();
@@ -75,8 +82,13 @@ export function listTasks(
params = paramsOrOptions as ListTasksParams | undefined;
options = maybeOptions;
}
return fetchJson<ListTasksResponse>(
return fetchJson<ListTasksWireResponse>(
`${BASE}/workspaces/${workspaceId}/tasks${buildTaskListQuery(params)}`,
options,
).then(
(response): ListTasksResponse => ({
...response,
tasks: response.tasks.map(normalizeOfficeTask),
}),
);
}
@@ -1,5 +1,6 @@
import type { StateCreator } from "zustand";
import type { OfficeSlice, OfficeSliceState } from "./types";
import { normalizeOfficeTask, normalizeTaskStatus } from "@/lib/api/domains/office-task-normalize";
export const defaultTaskFilters = {
statuses: [] as string[],
@@ -142,11 +143,20 @@ function createProjectActions(set: SetFn) {
};
}
type StoredTask = OfficeSlice["office"]["tasks"]["items"][number];
// Normalizes a freshly-ingested task's status, keeping the raw pre-
// normalization value on `rawStatus` for the few consumers that need a
// sub-state the canonical union collapses (see OfficeTask.rawStatus).
function normalizeIngestedTask(task: StoredTask): StoredTask {
return normalizeOfficeTask(task);
}
function createTaskActions(set: SetFn) {
return {
setTasks: (tasks: OfficeSlice["office"]["tasks"]["items"]) =>
set((draft) => {
draft.office.tasks.items = tasks;
draft.office.tasks.items = tasks.map(normalizeIngestedTask);
}),
appendTasks: (tasks: OfficeSlice["office"]["tasks"]["items"]) =>
set((draft) => {
@@ -154,18 +164,28 @@ function createTaskActions(set: SetFn) {
const existing = new Set(draft.office.tasks.items.map((t) => t.id));
for (const t of tasks) {
if (!existing.has(t.id)) {
draft.office.tasks.items.push(t);
draft.office.tasks.items.push(normalizeIngestedTask(t));
existing.add(t.id);
}
}
}),
patchTaskInStore: (
taskId: string,
patch: Partial<OfficeSlice["office"]["tasks"]["items"][number]>,
) =>
patchTaskInStore: (taskId: string, patch: Partial<StoredTask>) =>
set((draft) => {
const idx = draft.office.tasks.items.findIndex((t) => t.id === taskId);
if (idx >= 0) Object.assign(draft.office.tasks.items[idx], patch);
if (idx < 0) return;
if (patch.status === undefined) {
Object.assign(draft.office.tasks.items[idx], patch);
return;
}
// `patch.rawStatus` is only already set when the caller is restoring
// a full prior snapshot (see useOptimisticTaskMutation's rollback) —
// preserve it rather than re-deriving from the (already-canonical)
// snapshot status.
Object.assign(draft.office.tasks.items[idx], {
...patch,
rawStatus: patch.rawStatus ?? patch.status,
status: normalizeTaskStatus(patch.status),
});
}),
setTaskFilters: (filters: Partial<OfficeSlice["office"]["tasks"]["filters"]>) =>
set((draft) => {
@@ -75,3 +75,61 @@ describe("office task actions — appendTasks", () => {
expect(store.getState().office.tasks.items).toHaveLength(1);
});
});
describe("office task actions — status normalization at ingestion", () => {
it("normalizes a raw backend status on setTasks and keeps the raw value on rawStatus", () => {
const store = makeStore();
store.getState().setTasks([makeTask({ status: "CREATED" as OfficeTask["status"] })]);
expect(store.getState().office.tasks.items[0].status).toBe("todo");
expect(store.getState().office.tasks.items[0].rawStatus).toBe("CREATED");
});
it("normalizes a raw backend status on appendTasks", () => {
const store = makeStore();
store.getState().appendTasks([makeTask({ status: "COMPLETED" as OfficeTask["status"] })]);
expect(store.getState().office.tasks.items[0].status).toBe("done");
expect(store.getState().office.tasks.items[0].rawStatus).toBe("COMPLETED");
});
it("preserves a raw sub-state (SCHEDULING) that collapses into the same canonical status", () => {
const store = makeStore();
store.getState().setTasks([makeTask({ status: "SCHEDULING" as OfficeTask["status"] })]);
expect(store.getState().office.tasks.items[0].status).toBe("todo");
expect(store.getState().office.tasks.items[0].rawStatus).toBe("SCHEDULING");
});
it("normalizes a raw status patch via patchTaskInStore", () => {
const store = makeStore();
store.getState().setTasks([makeTask({ id: "task-1", status: "todo" })]);
store.getState().patchTaskInStore("task-1", { status: "SCHEDULING" as OfficeTask["status"] });
expect(store.getState().office.tasks.items[0].status).toBe("todo");
expect(store.getState().office.tasks.items[0].rawStatus).toBe("SCHEDULING");
});
it("leaves rawStatus untouched when a patch omits status", () => {
const store = makeStore();
store
.getState()
.setTasks([makeTask({ id: "task-1", status: "SCHEDULING" as OfficeTask["status"] })]);
store.getState().patchTaskInStore("task-1", { priority: "high" });
expect(store.getState().office.tasks.items[0].rawStatus).toBe("SCHEDULING");
expect(store.getState().office.tasks.items[0].priority).toBe("high");
});
it("restores the original rawStatus when patchTaskInStore is given a full prior snapshot (optimistic-mutation rollback)", () => {
const store = makeStore();
store
.getState()
.setTasks([makeTask({ id: "task-1", status: "SCHEDULING" as OfficeTask["status"] })]);
const snapshot = store.getState().office.tasks.items[0];
// Simulate an optimistic patch to a different status, then a rollback
// via the full prior snapshot — exactly what useOptimisticTaskMutation
// does on API failure.
store.getState().patchTaskInStore("task-1", { status: "in_review" });
store.getState().patchTaskInStore("task-1", snapshot);
expect(store.getState().office.tasks.items[0].status).toBe("todo");
expect(store.getState().office.tasks.items[0].rawStatus).toBe("SCHEDULING");
});
});
@@ -0,0 +1,178 @@
// --- Provider routing types ---
//
// Split out of types.ts (which grows past the file-length lint limit easily
// given how many office domains it hosts) — provider routing is a
// self-contained concern with no dependencies on the rest of the office
// slice types, so it splits cleanly.
export type Tier = "frontier" | "balanced" | "economy";
export type RoutingErrorCode =
| "auth_required"
| "missing_credentials"
| "subscription_required"
| "quota_limited"
| "rate_limited"
| ("network_unavailable" | "provider_unavailable" | "provider_overloaded" | "model_capacity")
| "model_unavailable"
| "provider_not_configured"
| "unknown_provider_error"
| "agent_runtime_error"
| "task_error"
| "repo_error"
| "permission_denied_by_user"
| "agent_transport_lost";
export type TierMap = {
frontier?: string;
balanced?: string;
economy?: string;
};
export type ProviderProfile = {
tier_map: TierMap;
execution_profile_ids?: Partial<Record<Tier, string>>;
/** @deprecated Accepted only while reading pre-migration routing payloads. */
tier_profile_ids?: Partial<Record<Tier, string>>;
mode?: string;
flags?: string[];
env?: Record<string, string>;
};
export type ExecutionProfileSummary = {
id: string;
name: string;
provider_id: string;
model: string;
mode?: string;
workspace_id?: string;
};
// Wake reasons the workspace can map onto specific tiers. v1 keeps the
// surface narrow — only the three reasons that historically used the
// cheap-profile shortcut. Adding more requires both a backend constant
// and a UI copy update so the user gets context for each row.
export type WakeReason = "heartbeat" | "routine_trigger" | "budget_alert";
export type TierPerReason = Partial<Record<WakeReason, Tier>>;
export type WorkspaceRouting = {
enabled: boolean;
provider_order: string[];
default_tier: Tier;
provider_profiles: Record<string, ProviderProfile>;
tier_per_reason?: TierPerReason;
};
export type AgentRoutingOverrides = {
provider_order_source?: "inherit" | "override" | "";
provider_order?: string[];
tier_source?: "inherit" | "override" | "";
tier?: Tier | "";
tier_per_reason_source?: "inherit" | "override" | "";
tier_per_reason?: TierPerReason;
};
export type ProviderHealthState = "healthy" | "short_retry" | "degraded" | "user_action_required";
export type ProviderHealthScope = "provider" | "model" | "tier";
export type ProviderHealth = {
workspace_id?: string;
provider_id: string;
scope: ProviderHealthScope;
scope_value: string;
state: ProviderHealthState;
error_code?: RoutingErrorCode;
retry_at?: string;
backoff_step: number;
last_failure?: string;
last_success?: string;
raw_excerpt?: string;
};
export type RouteAttemptOutcome =
| "launched"
| "retry_scheduled"
| "failed_provider_unavailable"
| "failed_other"
| "skipped_degraded"
| "skipped_user_action"
| "skipped_missing_mapping"
| "skipped_max_attempts";
export type RouteAttempt = {
seq: number;
execution_profile_id?: string;
provider_id: string;
model?: string;
tier: Tier | "";
outcome: RouteAttemptOutcome;
error_code?: RoutingErrorCode;
error_confidence?: "high" | "medium" | "low";
adapter_phase?: string;
classifier_rule?: string;
exit_code?: number;
raw_excerpt?: string;
reset_hint?: string;
started_at: string;
finished_at?: string;
};
export type ProviderModelPair = {
execution_profile_id?: string;
provider_id: string;
model: string;
tier: Tier | "";
};
export type AgentRoutePreview = {
agent_id: string;
agent_name: string;
tier_source: "inherit" | "override";
effective_tier: Tier;
// primary_* reflects configured intent — first entry in the
// effective provider order, even when that provider is currently
// skipped (degraded / missing mapping).
primary_provider_id?: string;
primary_execution_profile_id?: string;
primary_model?: string;
// current_* reflects the candidate the next launch would actually
// pick; equal to primary when not degraded. Empty when every
// candidate is skipped.
current_provider_id?: string;
current_execution_profile_id?: string;
current_model?: string;
fallback_chain: ProviderModelPair[];
missing: string[];
degraded: boolean;
};
export type AgentRouteData = {
preview: AgentRoutePreview;
/**
* Raw routing override blob persisted on the agent's settings. The
* agent routing UI hydrates from this so toggles + tier override +
* provider-order override reflect persisted values on first paint.
*/
overrides: AgentRoutingOverrides;
last_failure_code?: RoutingErrorCode;
last_failure_run?: string;
};
export type RoutingState = {
byWorkspace: Record<string, WorkspaceRouting | undefined>;
knownProviders: string[];
preview: { byWorkspace: Record<string, AgentRoutePreview[] | undefined> };
};
export type ProviderHealthSliceState = {
byWorkspace: Record<string, ProviderHealth[]>;
};
export type RunAttemptsState = {
byRunId: Record<string, RouteAttempt[]>;
};
export type AgentRoutingSliceState = {
byAgentId: Record<string, AgentRouteData | undefined>;
};
+40 -171
View File
@@ -261,6 +261,9 @@ export type OfficeTask = {
title: string;
description?: string;
status: OfficeTaskStatus;
// Pre-normalization backend value (e.g. "SCHEDULING"); read only for a raw
// sub-state the canonical union erases (ExecutionIndicator's "Live" dot).
rawStatus?: string;
priority: OfficeTaskPriority;
parentId?: string;
projectId?: string;
@@ -479,178 +482,44 @@ export type OfficeMeta = {
};
// --- Provider routing types ---
// Extracted to routing-types.ts to keep this file under the file-length
// lint limit; re-exported here so existing `from ".../office/types"`
// imports keep working unchanged.
export type Tier = "frontier" | "balanced" | "economy";
export type RoutingErrorCode =
| "auth_required"
| "missing_credentials"
| "subscription_required"
| "quota_limited"
| "rate_limited"
| ("network_unavailable" | "provider_unavailable" | "provider_overloaded" | "model_capacity")
| "model_unavailable"
| "provider_not_configured"
| "unknown_provider_error"
| "agent_runtime_error"
| "task_error"
| "repo_error"
| "permission_denied_by_user"
| "agent_transport_lost";
export type TierMap = {
frontier?: string;
balanced?: string;
economy?: string;
};
export type ProviderProfile = {
tier_map: TierMap;
execution_profile_ids?: Partial<Record<Tier, string>>;
/** @deprecated Accepted only while reading pre-migration routing payloads. */
tier_profile_ids?: Partial<Record<Tier, string>>;
mode?: string;
flags?: string[];
env?: Record<string, string>;
};
export type ExecutionProfileSummary = {
id: string;
name: string;
provider_id: string;
model: string;
mode?: string;
workspace_id?: string;
};
// Wake reasons the workspace can map onto specific tiers. v1 keeps the
// surface narrow — only the three reasons that historically used the
// cheap-profile shortcut. Adding more requires both a backend constant
// and a UI copy update so the user gets context for each row.
export type WakeReason = "heartbeat" | "routine_trigger" | "budget_alert";
export type TierPerReason = Partial<Record<WakeReason, Tier>>;
export type WorkspaceRouting = {
enabled: boolean;
provider_order: string[];
default_tier: Tier;
provider_profiles: Record<string, ProviderProfile>;
tier_per_reason?: TierPerReason;
};
export type AgentRoutingOverrides = {
provider_order_source?: "inherit" | "override" | "";
provider_order?: string[];
tier_source?: "inherit" | "override" | "";
tier?: Tier | "";
tier_per_reason_source?: "inherit" | "override" | "";
tier_per_reason?: TierPerReason;
};
export type ProviderHealthState = "healthy" | "short_retry" | "degraded" | "user_action_required";
export type ProviderHealthScope = "provider" | "model" | "tier";
export type ProviderHealth = {
workspace_id?: string;
provider_id: string;
scope: ProviderHealthScope;
scope_value: string;
state: ProviderHealthState;
error_code?: RoutingErrorCode;
retry_at?: string;
backoff_step: number;
last_failure?: string;
last_success?: string;
raw_excerpt?: string;
};
export type RouteAttemptOutcome =
| "launched"
| "retry_scheduled"
| "failed_provider_unavailable"
| "failed_other"
| "skipped_degraded"
| "skipped_user_action"
| "skipped_missing_mapping"
| "skipped_max_attempts";
export type RouteAttempt = {
seq: number;
execution_profile_id?: string;
provider_id: string;
model?: string;
tier: Tier | "";
outcome: RouteAttemptOutcome;
error_code?: RoutingErrorCode;
error_confidence?: "high" | "medium" | "low";
adapter_phase?: string;
classifier_rule?: string;
exit_code?: number;
raw_excerpt?: string;
reset_hint?: string;
started_at: string;
finished_at?: string;
};
export type ProviderModelPair = {
execution_profile_id?: string;
provider_id: string;
model: string;
tier: Tier | "";
};
export type AgentRoutePreview = {
agent_id: string;
agent_name: string;
tier_source: "inherit" | "override";
effective_tier: Tier;
// primary_* reflects configured intent — first entry in the
// effective provider order, even when that provider is currently
// skipped (degraded / missing mapping).
primary_provider_id?: string;
primary_execution_profile_id?: string;
primary_model?: string;
// current_* reflects the candidate the next launch would actually
// pick; equal to primary when not degraded. Empty when every
// candidate is skipped.
current_provider_id?: string;
current_execution_profile_id?: string;
current_model?: string;
fallback_chain: ProviderModelPair[];
missing: string[];
degraded: boolean;
};
export type AgentRouteData = {
preview: AgentRoutePreview;
/**
* Raw routing override blob persisted on the agent's settings. The
* agent routing UI hydrates from this so toggles + tier override +
* provider-order override reflect persisted values on first paint.
*/
overrides: AgentRoutingOverrides;
last_failure_code?: RoutingErrorCode;
last_failure_run?: string;
};
export type RoutingState = {
byWorkspace: Record<string, WorkspaceRouting | undefined>;
knownProviders: string[];
preview: { byWorkspace: Record<string, AgentRoutePreview[] | undefined> };
};
export type ProviderHealthSliceState = {
byWorkspace: Record<string, ProviderHealth[]>;
};
export type RunAttemptsState = {
byRunId: Record<string, RouteAttempt[]>;
};
export type AgentRoutingSliceState = {
byAgentId: Record<string, AgentRouteData | undefined>;
};
export type {
Tier,
RoutingErrorCode,
TierMap,
ProviderProfile,
ExecutionProfileSummary,
WakeReason,
TierPerReason,
WorkspaceRouting,
AgentRoutingOverrides,
ProviderHealthState,
ProviderHealthScope,
ProviderHealth,
RouteAttemptOutcome,
RouteAttempt,
ProviderModelPair,
AgentRoutePreview,
AgentRouteData,
RoutingState,
ProviderHealthSliceState,
RunAttemptsState,
AgentRoutingSliceState,
} from "./routing-types";
import type {
AgentRouteData,
AgentRoutePreview,
AgentRoutingSliceState,
ProviderHealth,
ProviderHealthSliceState,
RouteAttempt,
RoutingState,
RunAttemptsState,
WorkspaceRouting,
} from "./routing-types";
// --- Slice state & actions ---
+62
View File
@@ -13,6 +13,7 @@ function makeStore(activeWorkspaceId: string | null) {
const upsertProviderHealth = vi.fn();
const appendRunAttempt = vi.fn();
const setWorkspaceRouting = vi.fn();
const patchTaskInStore = vi.fn();
let state = {
workspaces: { items: [], activeId: activeWorkspaceId },
office: { tasks: { items: [] } },
@@ -21,6 +22,7 @@ function makeStore(activeWorkspaceId: string | null) {
upsertProviderHealth,
appendRunAttempt,
setWorkspaceRouting,
patchTaskInStore,
} as unknown as AppState;
const subs: Array<(s: AppState) => void> = [];
const store: StoreApi<AppState> = {
@@ -47,6 +49,7 @@ function makeStore(activeWorkspaceId: string | null) {
upsertProviderHealth,
appendRunAttempt,
setWorkspaceRouting,
patchTaskInStore,
};
}
@@ -131,6 +134,65 @@ describe("office WS handler — workspace filter", () => {
});
});
describe("office WS handler — task field updates delegate to patchTaskInStore", () => {
beforeEach(() => {
vi.clearAllMocks();
});
it("maps the producer state field on office.task.updated", () => {
const { store, patchTaskInStore } = makeStore(ACTIVE_WS);
const handlers = registerOfficeHandlers(store);
const handler = handlers["office.task.updated"]!;
handler({
type: "notification",
action: "office.task.updated",
payload: { workspace_id: ACTIVE_WS, task_id: "t-1", state: "SCHEDULING" },
} as Parameters<typeof handler>[0]);
expect(patchTaskInStore).toHaveBeenCalledWith(
"t-1",
expect.objectContaining({ status: "SCHEDULING" }),
);
});
it("refreshes tasks for a producer task.moved payload without a status", () => {
const { store, patchTaskInStore, setOfficeRefetchTrigger } = makeStore(ACTIVE_WS);
const handlers = registerOfficeHandlers(store);
const handler = handlers["office.task.moved"]!;
handler({
type: "notification",
action: "office.task.moved",
payload: {
workspace_id: ACTIVE_WS,
task_id: "t-1",
from_step_id: "step-todo",
to_step_id: "step-review",
session_id: "session-1",
},
} as Parameters<typeof handler>[0]);
expect(patchTaskInStore).not.toHaveBeenCalled();
expect(setOfficeRefetchTrigger).toHaveBeenCalledWith("tasks");
expect(setOfficeRefetchTrigger).toHaveBeenCalledWith("dashboard");
});
it("passes the raw new_status through on office.task.status_changed", () => {
const { store, patchTaskInStore } = makeStore(ACTIVE_WS);
const handlers = registerOfficeHandlers(store);
const handler = handlers["office.task.status_changed"]!;
handler({
type: "notification",
action: "office.task.status_changed",
payload: { workspace_id: ACTIVE_WS, task_id: "t-1", new_status: "CREATED" },
} as Parameters<typeof handler>[0]);
expect(patchTaskInStore).toHaveBeenCalledWith("t-1", { status: "CREATED" });
});
});
describe("office WS handler — approval flow events", () => {
beforeEach(() => {
vi.clearAllMocks();
+13 -15
View File
@@ -1,6 +1,12 @@
import type { StoreApi } from "zustand";
import type { AppState } from "@/lib/state/store";
import type { WsHandlers } from "@/lib/ws/handlers/types";
import type {
OfficeTask,
OfficeTaskStatus,
ProviderHealth,
RouteAttempt,
} from "@/lib/state/slices/office/types";
/**
* Registers WS handlers for office domain events.
@@ -27,17 +33,11 @@ export function registerOfficeHandlers(store: StoreApi<AppState>): WsHandlers {
return !wsId || wsId === activeId;
};
// Delegates to the store's own patchTaskInStore action rather than
// writing office.tasks.items directly, so a raw wire status (e.g.
// "SCHEDULING") gets the same normalization as API-sourced task loads.
const updateTaskStatus = (taskId: string, fields: Record<string, unknown>) => {
store.setState((state) => ({
...state,
office: {
...state.office,
tasks: {
...state.office.tasks,
items: state.office.tasks.items.map((i) => (i.id === taskId ? { ...i, ...fields } : i)),
},
},
}));
store.getState().patchTaskInStore(taskId, fields as Partial<OfficeTask>);
};
return {
@@ -256,8 +256,8 @@ function buildRoutingHandlers(
};
}
type ProviderHealthPayload = import("@/lib/state/slices/office/types").ProviderHealth;
type RouteAttemptPayload = import("@/lib/state/slices/office/types").RouteAttempt;
type ProviderHealthPayload = ProviderHealth;
type RouteAttemptPayload = RouteAttempt;
function extractProviderHealth(p: Record<string, unknown>): ProviderHealthPayload | null {
if (typeof p.provider_id !== "string" || typeof p.scope !== "string") return null;
@@ -281,6 +281,7 @@ function normalizeIssueFields(p: Record<string, unknown>): Record<string, unknow
const out: Record<string, unknown> = {};
if (p.title != null) out.title = p.title;
if (p.description != null) out.description = p.description;
if (p.state != null) out.status = p.state;
if (p.status != null) out.status = p.status;
if (p.new_status != null) out.status = p.new_status;
if (p.priority != null) out.priority = p.priority;
@@ -288,6 +289,3 @@ function normalizeIssueFields(p: Record<string, unknown>): Record<string, unknow
if (p.assignee_agent_profile_id != null) out.assigneeAgentProfileId = p.assignee_agent_profile_id;
return out;
}
// Re-import the type for the status field cast
type OfficeTaskStatus = import("@/lib/state/slices/office/types").OfficeTaskStatus;