S3.8: office people show real agent state: running, queued, cooling down, blocked or idle, from sessions, task holds and pending approvals

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
ZacharyZhang-NYandClaude Opus 5.5 committed 2026-10-09 13:51:04 -04:00
1 parent 202418f81c
commit ccd698602e
15 files changed
+164 -69

No files matched your search

+2 -2
View File
@@ -20,8 +20,8 @@ const APPROVAL_KINDS: Record<string, string> = {
};
export const approvalKindLabel = (kind: string) => labelOf("approval kind", APPROVAL_KINDS, kind);
const OFFICE_ACTIVITIES: Record<string, string> = { active: t("Active"), thinking: t("Thinking"), busy: t("Busy"), idle: t("Idle") };
export const officeActivityLabel = (activity: string) => labelOf("office activity", OFFICE_ACTIVITIES, activity);
const OFFICE_STATES: Record<string, string> = { running: t("Running"), queued: t("Queued"), cooldown: t("Cooling down"), blocked: t("Blocked"), idle: t("Idle") };
export const officeStateLabel = (state: string) => labelOf("office state", OFFICE_STATES, state);
const APPROVAL_STATES: Record<string, string> = { pending: t("Pending"), approved: t("Approved"), declined: t("Declined"), superseded: t("Superseded") };
export const approvalStateLabel = (state: string) => labelOf("approval state", APPROVAL_STATES, state);
+2 -2
View File
@@ -256,12 +256,13 @@ export const zh: Record<string, string> = {
"Base URL is required.": "请填写基础 URL。",
"Billing": "用量账单",
"Blocked": "受阻",
"Cooling down": "冷却中",
"Waiting for your approval.": "正在等待你的审批。",
"Board": "看板",
"Browse": "浏览",
"Budget": "预算",
"Budget cap": "预算上限",
"Budget utilization": "预算使用率",
"Busy": "忙碌",
"By agent": "按智能体",
"CEO": "CEO",
"CEO OFFICE": "CEO 办公室",
@@ -579,7 +580,6 @@ export const zh: Record<string, string> = {
"Team": "团队",
"Tests": "测试",
"Theme": "主题",
"Thinking": "思考中",
"Progress": "进度",
"Title": "标题",
"Today": "今天",
+10 -21
View File
@@ -5,12 +5,13 @@ import { activeOrgId, requireActiveEmployee } from "./types";
import { hashString, renderOffice, type RenderPerson } from "./office/office-renderer";
import { OfficeSimulation, cameraFor, type MoveInput } from "./office/simulation";
import { AgentDetailCard } from "./office/AgentDetailCard";
import { officeActivityLabel } from "../i18n/labels";
import { officeStateLabel } from "../i18n/labels";
import { cardPositionFor, CARD_FALLBACK_H } from "./office/agent-card-position";
import { isoFrame, isoPixelSize, pickAt, toIso, type IsoFrame } from "./office/iso";
import { dayPart, season } from "../shared/palette-clock";
import { pixelScale } from "../shared/pixel-scale";
import type { AgentActivity, Camera, OfficeAgentInput } from "./office/office-types";
import type { Camera, OfficeAgentInput } from "./office/office-types";
import { officeStatus } from "./office/agent-state";
import { t } from "../i18n/i18n";
const FPS_INTERVAL = 1000 / 24;
@@ -44,7 +45,8 @@ export function OfficePage({ snapshot }: PageProps) {
const orgAgents = snapshot.agents
.filter((agent) => agent.orgId === orgId && agent.lifecycle === "active")
.sort((a, b) => a.createdAt.localeCompare(b.createdAt) || a.id.localeCompare(b.id));
const orgTasks = snapshot.tasks.filter((task) => task.orgId === orgId && isBusinessTask(task));
const allTasks = snapshot.tasks.filter((task) => task.orgId === orgId);
const orgTasks = allTasks.filter(isBusinessTask);
return orgAgents.map((agent) => ({
id: agent.id,
name: agent.name,
@@ -52,10 +54,10 @@ export function OfficePage({ snapshot }: PageProps) {
title: agent.title,
department: agent.department,
roleLevel: agent.roleLevel,
activity: resolveActivity(snapshot.chatPresence, snapshot.taskRuns, agent.id),
status: officeStatus(agent.id, { runs: snapshot.taskRuns, tasks: allTasks, approvals: snapshot.approvals }),
taskTitle: orgTasks.find((task) => task.assigneeAgentId === agent.id && task.status === "in_progress")?.title ?? null,
}));
}, [snapshot.agents, snapshot.tasks, snapshot.chatPresence, snapshot.taskRuns, orgId]);
}, [snapshot.agents, snapshot.tasks, snapshot.approvals, snapshot.taskRuns, orgId]);
useEffect(() => {
simRef.current!.sync(inputs);
@@ -149,7 +151,7 @@ export function OfficePage({ snapshot }: PageProps) {
const people: RenderPerson[] = sim.agents.map((agent) => ({
id: agent.input.id,
name: agent.input.name,
activity: agent.input.activity,
state: agent.input.status.state,
x: agent.x,
y: agent.y,
desk: agent.isCeo ? sim.plan.ceoDesk : sim.plan.desks[agent.deskIndex!].bounds,
@@ -165,7 +167,7 @@ export function OfficePage({ snapshot }: PageProps) {
people.push({
id: `board:${sim.player.employeeId}`,
name: sim.player.name,
activity: "idle",
state: "idle",
x: sim.player.x,
y: sim.player.y,
desk: null,
@@ -262,22 +264,9 @@ export function OfficePage({ snapshot }: PageProps) {
style={{ width: 200 }}
>
<option value="">{t("Show a person…")}</option>
{inputs.map((input) => <option key={input.id} value={input.id}>{t("{name}, {activity}", { name: input.name, activity: officeActivityLabel(input.activity) })}</option>)}
{inputs.map((input) => <option key={input.id} value={input.id}>{t("{name}, {activity}", { name: input.name, activity: officeStateLabel(input.status.state) })}</option>)}
</select>
</div>
</div>
);
}
function resolveActivity(
presence: Array<{ principalType: string; principalId: string; status: string }>,
runs: Array<{ agentId: string | null; state: string }>,
agentId: string,
): AgentActivity {
if (runs.some((run) => run.agentId === agentId && run.state === "running")) return "active";
const record = presence.find((row) => row.principalType === "agent" && row.principalId === agentId);
if (record?.status === "thinking") return "thinking";
if (record?.status === "waiting_approval" || record?.status === "busy") return "busy";
if (record?.status === "active" || record?.status === "online") return "active";
return "idle";
}
@@ -1,13 +1,15 @@
import { Avatar, Icon, IconButton, Pill } from "../../shared/ui";
import { officeActivityLabel, roleLevelLabel } from "../../i18n/labels";
import { officeStateLabel, roleLevelLabel } from "../../i18n/labels";
import { t } from "../../i18n/i18n";
import type { AgentActivity, OfficeAgentInput } from "./office-types";
import { TaskHoldNotice } from "../work/TaskHoldNotice";
import type { OfficeAgentInput } from "./office-types";
import type { OfficeState } from "./agent-state";
const ACTIVITY_TONE: Record<AgentActivity, "green" | "violet" | "amber" | undefined> = {
active: "green",
thinking: "violet",
busy: "amber",
const STATE_TONE: Record<OfficeState, "green" | "amber" | "red" | undefined> = {
running: "green",
queued: undefined,
cooldown: "amber",
blocked: "red",
idle: undefined,
};
@@ -39,8 +41,9 @@ export function AgentDetailCard({ agent, onClose }: { agent: OfficeAgentInput; o
<div className="row" style={{ gap: 6, marginTop: 10, flexWrap: "wrap" }}>
<Pill>{agent.department}</Pill>
<Pill>{roleLevelLabel(agent.roleLevel)}</Pill>
<Pill tone={ACTIVITY_TONE[agent.activity]} dot>{officeActivityLabel(agent.activity)}</Pill>
<Pill tone={STATE_TONE[agent.status.state]} dot>{officeStateLabel(agent.status.state)}</Pill>
</div>
{agent.status.hold && <div style={{ marginTop: 10 }}><TaskHoldNotice hold={agent.status.hold} /></div>}
{agent.taskTitle && (
<div className="row" style={{ gap: 7, marginTop: 12, alignItems: "flex-start" }}>
<Icon name="work" color="var(--text-3)" />
@@ -0,0 +1,33 @@
import type { TaskHold } from "../../../../shared/domain";
import { t } from "../../i18n/i18n";
/** What an agent in the office is doing, from real records: a running session, work held for the operator or a provider cooldown, a queued session, or nothing. */
export type OfficeState = "running" | "blocked" | "cooldown" | "queued" | "idle";
export interface OfficeStatus {
state: OfficeState;
/** What holds a blocked or cooling-down agent. */
hold: TaskHold | null;
}
interface Inputs {
runs: Array<{ agentId: string | null; state: string }>;
tasks: Array<{ id: string; assigneeAgentId: string | null; status: string; hold: TaskHold | null }>;
approvals: Array<{ sourceEntityType: string | null; sourceEntityId: string | null; state: string }>;
}
/** The one state an agent shows: running beats blocked, which beats a cooldown, which beats a queued session. */
export function officeStatus(agentId: string, inputs: Inputs): OfficeStatus {
const runs = inputs.runs.filter((run) => run.agentId === agentId);
if (runs.some((run) => run.state === "running")) return { state: "running", hold: null };
const open = inputs.tasks.filter((task) => task.assigneeAgentId === agentId && task.status !== "done");
const holds = open.filter((task) => task.hold !== null).map((task) => task.hold!);
const operator = holds.find((hold) => hold.waitingOn === "operator");
if (operator) return { state: "blocked", hold: operator };
const awaitingApproval = inputs.approvals.some((approval) => approval.state === "pending" && approval.sourceEntityType === "task" && open.some((task) => task.id === approval.sourceEntityId));
if (awaitingApproval) return { state: "blocked", hold: { reason: t("Waiting for your approval."), waitingOn: "operator", resumesAt: null } };
const provider = holds.find((hold) => hold.waitingOn === "provider");
if (provider) return { state: "cooldown", hold: provider };
if (runs.some((run) => run.state === "queued")) return { state: "queued", hold: null };
return { state: "idle", hold: null };
}
@@ -1,4 +1,5 @@
import type { AgentActivity, Camera, FloorPlan, Rect } from "./office-types";
import type { Camera, FloorPlan, Rect } from "./office-types";
import type { OfficeState } from "./agent-state";
import { drawBackWalls, drawGround, sceneDrawables, type Drawable } from "./scene-renderer";
import { depthOf, toIso, type IsoFrame } from "./iso";
import { skyScene, weatherFor } from "./sky";
@@ -8,7 +9,7 @@ import type { DayPart, Season } from "../../shared/palette-clock";
export interface RenderPerson {
id: string;
name: string;
activity: AgentActivity;
state: OfficeState;
x: number;
y: number;
desk: Rect | null;
@@ -41,10 +42,12 @@ export interface OfficeFrame {
selectedId: string | null;
}
const ACTIVITY_DOT: Partial<Record<AgentActivity, string>> = {
active: "#34d399",
thinking: "#818cf8",
busy: "#f59e0b",
/** Marker over the head: green running, grey queued, amber cooling down, red blocked; none when idle. */
const STATE_MARK: Partial<Record<OfficeState, string>> = {
running: "#34d399",
queued: "#9aa3b2",
cooldown: "#f59e0b",
blocked: "#ef4444",
};
/** The day part washes over the whole floor: warm at dawn and dusk, blue at night. */
@@ -78,8 +81,8 @@ export function renderOffice(ctx: CanvasRenderingContext2D, scene: HTMLCanvasEle
drawGround(sctx, input.frame, input.plan);
drawBackWalls(sctx, input.frame, input.plan, { sky: skyScene(input.clock), weather: weatherFor(input.clock), clock: input.clock, timeMs: input.timeMs });
const lit = new Set(input.people.filter((person) => person.desk && (person.activity === "active" || person.activity === "thinking")).map((person) => person.desk!));
const items: Drawable[] = [...sceneDrawables(input.frame, input.plan, input.season, lit, input.timeMs), ...input.people.map((person) => personDrawable(input, person))];
const screens = new Map(input.people.filter((person) => person.desk).map((person) => [person.desk!, person.state]));
const items: Drawable[] = [...sceneDrawables(input.frame, input.plan, input.season, screens, input.timeMs), ...input.people.map((person) => personDrawable(input, person))];
items.sort((a, b) => a.depth - b.depth);
for (const item of items) item.draw(sctx);
@@ -109,7 +112,7 @@ function personDrawable(input: OfficeFrame, person: RenderPerson): Drawable {
ctx.fillRect(Math.round(feet.x) - 14, Math.round(feet.y) - 2, 2, 2);
ctx.fillRect(Math.round(feet.x) + 12, Math.round(feet.y) - 2, 2, 2);
}
const working = (person.activity === "active" || person.activity === "thinking") && person.walkPhase === 0;
const working = person.state === "running" && person.walkPhase === 0;
drawPerson(ctx, feet.x, feet.y, person.appearance, {
facing: person.facing,
walkPhase: person.walkPhase,
@@ -118,7 +121,7 @@ function personDrawable(input: OfficeFrame, person: RenderPerson): Drawable {
timeMs: input.timeMs,
animSeed: person.animSeed,
}, person.isCeo);
const dot = ACTIVITY_DOT[person.activity];
const dot = STATE_MARK[person.state];
if (dot) {
ctx.fillStyle = dot;
ctx.fillRect(Math.round(feet.x) + 8, Math.round(feet.y) - 40, 4, 4);
@@ -1,3 +1,4 @@
import type { OfficeStatus } from "./agent-state";
export interface Vec {
x: number;
y: number;
@@ -61,8 +62,6 @@ export interface FloorPlan {
entrance: Vec;
}
export type AgentActivity = "active" | "thinking" | "busy" | "idle";
/** Snapshot-derived facts the office simulation needs about one agent. */
export interface OfficeAgentInput {
id: string;
@@ -71,7 +70,7 @@ export interface OfficeAgentInput {
title: string;
department: string;
roleLevel: string;
activity: AgentActivity;
status: OfficeStatus;
taskTitle: string | null;
}
+18 -4
View File
@@ -1,3 +1,4 @@
import type { OfficeState } from "./agent-state";
import type { Rect } from "./office-types";
import { drawBox, setSouthFaceTransform, toIso, type IsoFrame } from "./iso";
@@ -31,14 +32,14 @@ function hand(ctx: CanvasRenderingContext2D, cx: number, cy: number, angle: numb
}
}
/** A desk monitor's screen on its south face: scrolling code and a blinking cursor while its agent works, a standby light otherwise. */
export function drawMonitorScreen(ctx: CanvasRenderingContext2D, frame: IsoFrame, monitor: Rect, z: number, height: number, on: boolean, timeMs: number, seed: number): void {
/** A desk monitor's screen on its south face: scrolling code while running, a progress dot for queued, an amber sweep for a cooldown, a blinking red mark when blocked, a standby light when idle. */
export function drawMonitorScreen(ctx: CanvasRenderingContext2D, frame: IsoFrame, monitor: Rect, z: number, height: number, state: OfficeState, timeMs: number, seed: number): void {
ctx.save();
setSouthFaceTransform(ctx, frame, monitor.y + monitor.h, z + height);
const glass = { x: monitor.x + 2, y: 2, w: monitor.w - 4, h: height - 4 };
ctx.fillStyle = on ? "#0f2a1c" : "#20242c";
ctx.fillStyle = state === "running" ? "#0f2a1c" : state === "blocked" ? "#2a1012" : "#20242c";
ctx.fillRect(glass.x, glass.y, glass.w, glass.h);
if (on) {
if (state === "running") {
const scroll = Math.floor(timeMs / 350);
ctx.fillStyle = "#37d38a";
for (let row = 0; row < 4; row++) {
@@ -46,6 +47,19 @@ export function drawMonitorScreen(ctx: CanvasRenderingContext2D, frame: IsoFrame
ctx.fillRect(glass.x + 1 + (row % 2) * 2, glass.y + 1 + row * 2, width, 1);
}
if (Math.floor(timeMs / 500) % 2 === 0) ctx.fillRect(glass.x + glass.w - 3, glass.y + glass.h - 2, 2, 1);
} else if (state === "queued") {
ctx.fillStyle = "#9aa3b2";
for (let dot = 0; dot <= Math.floor(timeMs / 400) % 3; dot++) ctx.fillRect(glass.x + 2 + dot * 3, glass.y + glass.h / 2, 2, 1);
} else if (state === "cooldown") {
ctx.fillStyle = "#e0a23a";
ctx.fillRect(glass.x + 1, glass.y + glass.h / 2, Math.max(1, (glass.w - 2) * (1 - ((timeMs / 4000) % 1))), 1);
} else if (state === "blocked") {
if (Math.floor(timeMs / 500) % 2 === 0) {
ctx.fillStyle = "#ef4444";
const cx = Math.round(glass.x + glass.w / 2);
ctx.fillRect(cx, glass.y + 1, 1, glass.h - 4);
ctx.fillRect(cx, glass.y + glass.h - 2, 1, 1);
}
} else {
ctx.fillStyle = "#e0a23a";
ctx.fillRect(glass.x + glass.w - 2, glass.y + glass.h - 1, 1, 1);
@@ -1,3 +1,4 @@
import type { OfficeState } from "./agent-state";
import type { FloorPlan, Rect, RoomSpec } from "./office-types";
import { WALL } from "./floor-plan";
import { drawWeather, drawWindowSky, type SkyScene, type Weather } from "./sky";
@@ -179,8 +180,8 @@ function centerDepth(rect: Rect): number {
return depthOf({ x: rect.x + rect.w / 2, y: rect.y + rect.h / 2 });
}
/** Interior walls, furniture and desks as blocks, each sorted by depth against the people; `lit` desks have their screen on, and `timeMs` animates screens and steam. */
export function sceneDrawables(frame: IsoFrame, plan: FloorPlan, season: Season, lit: Set<Rect>, timeMs: number): SceneDrawable[] {
/** Interior walls, furniture and desks as blocks, each sorted by depth against the people; `screens` gives each desk's screen its agent's state, and `timeMs` animates screens and steam. */
export function sceneDrawables(frame: IsoFrame, plan: FloorPlan, season: Season, screens: Map<Rect, OfficeState>, timeMs: number): SceneDrawable[] {
const items: SceneDrawable[] = [];
// Long blocks go in pieces so each sorts against nearby people on its own.
const block = (foot: Rect, height: number, colors: BoxColors, z = 0, support?: Rect) => {
@@ -197,7 +198,7 @@ export function sceneDrawables(frame: IsoFrame, plan: FloorPlan, season: Season,
// CEO office: rug, executive desk, sofa, plant.
block({ x: 32, y: 96, w: 24, h: 72 }, 10, faces("#7a4a41"));
plant(280, 40);
deskBlocks(plan.ceoDesk, true, lit.has(plan.ceoDesk), timeMs).forEach((item) => items.push(item(frame)));
deskBlocks(plan.ceoDesk, true, screens.get(plan.ceoDesk) ?? "idle", timeMs).forEach((item) => items.push(item(frame)));
// Meeting room: table and chairs.
block({ x: 392, y: 120, w: 160, h: 64 }, 12, faces("#75593b"));
for (let i = 0; i < 3; i++) {
@@ -229,12 +230,12 @@ export function sceneDrawables(frame: IsoFrame, plan: FloorPlan, season: Season,
items.push({ foot: basin, depth: restingDepth(basin, sinks), draw: (ctx) => drawBasin(ctx, frame, basin, 14) });
}
for (const [cx, cy] of [[312, 300], [632, 300], [904, 300]] as const) plant(cx, cy);
for (const desk of plan.desks) deskBlocks(desk.bounds, false, lit.has(desk.bounds), timeMs).forEach((item) => items.push(item(frame)));
for (const desk of plan.desks) deskBlocks(desk.bounds, false, screens.get(desk.bounds) ?? "idle", timeMs).forEach((item) => items.push(item(frame)));
return items;
}
/** A desk, its monitor and its chair, each a separate block so a seated person sorts between them. */
function deskBlocks(desk: Rect, executive: boolean, screenOn: boolean, timeMs: number): Array<(frame: IsoFrame) => SceneDrawable> {
function deskBlocks(desk: Rect, executive: boolean, screen: OfficeState, timeMs: number): Array<(frame: IsoFrame) => SceneDrawable> {
const monitor = deskMonitor(desk);
return [
...split(desk).map((piece) => (frame: IsoFrame) => blockDrawable(frame, piece, 14, faces(executive ? PALETTE.deskExec : PALETTE.desk))),
@@ -242,7 +243,7 @@ function deskBlocks(desk: Rect, executive: boolean, screenOn: boolean, timeMs: n
const shell = blockDrawable(frame, monitor, 12, { top: PALETTE.monitor, south: PALETTE.monitor, east: shade(PALETTE.monitor, 0.7) }, 14, desk);
return { foot: monitor, depth: shell.depth, draw: (ctx) => {
shell.draw(ctx);
drawMonitorScreen(ctx, frame, monitor, 14, 12, screenOn, timeMs, Math.round(desk.x + desk.y));
drawMonitorScreen(ctx, frame, monitor, 14, 12, screen, timeMs, Math.round(desk.x + desk.y));
} };
},
(frame) => blockDrawable(frame, { x: desk.x + desk.w / 2 - 8, y: desk.y + desk.h + 6, w: 16, h: 12 }, 9, faces(PALETTE.chair)),
+3 -3
View File
@@ -116,7 +116,7 @@ export class OfficeSimulation {
switch (agent.behavior.kind) {
case "seated": {
agent.settledMs += dt;
if (agent.input.activity === "idle" && agent.settledMs >= SETTLE_MS && this.random() < dt / MEAN_TRIP_INTERVAL_MS) {
if (agent.input.status.state === "idle" && agent.settledMs >= SETTLE_MS && this.random() < dt / MEAN_TRIP_INTERVAL_MS) {
agent.settledMs = 0;
this.startTrip(agent);
}
@@ -160,7 +160,7 @@ export class OfficeSimulation {
conveneMeeting(): number {
const spots = this.plan.amenities.filter((spot) => spot.kind === "meeting");
const candidates = this.agents.filter(
(agent) => agent.input.activity === "idle" && agent.behavior.kind === "seated" && agent.settledMs >= SETTLE_MS,
(agent) => agent.input.status.state === "idle" && agent.behavior.kind === "seated" && agent.settledMs >= SETTLE_MS,
);
if (candidates.length < 2 || spots.length < 2) return 0;
const size = Math.min(spots.length, candidates.length, 2 + Math.floor(this.random() * 3));
@@ -309,7 +309,7 @@ export class OfficeSimulation {
settledMs: prev?.settledMs ?? 0,
};
const onBreak = agent.behavior.kind === "dwelling" || (agent.behavior.kind === "walking" && agent.behavior.destination === "amenity");
const workStarted = input.activity !== "idle" && onBreak;
const workStarted = input.status.state !== "idle" && onBreak;
if (workStarted || (planRebuilt && prev && (agent.x !== seat.x || agent.y !== seat.y))) {
this.sendHome(agent);
}
+2 -1
View File
@@ -154,7 +154,8 @@ test("the list width, office people and chart values all work from the keyboard
await page.getByRole("button", { name: "Office", exact: true }).click();
await expect(page.getByRole("img", { name: /People here: 1./ })).toBeVisible();
const people = page.getByRole("combobox", { name: "Show a person" });
await people.selectOption({ label: "CEO, Idle" });
// The only person is the CEO, listed after the empty choice with whatever state their real work gives them.
await people.selectOption({ index: 1 });
await expect(page.getByRole("dialog", { name: "Agent CEO" })).toBeVisible();
await people.selectOption({ label: "Show a person…" });
await expect(page.getByRole("dialog", { name: "Agent CEO" })).toHaveCount(0);
+16
View File
@@ -3,6 +3,7 @@ import { mkdirSync } from "node:fs";
import { tmpdir } from "node:os";
import { join, resolve } from "node:path";
import { randomUUID } from "node:crypto";
import { DatabaseSync } from "node:sqlite";
import { buildFloorPlan } from "../../src/renderer/src/pages/office/floor-plan";
import { cameraFor } from "../../src/renderer/src/pages/office/simulation";
import { isoFrame, isoPixelSize, toIso } from "../../src/renderer/src/pages/office/iso";
@@ -78,6 +79,21 @@ test("the office floor opens agent details on click", async () => {
await page.keyboard.press("Escape");
await expect(card).not.toBeVisible();
// The office shows real state: a blocked task makes its agent blocked, with the reason on the card.
const db = new DatabaseSync(join(profileDir, "agentcompany.db"));
const blocked = db.prepare("update tasks set blocked_reason = 'needs the staging key' where assignee_agent_id = (select id from agents where name = 'CEO') and status <> 'done'").run();
db.close();
expect(blocked.changes).toBeGreaterThan(0);
await app.evaluate(({ BrowserWindow }) => {
BrowserWindow.getAllWindows()[0]!.webContents.send("agentcompany:renderer-event", { type: "snapshot-changed", version: Number.MAX_SAFE_INTEGER });
});
await expect(page.getByLabel("Show a person").locator("option", { hasText: "CEO, Blocked" })).toHaveCount(1);
await page.getByLabel("Show a person").selectOption({ label: "CEO, Blocked" });
await expect(card.getByText("Blocked: needs the staging key")).toBeVisible();
await expect(card.getByText("Waiting on you.")).toBeVisible();
await page.keyboard.press("Escape");
await expect(card).not.toBeVisible();
// Clicking empty corridor floor selects nothing.
const corridor = onScreen(plan, box!, ratio, { x: 500, y: 320 }, 0);
await page.mouse.click(corridor.x, corridor.y);
+35
View File
@@ -0,0 +1,35 @@
import { expect, test } from "vitest";
import { officeStatus } from "../../src/renderer/src/pages/office/agent-state";
import type { TaskHold } from "../../src/shared/domain";
const blocked: TaskHold = { reason: "Blocked: needs the staging key", waitingOn: "operator", resumesAt: null };
const cooling: TaskHold = { reason: "Provider is cooling down", waitingOn: "provider", resumesAt: "2026-10-09T14:00:00.000Z" };
const task = (hold: TaskHold | null, status = "in_progress", assigneeAgentId: string | null = "agt_a", id = "tsk_1") => ({ id, assigneeAgentId, status, hold });
const pending = (taskId: string, state = "pending") => ({ sourceEntityType: "task", sourceEntityId: taskId, state });
const none = { runs: [], tasks: [], approvals: [] };
test("each state comes from real records", () => {
expect(officeStatus("agt_a", none)).toEqual({ state: "idle", hold: null });
expect(officeStatus("agt_a", { ...none, runs: [{ agentId: "agt_a", state: "queued" }] }).state).toBe("queued");
expect(officeStatus("agt_a", { ...none, runs: [{ agentId: "agt_a", state: "running" }] }).state).toBe("running");
expect(officeStatus("agt_a", { ...none, tasks: [task(cooling)] })).toEqual({ state: "cooldown", hold: cooling });
expect(officeStatus("agt_a", { ...none, tasks: [task(blocked)] })).toEqual({ state: "blocked", hold: blocked });
// A pending approval on the agent's open task blocks the agent before any hold is written.
expect(officeStatus("agt_a", { ...none, tasks: [task(null)], approvals: [pending("tsk_1")] })).toEqual({ state: "blocked", hold: { reason: "Waiting for your approval.", waitingOn: "operator", resumesAt: null } });
});
test("running beats blocked, blocked beats cooldown, cooldown beats queued", () => {
const queued = { agentId: "agt_a", state: "queued" };
expect(officeStatus("agt_a", { ...none, runs: [{ agentId: "agt_a", state: "running" }], tasks: [task(blocked)] }).state).toBe("running");
expect(officeStatus("agt_a", { ...none, runs: [queued], tasks: [task(cooling), task(blocked)] }).state).toBe("blocked");
expect(officeStatus("agt_a", { ...none, runs: [queued], tasks: [task(cooling)], approvals: [pending("tsk_1")] }).state).toBe("blocked");
expect(officeStatus("agt_a", { ...none, runs: [queued], tasks: [task(cooling)] }).state).toBe("cooldown");
});
test("only this agent's open work counts", () => {
expect(officeStatus("agt_a", { ...none, runs: [{ agentId: "agt_b", state: "running" }] }).state).toBe("idle");
expect(officeStatus("agt_a", { ...none, tasks: [task(blocked, "in_progress", "agt_b")] }).state).toBe("idle");
expect(officeStatus("agt_a", { ...none, tasks: [task(blocked, "done")] }).state).toBe("idle");
expect(officeStatus("agt_a", { ...none, tasks: [task(null, "in_progress", "agt_b")], approvals: [pending("tsk_1")] }).state).toBe("idle");
expect(officeStatus("agt_a", { ...none, tasks: [task(null)], approvals: [pending("tsk_1", "approved")] }).state).toBe("idle");
});
+1 -1
View File
@@ -84,7 +84,7 @@ describe("office depth order", () => {
test("every piece of the scene sorts by a footprint no longer than 32 on either side, so a person beside a long block is never painted over", () => {
const plan = buildFloorPlan(12);
const items = sceneDrawables(isoFrame(plan.world), plan, "winter", new Set(), 0);
const items = sceneDrawables(isoFrame(plan.world), plan, "winter", new Map(), 0);
const oversized = items.filter((item) => item.foot.w > 32 || item.foot.h > 32).map((item) => item.foot);
expect(oversized).toEqual([]);
// A person at the left basin sorts after both the counter piece and the basin in front of them.
+9 -8
View File
@@ -1,10 +1,11 @@
import { describe, expect, test } from "vitest";
import { OfficeSimulation, cameraFor, type MoveInput } from "../../src/renderer/src/pages/office/simulation";
import type { AgentActivity, OfficeAgentInput } from "../../src/renderer/src/pages/office/office-types";
import type { OfficeAgentInput } from "../../src/renderer/src/pages/office/office-types";
import type { OfficeState } from "../../src/renderer/src/pages/office/agent-state";
const IDLE: MoveInput = { up: false, down: false, left: false, right: false };
function agentInput(id: string, roleLevel = "staff", activity: AgentActivity = "idle"): OfficeAgentInput {
function agentInput(id: string, roleLevel = "staff", state: OfficeState = "idle"): OfficeAgentInput {
return {
id,
name: `Agent ${id}`,
@@ -12,7 +13,7 @@ function agentInput(id: string, roleLevel = "staff", activity: AgentActivity = "
title: "Engineer",
department: "Engineering",
roleLevel,
activity,
status: { state, hold: null },
taskTitle: null,
};
}
@@ -121,10 +122,10 @@ describe("office simulation", () => {
expect(agent.carrying).toBe("coffee");
});
test("agents with a run, thinking or busy never leave their desk", () => {
for (const activity of ["active", "thinking", "busy"] as const) {
test("agents running, queued, cooling down or blocked never leave their desk", () => {
for (const state of ["running", "queued", "cooldown", "blocked"] as const) {
const sim = new OfficeSimulation(() => 0);
sim.sync([agentInput("ceo", "c_suite", activity), agentInput("a", "staff", activity)]);
sim.sync([agentInput("ceo", "c_suite", state), agentInput("a", "staff", state)]);
for (let i = 0; i < 6000; i++) {
sim.step(16, IDLE);
for (const agent of sim.agents) expect(agent.behavior.kind).toBe("seated");
@@ -144,7 +145,7 @@ describe("office simulation", () => {
sim.sync([agentInput("a")]);
expect(sim.agents[0].behavior.kind === "walking" && sim.agents[0].behavior.destination).toBe("amenity");
sim.sync([agentInput("a", "staff", "active")]);
sim.sync([agentInput("a", "staff", "running")]);
const working = sim.agents[0];
expect(working.behavior.kind === "walking" && working.behavior.destination).toBe("seat");
for (let i = 0; i < 6000 && working.behavior.kind !== "seated"; i++) {
@@ -157,7 +158,7 @@ describe("office simulation", () => {
test("only idle agents gather around the table, and anyone with a run stays at their desk", () => {
const sim = new OfficeSimulation(() => 0);
sim.sync([agentInput("a"), agentInput("b"), agentInput("ceo", "c_suite", "active")]);
sim.sync([agentInput("a"), agentInput("b"), agentInput("ceo", "c_suite", "running")]);
// Nobody has settled yet: a meeting cannot be convened.
expect(sim.conveneMeeting()).toBe(0);
for (const agent of sim.agents) agent.settledMs = 30_000;